From 10f85074e8fdc6564bf27cf9c74406930c198c21 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Thu, 12 Feb 2026 10:00:32 +0800 Subject: [PATCH] test: add comprehensive unit and integration tests for dataset module (#32187) Co-authored-by: CodingOnStar Co-authored-by: Cursor --- .../datasets/create-dataset-flow.test.tsx | 301 ++ .../datasets/dataset-settings-flow.test.tsx | 451 +++ .../datasets/document-management.test.tsx | 335 ++ .../datasets/external-knowledge-base.test.tsx | 215 ++ .../datasets/hit-testing-flow.test.tsx | 404 +++ .../metadata-management-flow.test.tsx | 337 ++ .../pipeline-datasource-flow.test.tsx | 477 +++ web/__tests__/datasets/segment-crud.test.tsx | 301 ++ .../base/chat/embedded-chatbot/hooks.spec.tsx | 6 +- .../datasets/__tests__/chunk.spec.tsx | 309 ++ .../datasets/{ => __tests__}/loading.spec.tsx | 2 +- .../no-linked-apps-panel.spec.tsx | 15 +- .../api/{ => __tests__}/index.spec.tsx | 2 +- web/app/components/datasets/chunk.spec.tsx | 111 - .../check-rerank-model.spec.ts | 2 +- .../chunking-mode-label.spec.tsx | 2 +- .../{ => __tests__}/credential-icon.spec.tsx | 2 +- .../document-file-icon.spec.tsx | 2 +- .../__tests__/document-list.spec.tsx | 49 + .../{ => __tests__}/index.spec.tsx | 35 +- .../preview-document-picker.spec.tsx | 69 +- .../auto-disabled-document.spec.tsx | 4 +- .../{ => __tests__}/index-failed.spec.tsx | 3 +- .../status-with-action.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 7 +- .../image-list/{ => __tests__}/index.spec.tsx | 7 +- .../image-list/{ => __tests__}/more.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 6 +- .../{ => __tests__}/store.spec.tsx | 4 +- .../{ => __tests__}/utils.spec.ts | 6 +- .../hooks/{ => __tests__}/use-upload.spec.tsx | 7 +- .../{ => __tests__}/image-input.spec.tsx | 5 +- .../{ => __tests__}/image-item.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 5 +- .../{ => __tests__}/image-input.spec.tsx | 7 +- .../{ => __tests__}/image-item.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 5 +- .../{ => __tests__}/index.spec.tsx | 6 +- .../{ => __tests__}/index.spec.tsx | 8 +- .../{ => __tests__}/index.spec.tsx | 36 +- .../{ => __tests__}/footer.spec.tsx | 29 +- .../{ => __tests__}/header.spec.tsx | 13 +- .../{ => __tests__}/index.spec.tsx | 20 +- .../dsl-confirm-modal.spec.tsx | 17 +- .../{ => __tests__}/header.spec.tsx | 16 +- .../{ => __tests__}/index.spec.tsx | 61 +- .../{ => __tests__}/uploader.spec.tsx | 24 +- .../{ => __tests__}/use-dsl-import.spec.tsx | 6 +- .../tab/{ => __tests__}/index.spec.tsx | 15 +- .../tab/{ => __tests__}/item.spec.tsx | 18 +- .../built-in-pipeline-list.spec.tsx | 26 +- .../list/{ => __tests__}/create-card.spec.tsx | 19 +- .../{ => __tests__}/customized-list.spec.tsx | 17 +- .../list/{ => __tests__}/index.spec.tsx | 17 +- .../{ => __tests__}/actions.spec.tsx | 20 +- .../{ => __tests__}/content.spec.tsx | 24 +- .../edit-pipeline-info.spec.tsx | 35 +- .../{ => __tests__}/index.spec.tsx | 40 +- .../{ => __tests__}/operations.spec.tsx | 16 +- .../chunk-structure-card.spec.tsx | 21 +- .../details/{ => __tests__}/hooks.spec.tsx | 16 +- .../details/{ => __tests__}/index.spec.tsx | 29 +- .../create/{ => __tests__}/index.spec.tsx | 288 +- .../{ => __tests__}/index.spec.tsx | 239 +- .../__tests__/indexing-progress-item.spec.tsx | 141 + .../__tests__/rule-detail.spec.tsx | 145 + .../__tests__/upgrade-banner.spec.tsx | 29 + .../use-indexing-status-polling.spec.ts | 179 ++ .../embedding-process/__tests__/utils.spec.ts | 140 + .../{ => __tests__}/index.spec.tsx | 109 +- .../{ => __tests__}/index.spec.tsx | 150 +- .../{ => __tests__}/index.spec.tsx | 37 +- .../{ => __tests__}/file-list-item.spec.tsx | 6 +- .../{ => __tests__}/upload-dropzone.spec.tsx | 39 +- .../{ => __tests__}/use-file-upload.spec.tsx | 12 +- .../{ => __tests__}/index.spec.tsx | 196 +- .../create/step-one/__tests__/index.spec.tsx | 561 ++++ .../step-one/__tests__/upgrade-card.spec.tsx | 89 + .../data-source-type-selector.spec.tsx | 66 + .../__tests__/next-step-button.spec.tsx | 48 + .../__tests__/preview-panel.spec.tsx | 119 + .../hooks/__tests__/use-preview-state.spec.ts | 60 + .../datasets/create/step-one/index.spec.tsx | 1204 -------- .../step-three/{ => __tests__}/index.spec.tsx | 174 +- .../step-two/{ => __tests__}/index.spec.tsx | 452 ++- .../general-chunking-options.spec.tsx | 168 + .../__tests__/indexing-mode-section.spec.tsx | 213 ++ .../components/__tests__/inputs.spec.tsx | 92 + .../components/__tests__/option-card.spec.tsx | 160 + .../__tests__/parent-child-options.spec.tsx | 150 + .../__tests__/preview-panel.spec.tsx | 166 + .../__tests__/step-two-footer.spec.tsx | 46 + .../step-two/hooks/__tests__/escape.spec.ts | 75 + .../step-two/hooks/__tests__/unescape.spec.ts | 96 + .../__tests__/use-document-creation.spec.ts | 186 ++ .../__tests__/use-indexing-config.spec.ts | 161 + .../__tests__/use-indexing-estimate.spec.ts | 127 + .../hooks/__tests__/use-preview-state.spec.ts | 198 ++ .../__tests__/use-segmentation-state.spec.ts | 372 +++ .../{ => __tests__}/index.spec.tsx | 95 +- .../{ => __tests__}/index.spec.tsx | 147 +- .../stepper/{ => __tests__}/index.spec.tsx | 145 +- .../create/stepper/__tests__/step.spec.tsx | 32 + .../{ => __tests__}/index.spec.tsx | 127 +- .../top-bar/{ => __tests__}/index.spec.tsx | 102 +- .../website/{ => __tests__}/base.spec.tsx | 23 +- .../create/website/__tests__/index.spec.tsx | 286 ++ .../create/website/__tests__/no-data.spec.tsx | 185 ++ .../create/website/__tests__/preview.spec.tsx | 197 ++ .../__tests__/checkbox-with-label.spec.tsx | 43 + .../__tests__/crawled-result-item.spec.tsx | 43 + .../base/__tests__/crawled-result.spec.tsx | 313 ++ .../website/base/__tests__/crawling.spec.tsx | 20 + .../base/__tests__/error-message.spec.tsx | 29 + .../website/base/__tests__/field.spec.tsx | 46 + .../website/base/__tests__/header.spec.tsx | 45 + .../website/base/__tests__/input.spec.tsx | 52 + .../base/__tests__/options-wrap.spec.tsx | 43 + .../base/{ => __tests__}/url-input.spec.tsx | 30 +- .../firecrawl/{ => __tests__}/index.spec.tsx | 30 +- .../{ => __tests__}/options.spec.tsx | 22 +- .../jina-reader/{ => __tests__}/base.spec.tsx | 77 +- .../{ => __tests__}/index.spec.tsx | 207 +- .../jina-reader/__tests__/options.spec.tsx | 191 ++ .../base/__tests__/url-input.spec.tsx | 192 ++ .../watercrawl/{ => __tests__}/index.spec.tsx | 212 +- .../watercrawl/__tests__/options.spec.tsx | 276 ++ .../documents/{ => __tests__}/index.spec.tsx | 14 +- .../documents/__tests__/status-filter.spec.ts | 156 + .../{ => __tests__}/documents-header.spec.tsx | 2 +- .../{ => __tests__}/empty-element.spec.tsx | 2 +- .../components/{ => __tests__}/icons.spec.tsx | 2 +- .../{ => __tests__}/operations.spec.tsx | 76 +- .../{ => __tests__}/rename-modal.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 3 +- .../document-source-icon.spec.tsx | 2 +- .../document-table-row.spec.tsx | 3 +- .../{ => __tests__}/sort-header.spec.tsx | 2 +- .../components/{ => __tests__}/utils.spec.tsx | 2 +- .../__tests__/use-document-actions.spec.ts | 231 ++ .../use-document-actions.spec.tsx | 2 +- .../use-document-selection.spec.ts | 2 +- .../{ => __tests__}/use-document-sort.spec.ts | 2 +- .../{ => __tests__}/index.spec.tsx | 110 +- .../__tests__/left-header.spec.tsx | 110 + .../__tests__/step-indicator.spec.tsx | 32 + .../actions/{ => __tests__}/index.spec.tsx | 117 +- .../__tests__/datasource-icon.spec.tsx | 33 + .../__tests__/hooks.spec.tsx | 141 + .../{ => __tests__}/index.spec.tsx | 214 +- .../__tests__/option-card.spec.tsx | 110 + .../base/__tests__/header.spec.tsx | 48 + .../{ => __tests__}/index.spec.tsx | 158 +- .../__tests__/item.spec.tsx | 32 + .../__tests__/list.spec.tsx | 37 + .../__tests__/trigger.spec.tsx | 36 + .../data-source/base/header.spec.tsx | 658 ---- .../local-file/{ => __tests__}/index.spec.tsx | 10 +- .../{ => __tests__}/file-list-item.spec.tsx | 6 +- .../{ => __tests__}/upload-dropzone.spec.tsx | 40 +- .../use-local-file-upload.spec.tsx | 15 +- .../{ => __tests__}/index.spec.tsx | 249 +- .../online-documents/__tests__/title.spec.tsx | 10 + .../{ => __tests__}/index.spec.tsx | 255 +- .../page-selector/__tests__/utils.spec.ts | 100 + .../online-drive/__tests__/header.spec.tsx | 22 + .../{ => __tests__}/index.spec.tsx | 363 +-- .../online-drive/__tests__/utils.spec.ts | 105 + .../connect/{ => __tests__}/index.spec.tsx | 132 +- .../file-list/{ => __tests__}/index.spec.tsx | 157 +- .../header/{ => __tests__}/index.spec.tsx | 147 +- .../breadcrumbs/__tests__/bucket.spec.tsx | 57 + .../breadcrumbs/__tests__/drive.spec.tsx | 61 + .../{ => __tests__}/index.spec.tsx | 160 +- .../breadcrumbs/__tests__/item.spec.tsx | 48 + .../dropdown/{ => __tests__}/index.spec.tsx | 123 +- .../dropdown/__tests__/item.spec.tsx | 44 + .../dropdown/__tests__/menu.spec.tsx | 79 + .../list/__tests__/empty-folder.spec.tsx | 10 + .../__tests__/empty-search-result.spec.tsx | 31 + .../list/__tests__/file-icon.spec.tsx | 29 + .../list/{ => __tests__}/index.spec.tsx | 223 +- .../file-list/list/__tests__/item.spec.tsx | 90 + .../file-list/list/__tests__/utils.spec.ts | 79 + .../file-list/list/empty-folder.spec.tsx | 38 - .../data-source/store/__tests__/index.spec.ts | 96 + .../store/__tests__/provider.spec.tsx | 89 + .../store/slices/__tests__/common.spec.ts | 29 + .../store/slices/__tests__/local-file.spec.ts | 49 + .../slices/__tests__/online-document.spec.ts | 55 + .../slices/__tests__/online-drive.spec.ts | 79 + .../slices/__tests__/website-crawl.spec.ts | 65 + .../{ => __tests__}/index.spec.tsx | 292 +- .../__tests__/checkbox-with-label.spec.tsx | 50 + .../__tests__/crawled-result-item.spec.tsx | 69 + .../base/__tests__/crawled-result.spec.tsx | 214 ++ .../base/__tests__/crawling.spec.tsx | 21 + .../base/__tests__/error-message.spec.tsx | 26 + .../base/{ => __tests__}/index.spec.tsx | 159 +- .../options/{ => __tests__}/index.spec.tsx | 205 +- .../__tests__/use-add-documents-steps.spec.ts | 50 + .../__tests__/use-datasource-actions.spec.ts | 204 ++ .../__tests__/use-datasource-options.spec.ts | 58 + .../__tests__/use-datasource-store.spec.ts | 207 ++ .../__tests__/use-datasource-ui-state.spec.ts | 205 ++ .../{ => __tests__}/chunk-preview.spec.tsx | 5 +- .../preview/__tests__/file-preview.spec.tsx | 68 + .../online-document-preview.spec.tsx | 4 +- .../preview/__tests__/web-preview.spec.tsx | 48 + .../preview/file-preview.spec.tsx | 320 -- .../preview/web-preview.spec.tsx | 256 -- .../__tests__/actions.spec.tsx | 69 + .../{ => __tests__}/components.spec.tsx | 180 +- .../__tests__/header.spec.tsx | 57 + .../process-documents/__tests__/hooks.spec.ts | 52 + .../{ => __tests__}/index.spec.tsx | 132 +- .../processing/{ => __tests__}/index.spec.tsx | 127 +- .../{ => __tests__}/index.spec.tsx | 181 +- .../{ => __tests__}/rule-detail.spec.tsx | 90 +- .../{ => __tests__}/preview-panel.spec.tsx | 4 +- .../{ => __tests__}/step-one-content.spec.tsx | 17 +- .../step-three-content.spec.tsx | 4 +- .../{ => __tests__}/step-two-content.spec.tsx | 4 +- .../__tests__/datasource-info-builder.spec.ts | 104 + .../{ => __tests__}/document-title.spec.tsx | 31 +- .../documents/detail/__tests__/index.spec.tsx | 454 +++ .../{ => __tests__}/new-segment.spec.tsx | 310 +- .../{ => __tests__}/csv-downloader.spec.tsx | 37 +- .../{ => __tests__}/csv-uploader.spec.tsx | 241 +- .../{ => __tests__}/index.spec.tsx | 40 +- .../child-segment-detail.spec.tsx | 65 +- .../child-segment-list.spec.tsx | 79 +- .../{ => __tests__}/display-toggle.spec.tsx | 24 +- .../completed/{ => __tests__}/index.spec.tsx | 1115 ++----- .../new-child-segment.spec.tsx | 82 +- .../{ => __tests__}/segment-detail.spec.tsx | 125 +- .../{ => __tests__}/segment-list.spec.tsx | 81 +- .../{ => __tests__}/status-item.spec.tsx | 27 +- .../{ => __tests__}/action-buttons.spec.tsx | 52 +- .../{ => __tests__}/add-another.spec.tsx | 31 +- .../{ => __tests__}/batch-action.spec.tsx | 63 +- .../{ => __tests__}/chunk-content.spec.tsx | 39 +- .../common/{ => __tests__}/dot.spec.tsx | 16 +- .../common/__tests__/drawer.spec.tsx | 135 + .../common/{ => __tests__}/empty.spec.tsx | 33 +- .../full-screen-drawer.spec.tsx | 39 +- .../common/{ => __tests__}/keywords.spec.tsx | 36 +- .../regeneration-modal.spec.tsx | 50 +- .../segment-index-tag.spec.tsx | 48 +- .../common/__tests__/summary-label.spec.tsx | 20 + .../common/__tests__/summary-status.spec.tsx | 27 + .../common/__tests__/summary-text.spec.tsx | 42 + .../common/__tests__/summary.spec.tsx | 233 ++ .../common/{ => __tests__}/tag.spec.tsx | 36 +- .../__tests__/drawer-group.spec.tsx | 106 + .../components/__tests__/menu-bar.spec.tsx | 95 + .../__tests__/segment-list-content.spec.tsx | 103 + .../use-child-segment-data.spec.ts | 181 +- .../hooks/__tests__/use-modal-state.spec.ts | 146 + .../hooks/__tests__/use-search-filter.spec.ts | 124 + .../use-segment-list-data.spec.ts | 67 +- .../__tests__/use-segment-selection.spec.ts | 159 + .../{ => __tests__}/chunk-content.spec.tsx | 32 +- .../{ => __tests__}/index.spec.tsx | 74 +- .../full-doc-list-skeleton.spec.tsx | 20 +- .../general-list-skeleton.spec.tsx | 36 +- .../paragraph-list-skeleton.spec.tsx | 28 +- .../parent-chunk-card-skeleton.spec.tsx | 27 +- .../embedding/{ => __tests__}/index.spec.tsx | 8 +- .../{ => __tests__}/progress-bar.spec.tsx | 2 +- .../{ => __tests__}/rule-detail.spec.tsx | 4 +- .../{ => __tests__}/segment-progress.spec.tsx | 2 +- .../{ => __tests__}/status-header.spec.tsx | 2 +- .../use-embedding-status.spec.tsx | 2 +- .../skeleton/{ => __tests__}/index.spec.tsx | 2 +- .../metadata/{ => __tests__}/index.spec.tsx | 75 +- .../{ => __tests__}/index.spec.tsx | 63 +- .../document-settings.spec.tsx | 56 +- .../settings/{ => __tests__}/index.spec.tsx | 28 +- .../{ => __tests__}/index.spec.tsx | 84 +- .../{ => __tests__}/left-header.spec.tsx | 35 +- .../{ => __tests__}/actions.spec.tsx | 35 +- .../process-documents/__tests__/hooks.spec.ts | 70 + .../{ => __tests__}/index.spec.tsx | 71 +- .../use-document-list-query-state.spec.ts | 439 +++ .../use-documents-page-state.spec.ts | 711 +++++ .../status-item/__tests__/hooks.spec.ts | 119 + .../{ => __tests__}/index.spec.tsx | 17 +- .../{ => __tests__}/Form.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 5 +- .../{ => __tests__}/index.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 2 +- .../connector/{ => __tests__}/index.spec.tsx | 5 +- .../__tests__/ExternalApiSelect.spec.tsx | 104 + .../__tests__/ExternalApiSelection.spec.tsx | 112 + .../create/__tests__/InfoPanel.spec.tsx | 94 + .../__tests__/KnowledgeBaseInfo.spec.tsx | 153 + .../__tests__/RetrievalSettings.spec.tsx | 92 + .../create/{ => __tests__}/index.spec.tsx | 20 +- .../extra-info/{ => __tests__}/index.spec.tsx | 29 +- .../{ => __tests__}/statistics.spec.tsx | 13 +- .../api-access/__tests__/card.spec.tsx | 186 ++ .../api-access/{ => __tests__}/index.spec.tsx | 20 +- .../service-api/__tests__/card.spec.tsx | 168 + .../{ => __tests__}/index.spec.tsx | 58 +- .../__tests__/formatted.spec.tsx | 27 + .../flavours/__tests__/edit-slice.spec.tsx | 190 ++ .../flavours/__tests__/preview-slice.spec.tsx | 113 + .../flavours/__tests__/shared.spec.tsx | 85 + .../hit-testing/__tests__/index.spec.tsx | 1067 +++++++ .../modify-external-retrieval-modal.spec.tsx | 126 + .../__tests__/modify-retrieval-modal.spec.tsx | 108 + .../__tests__/child-chunks-item.spec.tsx | 97 + .../__tests__/chunk-detail-modal.spec.tsx | 137 + .../__tests__/empty-records.spec.tsx | 33 + .../components/__tests__/mask.spec.tsx | 33 + .../components/__tests__/records.spec.tsx | 95 + .../__tests__/result-item-external.spec.tsx | 173 ++ .../__tests__/result-item-footer.spec.tsx | 70 + .../__tests__/result-item-meta.spec.tsx | 80 + .../components/__tests__/result-item.spec.tsx | 144 + .../components/__tests__/score.spec.tsx | 92 + .../query-input/__tests__/index.spec.tsx | 111 + .../query-input/__tests__/textarea.spec.tsx | 120 + .../datasets/hit-testing/index.spec.tsx | 2704 ----------------- .../__tests__/extension-to-file-type.spec.ts | 119 + .../list/{ => __tests__}/datasets.spec.tsx | 16 +- .../list/{ => __tests__}/index.spec.tsx | 33 +- .../dataset-card/__tests__/index.spec.tsx | 422 +++ .../{ => __tests__}/operation-item.spec.tsx | 2 +- .../{ => __tests__}/operations.spec.tsx | 2 +- .../{ => __tests__}/corner-labels.spec.tsx | 2 +- .../dataset-card-footer.spec.tsx | 2 +- .../dataset-card-header.spec.tsx | 2 +- .../dataset-card-modals.spec.tsx | 4 +- .../{ => __tests__}/description.spec.tsx | 2 +- .../operations-popover.spec.tsx | 6 +- .../{ => __tests__}/tag-area.spec.tsx | 2 +- .../use-dataset-card-state.spec.ts | 4 +- .../datasets/list/dataset-card/index.spec.tsx | 256 -- .../{ => __tests__}/index.spec.tsx | 2 +- .../new-dataset-card/__tests__/index.spec.tsx | 134 + .../{ => __tests__}/option.spec.tsx | 2 +- .../list/new-dataset-card/index.spec.tsx | 76 - .../add-metadata-button.spec.tsx | 2 +- .../base/{ => __tests__}/date-picker.spec.tsx | 2 +- .../{ => __tests__}/add-row.spec.tsx | 10 +- .../{ => __tests__}/edit-row.spec.tsx | 14 +- .../{ => __tests__}/edited-beacon.spec.tsx | 3 +- .../{ => __tests__}/input-combined.spec.tsx | 6 +- .../input-has-set-multiple-value.spec.tsx | 3 +- .../{ => __tests__}/label.spec.tsx | 2 +- .../{ => __tests__}/modal.spec.tsx | 15 +- .../use-batch-edit-document-metadata.spec.ts | 5 +- .../use-check-metadata-name.spec.ts | 2 +- .../use-edit-dataset-metadata.spec.ts | 8 +- .../use-metadata-document.spec.ts | 8 +- .../{ => __tests__}/create-content.spec.tsx | 10 +- .../create-metadata-modal.spec.tsx | 8 +- .../dataset-metadata-drawer.spec.tsx | 13 +- .../{ => __tests__}/field.spec.tsx | 2 +- .../select-metadata-modal.spec.tsx | 10 +- .../{ => __tests__}/select-metadata.spec.tsx | 8 +- .../{ => __tests__}/field.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 12 +- .../{ => __tests__}/info-group.spec.tsx | 15 +- .../{ => __tests__}/no-data.spec.tsx | 2 +- .../utils/{ => __tests__}/get-icon.spec.ts | 4 +- .../preview/__tests__/container.spec.tsx | 173 ++ .../preview/__tests__/header.spec.tsx | 141 + .../preview/{ => __tests__}/index.spec.tsx | 2 +- .../{ => __tests__}/index.spec.tsx | 12 +- .../{ => __tests__}/option-card.spec.tsx | 6 +- .../__tests__/summary-index-setting.spec.tsx | 226 ++ .../{ => __tests__}/hooks.spec.tsx | 6 +- .../{ => __tests__}/index.spec.tsx | 4 +- .../form/{ => __tests__}/index.spec.tsx | 6 +- .../basic-info-section.spec.tsx | 6 +- .../external-knowledge-section.spec.tsx | 4 +- .../{ => __tests__}/indexing-section.spec.tsx | 4 +- .../{ => __tests__}/use-form-state.spec.ts | 4 +- .../{ => __tests__}/index.spec.tsx | 7 +- .../{ => __tests__}/keyword-number.spec.tsx | 4 +- .../{ => __tests__}/index.spec.tsx | 24 +- .../{ => __tests__}/member-item.spec.tsx | 4 +- .../{ => __tests__}/permission-item.spec.tsx | 2 +- .../utils/{ => __tests__}/index.spec.ts | 4 +- web/eslint-suppressions.json | 80 - 388 files changed, 22637 insertions(+), 15567 deletions(-) create mode 100644 web/__tests__/datasets/create-dataset-flow.test.tsx create mode 100644 web/__tests__/datasets/dataset-settings-flow.test.tsx create mode 100644 web/__tests__/datasets/document-management.test.tsx create mode 100644 web/__tests__/datasets/external-knowledge-base.test.tsx create mode 100644 web/__tests__/datasets/hit-testing-flow.test.tsx create mode 100644 web/__tests__/datasets/metadata-management-flow.test.tsx create mode 100644 web/__tests__/datasets/pipeline-datasource-flow.test.tsx create mode 100644 web/__tests__/datasets/segment-crud.test.tsx create mode 100644 web/app/components/datasets/__tests__/chunk.spec.tsx rename web/app/components/datasets/{ => __tests__}/loading.spec.tsx (92%) rename web/app/components/datasets/{ => __tests__}/no-linked-apps-panel.spec.tsx (78%) rename web/app/components/datasets/api/{ => __tests__}/index.spec.tsx (95%) delete mode 100644 web/app/components/datasets/chunk.spec.tsx rename web/app/components/datasets/common/{ => __tests__}/check-rerank-model.spec.ts (99%) rename web/app/components/datasets/common/{ => __tests__}/chunking-mode-label.spec.tsx (97%) rename web/app/components/datasets/common/{ => __tests__}/credential-icon.spec.tsx (99%) rename web/app/components/datasets/common/{ => __tests__}/document-file-icon.spec.tsx (98%) create mode 100644 web/app/components/datasets/common/document-picker/__tests__/document-list.spec.tsx rename web/app/components/datasets/common/document-picker/{ => __tests__}/index.spec.tsx (94%) rename web/app/components/datasets/common/document-picker/{ => __tests__}/preview-document-picker.spec.tsx (87%) rename web/app/components/datasets/common/document-status-with-action/{ => __tests__}/auto-disabled-document.spec.tsx (98%) rename web/app/components/datasets/common/document-status-with-action/{ => __tests__}/index-failed.spec.tsx (99%) rename web/app/components/datasets/common/document-status-with-action/{ => __tests__}/status-with-action.spec.tsx (99%) rename web/app/components/datasets/common/economical-retrieval-method-config/{ => __tests__}/index.spec.tsx (94%) rename web/app/components/datasets/common/image-list/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/datasets/common/image-list/{ => __tests__}/more.spec.tsx (99%) rename web/app/components/datasets/common/image-previewer/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/common/image-uploader/{ => __tests__}/store.spec.tsx (99%) rename web/app/components/datasets/common/image-uploader/{ => __tests__}/utils.spec.ts (99%) rename web/app/components/datasets/common/image-uploader/hooks/{ => __tests__}/use-upload.spec.tsx (99%) rename web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/{ => __tests__}/image-input.spec.tsx (96%) rename web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/{ => __tests__}/image-item.spec.tsx (98%) rename web/app/components/datasets/common/image-uploader/image-uploader-in-chunk/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/{ => __tests__}/image-input.spec.tsx (96%) rename web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/{ => __tests__}/image-item.spec.tsx (98%) rename web/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/common/retrieval-method-config/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/datasets/common/retrieval-method-info/{ => __tests__}/index.spec.tsx (95%) rename web/app/components/datasets/common/retrieval-param-config/{ => __tests__}/index.spec.tsx (95%) rename web/app/components/datasets/create-from-pipeline/{ => __tests__}/footer.spec.tsx (81%) rename web/app/components/datasets/create-from-pipeline/{ => __tests__}/header.spec.tsx (72%) rename web/app/components/datasets/create-from-pipeline/{ => __tests__}/index.spec.tsx (77%) rename web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/{ => __tests__}/dsl-confirm-modal.spec.tsx (80%) rename web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/{ => __tests__}/header.spec.tsx (73%) rename web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/{ => __tests__}/index.spec.tsx (96%) rename web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/{ => __tests__}/uploader.spec.tsx (81%) rename web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/{ => __tests__}/use-dsl-import.spec.tsx (99%) rename web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/{ => __tests__}/index.spec.tsx (80%) rename web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/tab/{ => __tests__}/item.spec.tsx (74%) rename web/app/components/datasets/create-from-pipeline/list/{ => __tests__}/built-in-pipeline-list.spec.tsx (83%) rename web/app/components/datasets/create-from-pipeline/list/{ => __tests__}/create-card.spec.tsx (83%) rename web/app/components/datasets/create-from-pipeline/list/{ => __tests__}/customized-list.spec.tsx (78%) rename web/app/components/datasets/create-from-pipeline/list/{ => __tests__}/index.spec.tsx (69%) rename web/app/components/datasets/create-from-pipeline/list/template-card/{ => __tests__}/actions.spec.tsx (79%) rename web/app/components/datasets/create-from-pipeline/list/template-card/{ => __tests__}/content.spec.tsx (81%) rename web/app/components/datasets/create-from-pipeline/list/template-card/{ => __tests__}/edit-pipeline-info.spec.tsx (91%) rename web/app/components/datasets/create-from-pipeline/list/template-card/{ => __tests__}/index.spec.tsx (91%) rename web/app/components/datasets/create-from-pipeline/list/template-card/{ => __tests__}/operations.spec.tsx (82%) rename web/app/components/datasets/create-from-pipeline/list/template-card/details/{ => __tests__}/chunk-structure-card.spec.tsx (83%) rename web/app/components/datasets/create-from-pipeline/list/template-card/details/{ => __tests__}/hooks.spec.tsx (81%) rename web/app/components/datasets/create-from-pipeline/list/template-card/details/{ => __tests__}/index.spec.tsx (85%) rename web/app/components/datasets/create/{ => __tests__}/index.spec.tsx (85%) rename web/app/components/datasets/create/embedding-process/{ => __tests__}/index.spec.tsx (89%) create mode 100644 web/app/components/datasets/create/embedding-process/__tests__/indexing-progress-item.spec.tsx create mode 100644 web/app/components/datasets/create/embedding-process/__tests__/rule-detail.spec.tsx create mode 100644 web/app/components/datasets/create/embedding-process/__tests__/upgrade-banner.spec.tsx create mode 100644 web/app/components/datasets/create/embedding-process/__tests__/use-indexing-status-polling.spec.ts create mode 100644 web/app/components/datasets/create/embedding-process/__tests__/utils.spec.ts rename web/app/components/datasets/create/empty-dataset-creation-modal/{ => __tests__}/index.spec.tsx (92%) rename web/app/components/datasets/create/file-preview/{ => __tests__}/index.spec.tsx (85%) rename web/app/components/datasets/create/file-uploader/{ => __tests__}/index.spec.tsx (87%) rename web/app/components/datasets/create/file-uploader/components/{ => __tests__}/file-list-item.spec.tsx (98%) rename web/app/components/datasets/create/file-uploader/components/{ => __tests__}/upload-dropzone.spec.tsx (84%) rename web/app/components/datasets/create/file-uploader/hooks/{ => __tests__}/use-file-upload.spec.tsx (99%) rename web/app/components/datasets/create/notion-page-preview/{ => __tests__}/index.spec.tsx (86%) create mode 100644 web/app/components/datasets/create/step-one/__tests__/index.spec.tsx create mode 100644 web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx create mode 100644 web/app/components/datasets/create/step-one/components/__tests__/data-source-type-selector.spec.tsx create mode 100644 web/app/components/datasets/create/step-one/components/__tests__/next-step-button.spec.tsx create mode 100644 web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx create mode 100644 web/app/components/datasets/create/step-one/hooks/__tests__/use-preview-state.spec.ts delete mode 100644 web/app/components/datasets/create/step-one/index.spec.tsx rename web/app/components/datasets/create/step-three/{ => __tests__}/index.spec.tsx (84%) rename web/app/components/datasets/create/step-two/{ => __tests__}/index.spec.tsx (81%) create mode 100644 web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/components/__tests__/parent-child-options.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/components/__tests__/preview-panel.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/components/__tests__/step-two-footer.spec.tsx create mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/escape.spec.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/unescape.spec.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts create mode 100644 web/app/components/datasets/create/step-two/hooks/__tests__/use-segmentation-state.spec.ts rename web/app/components/datasets/create/step-two/language-select/{ => __tests__}/index.spec.tsx (89%) rename web/app/components/datasets/create/step-two/preview-item/{ => __tests__}/index.spec.tsx (88%) rename web/app/components/datasets/create/stepper/{ => __tests__}/index.spec.tsx (82%) create mode 100644 web/app/components/datasets/create/stepper/__tests__/step.spec.tsx rename web/app/components/datasets/create/stop-embedding-modal/{ => __tests__}/index.spec.tsx (85%) rename web/app/components/datasets/create/top-bar/{ => __tests__}/index.spec.tsx (83%) rename web/app/components/datasets/create/website/{ => __tests__}/base.spec.tsx (94%) create mode 100644 web/app/components/datasets/create/website/__tests__/index.spec.tsx create mode 100644 web/app/components/datasets/create/website/__tests__/no-data.spec.tsx create mode 100644 web/app/components/datasets/create/website/__tests__/preview.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/checkbox-with-label.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/crawled-result-item.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/crawled-result.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/crawling.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/error-message.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/field.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/header.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/input.spec.tsx create mode 100644 web/app/components/datasets/create/website/base/__tests__/options-wrap.spec.tsx rename web/app/components/datasets/create/website/base/{ => __tests__}/url-input.spec.tsx (86%) rename web/app/components/datasets/create/website/firecrawl/{ => __tests__}/index.spec.tsx (90%) rename web/app/components/datasets/create/website/firecrawl/{ => __tests__}/options.spec.tsx (90%) rename web/app/components/datasets/create/website/jina-reader/{ => __tests__}/base.spec.tsx (82%) rename web/app/components/datasets/create/website/jina-reader/{ => __tests__}/index.spec.tsx (91%) create mode 100644 web/app/components/datasets/create/website/jina-reader/__tests__/options.spec.tsx create mode 100644 web/app/components/datasets/create/website/jina-reader/base/__tests__/url-input.spec.tsx rename web/app/components/datasets/create/website/watercrawl/{ => __tests__}/index.spec.tsx (91%) create mode 100644 web/app/components/datasets/create/website/watercrawl/__tests__/options.spec.tsx rename web/app/components/datasets/documents/{ => __tests__}/index.spec.tsx (98%) create mode 100644 web/app/components/datasets/documents/__tests__/status-filter.spec.ts rename web/app/components/datasets/documents/components/{ => __tests__}/documents-header.spec.tsx (99%) rename web/app/components/datasets/documents/components/{ => __tests__}/empty-element.spec.tsx (98%) rename web/app/components/datasets/documents/components/{ => __tests__}/icons.spec.tsx (97%) rename web/app/components/datasets/documents/components/{ => __tests__}/operations.spec.tsx (87%) rename web/app/components/datasets/documents/components/{ => __tests__}/rename-modal.spec.tsx (99%) rename web/app/components/datasets/documents/components/document-list/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/datasets/documents/components/document-list/components/{ => __tests__}/document-source-icon.spec.tsx (99%) rename web/app/components/datasets/documents/components/document-list/components/{ => __tests__}/document-table-row.spec.tsx (99%) rename web/app/components/datasets/documents/components/document-list/components/{ => __tests__}/sort-header.spec.tsx (98%) rename web/app/components/datasets/documents/components/document-list/components/{ => __tests__}/utils.spec.tsx (98%) create mode 100644 web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts rename web/app/components/datasets/documents/components/document-list/hooks/{ => __tests__}/use-document-actions.spec.tsx (99%) rename web/app/components/datasets/documents/components/document-list/hooks/{ => __tests__}/use-document-selection.spec.ts (99%) rename web/app/components/datasets/documents/components/document-list/hooks/{ => __tests__}/use-document-sort.spec.ts (99%) rename web/app/components/datasets/documents/create-from-pipeline/{ => __tests__}/index.spec.tsx (95%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/__tests__/step-indicator.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/actions/{ => __tests__}/index.spec.tsx (89%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/datasource-icon.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/hooks.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source-options/{ => __tests__}/index.spec.tsx (90%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/option-card.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/base/__tests__/header.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/{ => __tests__}/index.spec.tsx (89%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/item.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/list.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/trigger.spec.tsx delete mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/{ => __tests__}/file-list-item.spec.tsx (98%) rename web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/{ => __tests__}/upload-dropzone.spec.tsx (83%) rename web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/{ => __tests__}/use-local-file-upload.spec.tsx (98%) rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/{ => __tests__}/index.spec.tsx (87%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/title.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/{ => __tests__}/index.spec.tsx (90%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/utils.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/header.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/{ => __tests__}/index.spec.tsx (86%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/utils.spec.ts rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/{ => __tests__}/index.spec.tsx (84%) rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/{ => __tests__}/index.spec.tsx (86%) rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/{ => __tests__}/index.spec.tsx (87%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/drive.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/{ => __tests__}/index.spec.tsx (90%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/item.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/{ => __tests__}/index.spec.tsx (89%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-folder.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-search-result.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/file-icon.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/{ => __tests__}/index.spec.tsx (92%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/item.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/utils.spec.ts delete mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-folder.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/index.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/provider.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/common.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/local-file.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-document.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-drive.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/website-crawl.spec.ts rename web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/{ => __tests__}/index.spec.tsx (86%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/checkbox-with-label.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawling.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/error-message.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/{ => __tests__}/index.spec.tsx (88%) rename web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/{ => __tests__}/index.spec.tsx (88%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-add-documents-steps.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-store.spec.ts create mode 100644 web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts rename web/app/components/datasets/documents/create-from-pipeline/preview/{ => __tests__}/chunk-preview.spec.tsx (99%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/file-preview.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/preview/{ => __tests__}/online-document-preview.spec.tsx (99%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/web-preview.spec.tsx delete mode 100644 web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.spec.tsx delete mode 100644 web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/actions.spec.tsx rename web/app/components/datasets/documents/create-from-pipeline/process-documents/{ => __tests__}/components.spec.tsx (83%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx create mode 100644 web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts rename web/app/components/datasets/documents/create-from-pipeline/process-documents/{ => __tests__}/index.spec.tsx (85%) rename web/app/components/datasets/documents/create-from-pipeline/processing/{ => __tests__}/index.spec.tsx (88%) rename web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/{ => __tests__}/index.spec.tsx (90%) rename web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/{ => __tests__}/rule-detail.spec.tsx (85%) rename web/app/components/datasets/documents/create-from-pipeline/steps/{ => __tests__}/preview-panel.spec.tsx (98%) rename web/app/components/datasets/documents/create-from-pipeline/steps/{ => __tests__}/step-one-content.spec.tsx (97%) rename web/app/components/datasets/documents/create-from-pipeline/steps/{ => __tests__}/step-three-content.spec.tsx (96%) rename web/app/components/datasets/documents/create-from-pipeline/steps/{ => __tests__}/step-two-content.spec.tsx (97%) create mode 100644 web/app/components/datasets/documents/create-from-pipeline/utils/__tests__/datasource-info-builder.spec.ts rename web/app/components/datasets/documents/detail/{ => __tests__}/document-title.spec.tsx (87%) create mode 100644 web/app/components/datasets/documents/detail/__tests__/index.spec.tsx rename web/app/components/datasets/documents/detail/{ => __tests__}/new-segment.spec.tsx (61%) rename web/app/components/datasets/documents/detail/batch-modal/{ => __tests__}/csv-downloader.spec.tsx (91%) rename web/app/components/datasets/documents/detail/batch-modal/{ => __tests__}/csv-uploader.spec.tsx (68%) rename web/app/components/datasets/documents/detail/batch-modal/{ => __tests__}/index.spec.tsx (89%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/child-segment-detail.spec.tsx (87%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/child-segment-list.spec.tsx (89%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/display-toggle.spec.tsx (88%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/index.spec.tsx (51%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/new-child-segment.spec.tsx (90%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/segment-detail.spec.tsx (89%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/segment-list.spec.tsx (90%) rename web/app/components/datasets/documents/detail/completed/{ => __tests__}/status-item.spec.tsx (86%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/action-buttons.spec.tsx (93%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/add-another.spec.tsx (89%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/batch-action.spec.tsx (86%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/chunk-content.spec.tsx (91%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/dot.spec.tsx (82%) create mode 100644 web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/empty.spec.tsx (86%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/full-screen-drawer.spec.tsx (90%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/keywords.spec.tsx (91%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/regeneration-modal.spec.tsx (91%) rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/segment-index-tag.spec.tsx (85%) create mode 100644 web/app/components/datasets/documents/detail/completed/common/__tests__/summary-label.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/__tests__/summary-status.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/__tests__/summary-text.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/common/__tests__/summary.spec.tsx rename web/app/components/datasets/documents/detail/completed/common/{ => __tests__}/tag.spec.tsx (84%) create mode 100644 web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx create mode 100644 web/app/components/datasets/documents/detail/completed/components/__tests__/segment-list-content.spec.tsx rename web/app/components/datasets/documents/detail/completed/hooks/{ => __tests__}/use-child-segment-data.spec.ts (76%) create mode 100644 web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts create mode 100644 web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-search-filter.spec.ts rename web/app/components/datasets/documents/detail/completed/hooks/{ => __tests__}/use-segment-list-data.spec.ts (92%) create mode 100644 web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-selection.spec.ts rename web/app/components/datasets/documents/detail/completed/segment-card/{ => __tests__}/chunk-content.spec.tsx (92%) rename web/app/components/datasets/documents/detail/completed/segment-card/{ => __tests__}/index.spec.tsx (90%) rename web/app/components/datasets/documents/detail/completed/skeleton/{ => __tests__}/full-doc-list-skeleton.spec.tsx (90%) rename web/app/components/datasets/documents/detail/completed/skeleton/{ => __tests__}/general-list-skeleton.spec.tsx (88%) rename web/app/components/datasets/documents/detail/completed/skeleton/{ => __tests__}/paragraph-list-skeleton.spec.tsx (88%) rename web/app/components/datasets/documents/detail/completed/skeleton/{ => __tests__}/parent-chunk-card-skeleton.spec.tsx (87%) rename web/app/components/datasets/documents/detail/embedding/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/documents/detail/embedding/components/{ => __tests__}/progress-bar.spec.tsx (99%) rename web/app/components/datasets/documents/detail/embedding/components/{ => __tests__}/rule-detail.spec.tsx (98%) rename web/app/components/datasets/documents/detail/embedding/components/{ => __tests__}/segment-progress.spec.tsx (98%) rename web/app/components/datasets/documents/detail/embedding/components/{ => __tests__}/status-header.spec.tsx (99%) rename web/app/components/datasets/documents/detail/embedding/hooks/{ => __tests__}/use-embedding-status.spec.tsx (99%) rename web/app/components/datasets/documents/detail/embedding/skeleton/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/datasets/documents/detail/metadata/{ => __tests__}/index.spec.tsx (93%) rename web/app/components/datasets/documents/detail/segment-add/{ => __tests__}/index.spec.tsx (90%) rename web/app/components/datasets/documents/detail/settings/{ => __tests__}/document-settings.spec.tsx (90%) rename web/app/components/datasets/documents/detail/settings/{ => __tests__}/index.spec.tsx (88%) rename web/app/components/datasets/documents/detail/settings/pipeline-settings/{ => __tests__}/index.spec.tsx (94%) rename web/app/components/datasets/documents/detail/settings/pipeline-settings/{ => __tests__}/left-header.spec.tsx (84%) rename web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/{ => __tests__}/actions.spec.tsx (86%) create mode 100644 web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts rename web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/{ => __tests__}/index.spec.tsx (94%) create mode 100644 web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.ts create mode 100644 web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts create mode 100644 web/app/components/datasets/documents/status-item/__tests__/hooks.spec.ts rename web/app/components/datasets/documents/status-item/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/datasets/external-api/external-api-modal/{ => __tests__}/Form.spec.tsx (98%) rename web/app/components/datasets/external-api/external-api-modal/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/datasets/external-api/external-api-panel/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/external-api/external-knowledge-api-card/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/datasets/external-knowledge-base/connector/{ => __tests__}/index.spec.tsx (99%) create mode 100644 web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx create mode 100644 web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx create mode 100644 web/app/components/datasets/external-knowledge-base/create/__tests__/InfoPanel.spec.tsx create mode 100644 web/app/components/datasets/external-knowledge-base/create/__tests__/KnowledgeBaseInfo.spec.tsx create mode 100644 web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx rename web/app/components/datasets/external-knowledge-base/create/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/extra-info/{ => __tests__}/index.spec.tsx (95%) rename web/app/components/datasets/extra-info/{ => __tests__}/statistics.spec.tsx (88%) create mode 100644 web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx rename web/app/components/datasets/extra-info/api-access/{ => __tests__}/index.spec.tsx (88%) create mode 100644 web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx rename web/app/components/datasets/extra-info/service-api/{ => __tests__}/index.spec.tsx (88%) create mode 100644 web/app/components/datasets/formatted-text/__tests__/formatted.spec.tsx create mode 100644 web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx create mode 100644 web/app/components/datasets/formatted-text/flavours/__tests__/preview-slice.spec.tsx create mode 100644 web/app/components/datasets/formatted-text/flavours/__tests__/shared.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/__tests__/index.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/__tests__/modify-external-retrieval-modal.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/child-chunks-item.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/empty-records.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/mask.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/records.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/result-item-external.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/result-item-meta.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/__tests__/score.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/components/query-input/__tests__/textarea.spec.tsx delete mode 100644 web/app/components/datasets/hit-testing/index.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/utils/__tests__/extension-to-file-type.spec.ts rename web/app/components/datasets/list/{ => __tests__}/datasets.spec.tsx (97%) rename web/app/components/datasets/list/{ => __tests__}/index.spec.tsx (91%) create mode 100644 web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx rename web/app/components/datasets/list/dataset-card/{ => __tests__}/operation-item.spec.tsx (98%) rename web/app/components/datasets/list/dataset-card/{ => __tests__}/operations.spec.tsx (99%) rename web/app/components/datasets/list/dataset-card/components/{ => __tests__}/corner-labels.spec.tsx (99%) rename web/app/components/datasets/list/dataset-card/components/{ => __tests__}/dataset-card-footer.spec.tsx (99%) rename web/app/components/datasets/list/dataset-card/components/{ => __tests__}/dataset-card-header.spec.tsx (99%) rename web/app/components/datasets/list/dataset-card/components/{ => __tests__}/dataset-card-modals.spec.tsx (98%) rename web/app/components/datasets/list/dataset-card/components/{ => __tests__}/description.spec.tsx (99%) rename web/app/components/datasets/list/dataset-card/components/{ => __tests__}/operations-popover.spec.tsx (97%) rename web/app/components/datasets/list/dataset-card/components/{ => __tests__}/tag-area.spec.tsx (99%) rename web/app/components/datasets/list/dataset-card/hooks/{ => __tests__}/use-dataset-card-state.spec.ts (99%) delete mode 100644 web/app/components/datasets/list/dataset-card/index.spec.tsx rename web/app/components/datasets/list/dataset-footer/{ => __tests__}/index.spec.tsx (97%) create mode 100644 web/app/components/datasets/list/new-dataset-card/__tests__/index.spec.tsx rename web/app/components/datasets/list/new-dataset-card/{ => __tests__}/option.spec.tsx (98%) delete mode 100644 web/app/components/datasets/list/new-dataset-card/index.spec.tsx rename web/app/components/datasets/metadata/{ => __tests__}/add-metadata-button.spec.tsx (98%) rename web/app/components/datasets/metadata/base/{ => __tests__}/date-picker.spec.tsx (99%) rename web/app/components/datasets/metadata/edit-metadata-batch/{ => __tests__}/add-row.spec.tsx (97%) rename web/app/components/datasets/metadata/edit-metadata-batch/{ => __tests__}/edit-row.spec.tsx (97%) rename web/app/components/datasets/metadata/edit-metadata-batch/{ => __tests__}/edited-beacon.spec.tsx (98%) rename web/app/components/datasets/metadata/edit-metadata-batch/{ => __tests__}/input-combined.spec.tsx (98%) rename web/app/components/datasets/metadata/edit-metadata-batch/{ => __tests__}/input-has-set-multiple-value.spec.tsx (98%) rename web/app/components/datasets/metadata/edit-metadata-batch/{ => __tests__}/label.spec.tsx (99%) rename web/app/components/datasets/metadata/edit-metadata-batch/{ => __tests__}/modal.spec.tsx (98%) rename web/app/components/datasets/metadata/hooks/{ => __tests__}/use-batch-edit-document-metadata.spec.ts (99%) rename web/app/components/datasets/metadata/hooks/{ => __tests__}/use-check-metadata-name.spec.ts (99%) rename web/app/components/datasets/metadata/hooks/{ => __tests__}/use-edit-dataset-metadata.spec.ts (97%) rename web/app/components/datasets/metadata/hooks/{ => __tests__}/use-metadata-document.spec.ts (98%) rename web/app/components/datasets/metadata/metadata-dataset/{ => __tests__}/create-content.spec.tsx (97%) rename web/app/components/datasets/metadata/metadata-dataset/{ => __tests__}/create-metadata-modal.spec.tsx (97%) rename web/app/components/datasets/metadata/metadata-dataset/{ => __tests__}/dataset-metadata-drawer.spec.tsx (98%) rename web/app/components/datasets/metadata/metadata-dataset/{ => __tests__}/field.spec.tsx (99%) rename web/app/components/datasets/metadata/metadata-dataset/{ => __tests__}/select-metadata-modal.spec.tsx (97%) rename web/app/components/datasets/metadata/metadata-dataset/{ => __tests__}/select-metadata.spec.tsx (98%) rename web/app/components/datasets/metadata/metadata-document/{ => __tests__}/field.spec.tsx (99%) rename web/app/components/datasets/metadata/metadata-document/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/metadata/metadata-document/{ => __tests__}/info-group.spec.tsx (96%) rename web/app/components/datasets/metadata/metadata-document/{ => __tests__}/no-data.spec.tsx (99%) rename web/app/components/datasets/metadata/utils/{ => __tests__}/get-icon.spec.ts (94%) create mode 100644 web/app/components/datasets/preview/__tests__/container.spec.tsx create mode 100644 web/app/components/datasets/preview/__tests__/header.spec.tsx rename web/app/components/datasets/preview/{ => __tests__}/index.spec.tsx (94%) rename web/app/components/datasets/rename-modal/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/datasets/settings/{ => __tests__}/option-card.spec.tsx (98%) create mode 100644 web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx rename web/app/components/datasets/settings/chunk-structure/{ => __tests__}/hooks.spec.tsx (98%) rename web/app/components/datasets/settings/chunk-structure/{ => __tests__}/index.spec.tsx (98%) rename web/app/components/datasets/settings/form/{ => __tests__}/index.spec.tsx (99%) rename web/app/components/datasets/settings/form/components/{ => __tests__}/basic-info-section.spec.tsx (98%) rename web/app/components/datasets/settings/form/components/{ => __tests__}/external-knowledge-section.spec.tsx (99%) rename web/app/components/datasets/settings/form/components/{ => __tests__}/indexing-section.spec.tsx (99%) rename web/app/components/datasets/settings/form/hooks/{ => __tests__}/use-form-state.spec.ts (99%) rename web/app/components/datasets/settings/index-method/{ => __tests__}/index.spec.tsx (97%) rename web/app/components/datasets/settings/index-method/{ => __tests__}/keyword-number.spec.tsx (98%) rename web/app/components/datasets/settings/permission-selector/{ => __tests__}/index.spec.tsx (96%) rename web/app/components/datasets/settings/permission-selector/{ => __tests__}/member-item.spec.tsx (98%) rename web/app/components/datasets/settings/permission-selector/{ => __tests__}/permission-item.spec.tsx (98%) rename web/app/components/datasets/settings/utils/{ => __tests__}/index.spec.ts (98%) diff --git a/web/__tests__/datasets/create-dataset-flow.test.tsx b/web/__tests__/datasets/create-dataset-flow.test.tsx new file mode 100644 index 0000000000..e3a59edde6 --- /dev/null +++ b/web/__tests__/datasets/create-dataset-flow.test.tsx @@ -0,0 +1,301 @@ +/** + * Integration Test: Create Dataset Flow + * + * Tests cross-module data flow: step-one data → step-two hooks → creation params → API call + * Validates data contracts between steps. + */ + +import type { CustomFile } from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' + +const mockCreateFirstDocument = vi.fn() +const mockCreateDocument = vi.fn() +vi.mock('@/service/knowledge/use-create-dataset', () => ({ + useCreateFirstDocument: () => ({ mutateAsync: mockCreateFirstDocument, isPending: false }), + useCreateDocument: () => ({ mutateAsync: mockCreateDocument, isPending: false }), + getNotionInfo: (pages: { page_id: string }[], credentialId: string) => ({ + workspace_id: 'ws-1', + pages: pages.map(p => p.page_id), + notion_credential_id: credentialId, + }), + getWebsiteInfo: (opts: { websitePages: { url: string }[], websiteCrawlProvider: string }) => ({ + urls: opts.websitePages.map(p => p.url), + only_main_content: true, + provider: opts.websiteCrawlProvider, + }), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useInvalidDatasetList: () => vi.fn(), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: vi.fn() }, +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: vi.fn(), +})) + +// Import hooks after mocks +const { useSegmentationState, DEFAULT_SEGMENT_IDENTIFIER, DEFAULT_MAXIMUM_CHUNK_LENGTH, DEFAULT_OVERLAP } + = await import('@/app/components/datasets/create/step-two/hooks') +const { useDocumentCreation, IndexingType } + = await import('@/app/components/datasets/create/step-two/hooks') + +const createMockFile = (overrides?: Partial): CustomFile => ({ + id: 'file-1', + name: 'test.txt', + type: 'text/plain', + size: 1024, + extension: '.txt', + mime_type: 'text/plain', + created_at: 0, + created_by: '', + ...overrides, +} as CustomFile) + +describe('Create Dataset Flow - Cross-Step Data Contract', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Step-One → Step-Two: Segmentation Defaults', () => { + it('should initialise with correct default segmentation values', () => { + const { result } = renderHook(() => useSegmentationState()) + expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER) + expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH) + expect(result.current.overlap).toBe(DEFAULT_OVERLAP) + expect(result.current.segmentationType).toBe(ProcessMode.general) + }) + + it('should produce valid process rule for general chunking', () => { + const { result } = renderHook(() => useSegmentationState()) + const processRule = result.current.getProcessRule(ChunkingMode.text) + + // mode should be segmentationType = ProcessMode.general = 'custom' + expect(processRule.mode).toBe('custom') + expect(processRule.rules.segmentation).toEqual({ + separator: '\n\n', // unescaped from \\n\\n + max_tokens: DEFAULT_MAXIMUM_CHUNK_LENGTH, + chunk_overlap: DEFAULT_OVERLAP, + }) + // rules is empty initially since no default config loaded + expect(processRule.rules.pre_processing_rules).toEqual([]) + }) + + it('should produce valid process rule for parent-child chunking', () => { + const { result } = renderHook(() => useSegmentationState()) + const processRule = result.current.getProcessRule(ChunkingMode.parentChild) + + expect(processRule.mode).toBe('hierarchical') + expect(processRule.rules.parent_mode).toBe('paragraph') + expect(processRule.rules.segmentation).toEqual({ + separator: '\n\n', + max_tokens: 1024, + }) + expect(processRule.rules.subchunk_segmentation).toEqual({ + separator: '\n', + max_tokens: 512, + }) + }) + }) + + describe('Step-Two → Creation API: Params Building', () => { + it('should build valid creation params for file upload workflow', () => { + const files = [createMockFile()] + const { result: segResult } = renderHook(() => useSegmentationState()) + const { result: creationResult } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + const processRule = segResult.current.getProcessRule(ChunkingMode.text) + const retrievalConfig: RetrievalConfig = { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + } + + const params = creationResult.current.buildCreationParams( + ChunkingMode.text, + 'English', + processRule, + retrievalConfig, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).not.toBeNull() + // File IDs come from file.id (not file.file.id) + expect(params!.data_source.type).toBe(DataSourceType.FILE) + expect(params!.data_source.info_list.file_info_list?.file_ids).toContain('file-1') + + expect(params!.indexing_technique).toBe(IndexingType.QUALIFIED) + expect(params!.doc_form).toBe(ChunkingMode.text) + expect(params!.doc_language).toBe('English') + expect(params!.embedding_model).toBe('text-embedding-ada-002') + expect(params!.embedding_model_provider).toBe('openai') + expect(params!.process_rule.mode).toBe('custom') + }) + + it('should validate params: overlap must not exceed maxChunkLength', () => { + const { result } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files: [createMockFile()], + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + // validateParams returns false (invalid) when overlap > maxChunkLength for general mode + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 100, + limitMaxChunkLength: 4000, + overlap: 200, // overlap > maxChunkLength + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, + rerankModelList: [], + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + }, + }) + expect(isValid).toBe(false) + }) + + it('should validate params: maxChunkLength must not exceed limit', () => { + const { result } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files: [createMockFile()], + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + const isValid = result.current.validateParams({ + segmentationType: 'general', + maxChunkLength: 5000, + limitMaxChunkLength: 4000, // limit < maxChunkLength + overlap: 50, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-ada-002' }, + rerankModelList: [], + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + }, + }) + expect(isValid).toBe(false) + }) + }) + + describe('Full Flow: Segmentation State → Process Rule → Creation Params Consistency', () => { + it('should keep segmentation values consistent across getProcessRule and buildCreationParams', () => { + const files = [createMockFile()] + const { result: segResult } = renderHook(() => useSegmentationState()) + const { result: creationResult } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + // Change segmentation settings + act(() => { + segResult.current.setMaxChunkLength(2048) + segResult.current.setOverlap(100) + }) + + const processRule = segResult.current.getProcessRule(ChunkingMode.text) + expect(processRule.rules.segmentation.max_tokens).toBe(2048) + expect(processRule.rules.segmentation.chunk_overlap).toBe(100) + + const params = creationResult.current.buildCreationParams( + ChunkingMode.text, + 'Chinese', + processRule, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).not.toBeNull() + expect(params!.process_rule.rules.segmentation.max_tokens).toBe(2048) + expect(params!.process_rule.rules.segmentation.chunk_overlap).toBe(100) + expect(params!.doc_language).toBe('Chinese') + }) + + it('should support parent-child mode through the full pipeline', () => { + const files = [createMockFile()] + const { result: segResult } = renderHook(() => useSegmentationState()) + const { result: creationResult } = renderHook(() => + useDocumentCreation({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + notionCredentialId: '', + websitePages: [], + }), + ) + + const processRule = segResult.current.getProcessRule(ChunkingMode.parentChild) + const params = creationResult.current.buildCreationParams( + ChunkingMode.parentChild, + 'English', + processRule, + { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + }, + { provider: 'openai', model: 'text-embedding-ada-002' }, + IndexingType.QUALIFIED, + ) + + expect(params).not.toBeNull() + expect(params!.doc_form).toBe(ChunkingMode.parentChild) + expect(params!.process_rule.mode).toBe('hierarchical') + expect(params!.process_rule.rules.parent_mode).toBe('paragraph') + expect(params!.process_rule.rules.subchunk_segmentation).toBeDefined() + }) + }) +}) diff --git a/web/__tests__/datasets/dataset-settings-flow.test.tsx b/web/__tests__/datasets/dataset-settings-flow.test.tsx new file mode 100644 index 0000000000..607cd8c2d5 --- /dev/null +++ b/web/__tests__/datasets/dataset-settings-flow.test.tsx @@ -0,0 +1,451 @@ +/** + * Integration Test: Dataset Settings Flow + * + * Tests cross-module data contracts in the dataset settings form: + * useFormState hook ↔ index method config ↔ retrieval config ↔ permission state. + * + * The unit-level use-form-state.spec.ts validates the hook in isolation. + * This integration test verifies that changing one configuration dimension + * correctly cascades to dependent parts (index method → retrieval config, + * permission → member list visibility, embedding model → embedding available state). + */ + +import type { DataSet } from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { act, renderHook, waitFor } from '@testing-library/react' +import { IndexingType } from '@/app/components/datasets/create/step-two' +import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' + +// --- Mocks --- + +const mockMutateDatasets = vi.fn() +const mockInvalidDatasetList = vi.fn() +const mockUpdateDatasetSetting = vi.fn().mockResolvedValue({}) + +vi.mock('@/context/app-context', () => ({ + useSelector: () => false, +})) + +vi.mock('@/service/datasets', () => ({ + updateDatasetSetting: (...args: unknown[]) => mockUpdateDatasetSetting(...args), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useInvalidDatasetList: () => mockInvalidDatasetList, +})) + +vi.mock('@/service/use-common', () => ({ + useMembers: () => ({ + data: { + accounts: [ + { id: 'user-1', name: 'Alice', email: 'alice@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, + { id: 'user-2', name: 'Bob', email: 'bob@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, + { id: 'user-3', name: 'Charlie', email: 'charlie@example.com', role: 'normal', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' }, + ], + }, + }), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: () => ({ data: [] }), +})) + +vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({ + isReRankModelSelected: () => true, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: vi.fn() }, +})) + +// --- Dataset factory --- + +const createMockDataset = (overrides?: Partial): DataSet => ({ + id: 'ds-settings-1', + name: 'Settings Test Dataset', + description: 'Integration test dataset', + permission: DatasetPermission.onlyMe, + icon_info: { + icon_type: 'emoji', + icon: '📙', + icon_background: '#FFF4ED', + icon_url: '', + }, + indexing_technique: 'high_quality', + indexing_status: 'completed', + data_source_type: DataSourceType.FILE, + doc_form: ChunkingMode.text, + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + embedding_available: true, + app_count: 2, + document_count: 10, + total_document_count: 10, + word_count: 5000, + provider: 'vendor', + tags: [], + partial_member_list: [], + external_knowledge_info: { + external_knowledge_id: '', + external_knowledge_api_id: '', + external_knowledge_api_name: '', + external_knowledge_api_endpoint: '', + }, + external_retrieval_model: { + top_k: 2, + score_threshold: 0.5, + score_threshold_enabled: false, + }, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + } as RetrievalConfig, + retrieval_model: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, + } as RetrievalConfig, + built_in_field_enabled: false, + keyword_number: 10, + created_by: 'user-1', + updated_by: 'user-1', + updated_at: Date.now(), + runtime_mode: 'general', + enable_api: true, + is_multimodal: false, + ...overrides, +} as DataSet) + +let mockDataset: DataSet = createMockDataset() + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: ( + selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown, + ) => selector({ dataset: mockDataset, mutateDatasetRes: mockMutateDatasets }), +})) + +// Import after mocks are registered +const { useFormState } = await import( + '@/app/components/datasets/settings/form/hooks/use-form-state', +) + +describe('Dataset Settings Flow - Cross-Module Configuration Cascade', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUpdateDatasetSetting.mockResolvedValue({}) + mockDataset = createMockDataset() + }) + + describe('Form State Initialization from Dataset → Index Method → Retrieval Config Chain', () => { + it('should initialise all form dimensions from a QUALIFIED dataset', () => { + const { result } = renderHook(() => useFormState()) + + expect(result.current.name).toBe('Settings Test Dataset') + expect(result.current.description).toBe('Integration test dataset') + expect(result.current.indexMethod).toBe('high_quality') + expect(result.current.embeddingModel).toEqual({ + provider: 'openai', + model: 'text-embedding-ada-002', + }) + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.semantic) + }) + + it('should initialise from an ECONOMICAL dataset with keyword retrieval', () => { + mockDataset = createMockDataset({ + indexing_technique: IndexingType.ECONOMICAL, + embedding_model: '', + embedding_model_provider: '', + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.keywordSearch, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 5, + score_threshold_enabled: false, + score_threshold: 0, + } as RetrievalConfig, + }) + + const { result } = renderHook(() => useFormState()) + + expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL) + expect(result.current.embeddingModel).toEqual({ provider: '', model: '' }) + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch) + }) + }) + + describe('Index Method Change → Retrieval Config Sync', () => { + it('should allow switching index method from QUALIFIED to ECONOMICAL', () => { + const { result } = renderHook(() => useFormState()) + + expect(result.current.indexMethod).toBe('high_quality') + + act(() => { + result.current.setIndexMethod(IndexingType.ECONOMICAL) + }) + + expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL) + }) + + it('should allow updating retrieval config after index method switch', () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setIndexMethod(IndexingType.ECONOMICAL) + }) + + act(() => { + result.current.setRetrievalConfig({ + ...result.current.retrievalConfig, + search_method: RETRIEVE_METHOD.keywordSearch, + reranking_enable: false, + }) + }) + + expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL) + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.keywordSearch) + expect(result.current.retrievalConfig.reranking_enable).toBe(false) + }) + + it('should preserve retrieval config when switching back to QUALIFIED', () => { + const { result } = renderHook(() => useFormState()) + + const originalConfig = { ...result.current.retrievalConfig } + + act(() => { + result.current.setIndexMethod(IndexingType.ECONOMICAL) + }) + act(() => { + result.current.setIndexMethod(IndexingType.QUALIFIED) + }) + + expect(result.current.indexMethod).toBe('high_quality') + expect(result.current.retrievalConfig.search_method).toBe(originalConfig.search_method) + }) + }) + + describe('Permission Change → Member List Visibility Logic', () => { + it('should start with onlyMe permission and empty member selection', () => { + const { result } = renderHook(() => useFormState()) + + expect(result.current.permission).toBe(DatasetPermission.onlyMe) + expect(result.current.selectedMemberIDs).toEqual([]) + }) + + it('should enable member selection when switching to partialMembers', () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setPermission(DatasetPermission.partialMembers) + }) + + expect(result.current.permission).toBe(DatasetPermission.partialMembers) + expect(result.current.memberList).toHaveLength(3) + expect(result.current.memberList.map(m => m.id)).toEqual(['user-1', 'user-2', 'user-3']) + }) + + it('should persist member selection through permission toggle', () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setPermission(DatasetPermission.partialMembers) + result.current.setSelectedMemberIDs(['user-1', 'user-3']) + }) + + act(() => { + result.current.setPermission(DatasetPermission.allTeamMembers) + }) + + act(() => { + result.current.setPermission(DatasetPermission.partialMembers) + }) + + expect(result.current.selectedMemberIDs).toEqual(['user-1', 'user-3']) + }) + + it('should include partial_member_list in save payload only for partialMembers', async () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setPermission(DatasetPermission.partialMembers) + result.current.setSelectedMemberIDs(['user-2']) + }) + + await act(async () => { + await result.current.handleSave() + }) + + expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({ + datasetId: 'ds-settings-1', + body: expect.objectContaining({ + permission: DatasetPermission.partialMembers, + partial_member_list: [ + expect.objectContaining({ user_id: 'user-2', role: 'admin' }), + ], + }), + }) + }) + + it('should not include partial_member_list for allTeamMembers permission', async () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setPermission(DatasetPermission.allTeamMembers) + }) + + await act(async () => { + await result.current.handleSave() + }) + + const savedBody = mockUpdateDatasetSetting.mock.calls[0][0].body as Record + expect(savedBody).not.toHaveProperty('partial_member_list') + }) + }) + + describe('Form Submission Validation → All Fields Together', () => { + it('should reject empty name on save', async () => { + const Toast = await import('@/app/components/base/toast') + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setName('') + }) + + await act(async () => { + await result.current.handleSave() + }) + + expect(Toast.default.notify).toHaveBeenCalledWith({ + type: 'error', + message: expect.any(String), + }) + expect(mockUpdateDatasetSetting).not.toHaveBeenCalled() + }) + + it('should include all configuration dimensions in a successful save', async () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setName('Updated Name') + result.current.setDescription('Updated Description') + result.current.setIndexMethod(IndexingType.ECONOMICAL) + result.current.setKeywordNumber(15) + }) + + await act(async () => { + await result.current.handleSave() + }) + + expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({ + datasetId: 'ds-settings-1', + body: expect.objectContaining({ + name: 'Updated Name', + description: 'Updated Description', + indexing_technique: 'economy', + keyword_number: 15, + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + }), + }) + }) + + it('should call mutateDatasets and invalidDatasetList after successful save', async () => { + const { result } = renderHook(() => useFormState()) + + await act(async () => { + await result.current.handleSave() + }) + + await waitFor(() => { + expect(mockMutateDatasets).toHaveBeenCalled() + expect(mockInvalidDatasetList).toHaveBeenCalled() + }) + }) + }) + + describe('Embedding Model Change → Retrieval Config Cascade', () => { + it('should update embedding model independently of retrieval config', () => { + const { result } = renderHook(() => useFormState()) + + const originalRetrievalConfig = { ...result.current.retrievalConfig } + + act(() => { + result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-english-v3.0' }) + }) + + expect(result.current.embeddingModel).toEqual({ + provider: 'cohere', + model: 'embed-english-v3.0', + }) + expect(result.current.retrievalConfig.search_method).toBe(originalRetrievalConfig.search_method) + }) + + it('should propagate embedding model into weighted retrieval config on save', async () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' }) + result.current.setRetrievalConfig({ + ...result.current.retrievalConfig, + search_method: RETRIEVE_METHOD.hybrid, + weights: { + weight_type: WeightedScoreEnum.Customized, + vector_setting: { + vector_weight: 0.6, + embedding_provider_name: '', + embedding_model_name: '', + }, + keyword_setting: { keyword_weight: 0.4 }, + }, + }) + }) + + await act(async () => { + await result.current.handleSave() + }) + + expect(mockUpdateDatasetSetting).toHaveBeenCalledWith({ + datasetId: 'ds-settings-1', + body: expect.objectContaining({ + embedding_model: 'embed-v3', + embedding_model_provider: 'cohere', + retrieval_model: expect.objectContaining({ + weights: expect.objectContaining({ + vector_setting: expect.objectContaining({ + embedding_provider_name: 'cohere', + embedding_model_name: 'embed-v3', + }), + }), + }), + }), + }) + }) + + it('should handle switching from semantic to hybrid search with embedding model', () => { + const { result } = renderHook(() => useFormState()) + + act(() => { + result.current.setRetrievalConfig({ + ...result.current.retrievalConfig, + search_method: RETRIEVE_METHOD.hybrid, + reranking_enable: true, + reranking_model: { + reranking_provider_name: 'cohere', + reranking_model_name: 'rerank-english-v3.0', + }, + }) + }) + + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.hybrid) + expect(result.current.retrievalConfig.reranking_enable).toBe(true) + expect(result.current.embeddingModel.model).toBe('text-embedding-ada-002') + }) + }) +}) diff --git a/web/__tests__/datasets/document-management.test.tsx b/web/__tests__/datasets/document-management.test.tsx new file mode 100644 index 0000000000..3b901ccee2 --- /dev/null +++ b/web/__tests__/datasets/document-management.test.tsx @@ -0,0 +1,335 @@ +/** + * Integration Test: Document Management Flow + * + * Tests cross-module interactions: query state (URL-based) → document list sorting → + * document selection → status filter utilities. + * Validates the data contract between documents page hooks and list component hooks. + */ + +import type { SimpleDocumentDetail } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DataSourceType } from '@/models/datasets' + +const mockPush = vi.fn() +vi.mock('next/navigation', () => ({ + useSearchParams: () => new URLSearchParams(''), + useRouter: () => ({ push: mockPush }), + usePathname: () => '/datasets/ds-1/documents', +})) + +const { sanitizeStatusValue, normalizeStatusForQuery } = await import( + '@/app/components/datasets/documents/status-filter', +) + +const { useDocumentSort } = await import( + '@/app/components/datasets/documents/components/document-list/hooks/use-document-sort', +) +const { useDocumentSelection } = await import( + '@/app/components/datasets/documents/components/document-list/hooks/use-document-selection', +) +const { default: useDocumentListQueryState } = await import( + '@/app/components/datasets/documents/hooks/use-document-list-query-state', +) + +type LocalDoc = SimpleDocumentDetail & { percent?: number } + +const createDoc = (overrides?: Partial): LocalDoc => ({ + id: `doc-${Math.random().toString(36).slice(2, 8)}`, + name: 'test-doc.txt', + word_count: 500, + hit_count: 10, + created_at: Date.now() / 1000, + data_source_type: DataSourceType.FILE, + display_status: 'available', + indexing_status: 'completed', + enabled: true, + archived: false, + doc_type: null, + doc_metadata: null, + position: 1, + dataset_process_rule_id: 'rule-1', + ...overrides, +} as LocalDoc) + +describe('Document Management Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Status Filter Utilities', () => { + it('should sanitize valid status values', () => { + expect(sanitizeStatusValue('all')).toBe('all') + expect(sanitizeStatusValue('available')).toBe('available') + expect(sanitizeStatusValue('error')).toBe('error') + }) + + it('should fallback to "all" for invalid values', () => { + expect(sanitizeStatusValue(null)).toBe('all') + expect(sanitizeStatusValue(undefined)).toBe('all') + expect(sanitizeStatusValue('')).toBe('all') + expect(sanitizeStatusValue('nonexistent')).toBe('all') + }) + + it('should handle URL aliases', () => { + // 'active' is aliased to 'available' + expect(sanitizeStatusValue('active')).toBe('available') + }) + + it('should normalize status for API query', () => { + expect(normalizeStatusForQuery('all')).toBe('all') + // 'enabled' normalized to 'available' for query + expect(normalizeStatusForQuery('enabled')).toBe('available') + }) + }) + + describe('URL-based Query State', () => { + it('should parse default query from empty URL params', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query).toEqual({ + page: 1, + limit: 10, + keyword: '', + status: 'all', + sort: '-created_at', + }) + }) + + it('should update query and push to router', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ keyword: 'test', page: 2 }) + }) + + expect(mockPush).toHaveBeenCalled() + // The push call should contain the updated query params + const pushUrl = mockPush.mock.calls[0][0] as string + expect(pushUrl).toContain('keyword=test') + expect(pushUrl).toContain('page=2') + }) + + it('should reset query to defaults', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.resetQuery() + }) + + expect(mockPush).toHaveBeenCalled() + // Default query omits default values from URL + const pushUrl = mockPush.mock.calls[0][0] as string + expect(pushUrl).toBe('/datasets/ds-1/documents') + }) + }) + + describe('Document Sort Integration', () => { + it('should return documents unsorted when no sort field set', () => { + const docs = [ + createDoc({ id: 'doc-1', name: 'Banana.txt', word_count: 300 }), + createDoc({ id: 'doc-2', name: 'Apple.txt', word_count: 100 }), + createDoc({ id: 'doc-3', name: 'Cherry.txt', word_count: 200 }), + ] + + const { result } = renderHook(() => useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '-created_at', + })) + + expect(result.current.sortField).toBeNull() + expect(result.current.sortedDocuments).toHaveLength(3) + }) + + it('should sort by name descending', () => { + const docs = [ + createDoc({ id: 'doc-1', name: 'Banana.txt' }), + createDoc({ id: 'doc-2', name: 'Apple.txt' }), + createDoc({ id: 'doc-3', name: 'Cherry.txt' }), + ] + + const { result } = renderHook(() => useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '-created_at', + })) + + act(() => { + result.current.handleSort('name') + }) + + expect(result.current.sortField).toBe('name') + expect(result.current.sortOrder).toBe('desc') + const names = result.current.sortedDocuments.map(d => d.name) + expect(names).toEqual(['Cherry.txt', 'Banana.txt', 'Apple.txt']) + }) + + it('should toggle sort order on same field click', () => { + const docs = [createDoc({ id: 'doc-1', name: 'A.txt' }), createDoc({ id: 'doc-2', name: 'B.txt' })] + + const { result } = renderHook(() => useDocumentSort({ + documents: docs, + statusFilterValue: '', + remoteSortValue: '-created_at', + })) + + act(() => result.current.handleSort('name')) + expect(result.current.sortOrder).toBe('desc') + + act(() => result.current.handleSort('name')) + expect(result.current.sortOrder).toBe('asc') + }) + + it('should filter by status before sorting', () => { + const docs = [ + createDoc({ id: 'doc-1', name: 'A.txt', display_status: 'available' }), + createDoc({ id: 'doc-2', name: 'B.txt', display_status: 'error' }), + createDoc({ id: 'doc-3', name: 'C.txt', display_status: 'available' }), + ] + + const { result } = renderHook(() => useDocumentSort({ + documents: docs, + statusFilterValue: 'available', + remoteSortValue: '-created_at', + })) + + // Only 'available' documents should remain + expect(result.current.sortedDocuments).toHaveLength(2) + expect(result.current.sortedDocuments.every(d => d.display_status === 'available')).toBe(true) + }) + }) + + describe('Document Selection Integration', () => { + it('should manage selection state externally', () => { + const docs = [ + createDoc({ id: 'doc-1' }), + createDoc({ id: 'doc-2' }), + createDoc({ id: 'doc-3' }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: [], + onSelectedIdChange, + })) + + expect(result.current.isAllSelected).toBe(false) + expect(result.current.isSomeSelected).toBe(false) + }) + + it('should select all documents', () => { + const docs = [ + createDoc({ id: 'doc-1' }), + createDoc({ id: 'doc-2' }), + ] + const onSelectedIdChange = vi.fn() + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: [], + onSelectedIdChange, + })) + + act(() => { + result.current.onSelectAll() + }) + + expect(onSelectedIdChange).toHaveBeenCalledWith( + expect.arrayContaining(['doc-1', 'doc-2']), + ) + }) + + it('should detect all-selected state', () => { + const docs = [ + createDoc({ id: 'doc-1' }), + createDoc({ id: 'doc-2' }), + ] + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: ['doc-1', 'doc-2'], + onSelectedIdChange: vi.fn(), + })) + + expect(result.current.isAllSelected).toBe(true) + }) + + it('should detect partial selection', () => { + const docs = [ + createDoc({ id: 'doc-1' }), + createDoc({ id: 'doc-2' }), + createDoc({ id: 'doc-3' }), + ] + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: ['doc-1'], + onSelectedIdChange: vi.fn(), + })) + + expect(result.current.isSomeSelected).toBe(true) + expect(result.current.isAllSelected).toBe(false) + }) + + it('should identify downloadable selected documents (FILE type only)', () => { + const docs = [ + createDoc({ id: 'doc-1', data_source_type: DataSourceType.FILE }), + createDoc({ id: 'doc-2', data_source_type: DataSourceType.NOTION }), + ] + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: ['doc-1', 'doc-2'], + onSelectedIdChange: vi.fn(), + })) + + expect(result.current.downloadableSelectedIds).toEqual(['doc-1']) + }) + + it('should clear selection', () => { + const onSelectedIdChange = vi.fn() + const docs = [createDoc({ id: 'doc-1' })] + + const { result } = renderHook(() => useDocumentSelection({ + documents: docs, + selectedIds: ['doc-1'], + onSelectedIdChange, + })) + + act(() => { + result.current.clearSelection() + }) + + expect(onSelectedIdChange).toHaveBeenCalledWith([]) + }) + }) + + describe('Cross-Module: Query State → Sort → Selection Pipeline', () => { + it('should maintain consistent default state across all hooks', () => { + const docs = [createDoc({ id: 'doc-1' })] + const { result: queryResult } = renderHook(() => useDocumentListQueryState()) + const { result: sortResult } = renderHook(() => useDocumentSort({ + documents: docs, + statusFilterValue: queryResult.current.query.status, + remoteSortValue: queryResult.current.query.sort, + })) + const { result: selResult } = renderHook(() => useDocumentSelection({ + documents: sortResult.current.sortedDocuments, + selectedIds: [], + onSelectedIdChange: vi.fn(), + })) + + // Query defaults + expect(queryResult.current.query.sort).toBe('-created_at') + expect(queryResult.current.query.status).toBe('all') + + // Sort inherits 'all' status → no filtering applied + expect(sortResult.current.sortedDocuments).toHaveLength(1) + + // Selection starts empty + expect(selResult.current.isAllSelected).toBe(false) + }) + }) +}) diff --git a/web/__tests__/datasets/external-knowledge-base.test.tsx b/web/__tests__/datasets/external-knowledge-base.test.tsx new file mode 100644 index 0000000000..9c2b0da19d --- /dev/null +++ b/web/__tests__/datasets/external-knowledge-base.test.tsx @@ -0,0 +1,215 @@ +/** + * Integration Test: External Knowledge Base Creation Flow + * + * Tests the data contract, validation logic, and API interaction + * for external knowledge base creation. + */ + +import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations' +import { describe, expect, it } from 'vitest' + +// --- Factory --- +const createFormData = (overrides?: Partial): CreateKnowledgeBaseReq => ({ + name: 'My External KB', + description: 'A test external knowledge base', + external_knowledge_api_id: 'api-1', + external_knowledge_id: 'ext-kb-123', + external_retrieval_model: { + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }, + provider: 'external', + ...overrides, +}) + +describe('External Knowledge Base Creation Flow', () => { + describe('Data Contract: CreateKnowledgeBaseReq', () => { + it('should define a complete form structure', () => { + const form = createFormData() + + expect(form).toHaveProperty('name') + expect(form).toHaveProperty('external_knowledge_api_id') + expect(form).toHaveProperty('external_knowledge_id') + expect(form).toHaveProperty('external_retrieval_model') + expect(form).toHaveProperty('provider') + expect(form.provider).toBe('external') + }) + + it('should include retrieval model settings', () => { + const form = createFormData() + + expect(form.external_retrieval_model).toEqual({ + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }) + }) + + it('should allow partial overrides', () => { + const form = createFormData({ + name: 'Custom Name', + external_retrieval_model: { + top_k: 10, + score_threshold: 0.8, + score_threshold_enabled: true, + }, + }) + + expect(form.name).toBe('Custom Name') + expect(form.external_retrieval_model.top_k).toBe(10) + expect(form.external_retrieval_model.score_threshold_enabled).toBe(true) + }) + }) + + describe('Form Validation Logic', () => { + const isFormValid = (form: CreateKnowledgeBaseReq): boolean => { + return ( + form.name.trim() !== '' + && form.external_knowledge_api_id !== '' + && form.external_knowledge_id !== '' + && form.external_retrieval_model.top_k !== undefined + && form.external_retrieval_model.score_threshold !== undefined + ) + } + + it('should validate a complete form', () => { + const form = createFormData() + expect(isFormValid(form)).toBe(true) + }) + + it('should reject empty name', () => { + const form = createFormData({ name: '' }) + expect(isFormValid(form)).toBe(false) + }) + + it('should reject whitespace-only name', () => { + const form = createFormData({ name: ' ' }) + expect(isFormValid(form)).toBe(false) + }) + + it('should reject empty external_knowledge_api_id', () => { + const form = createFormData({ external_knowledge_api_id: '' }) + expect(isFormValid(form)).toBe(false) + }) + + it('should reject empty external_knowledge_id', () => { + const form = createFormData({ external_knowledge_id: '' }) + expect(isFormValid(form)).toBe(false) + }) + }) + + describe('Form State Transitions', () => { + it('should start with empty default state', () => { + const defaultForm: CreateKnowledgeBaseReq = { + name: '', + description: '', + external_knowledge_api_id: '', + external_knowledge_id: '', + external_retrieval_model: { + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }, + provider: 'external', + } + + // Verify default state matches component's initial useState + expect(defaultForm.name).toBe('') + expect(defaultForm.external_knowledge_api_id).toBe('') + expect(defaultForm.external_knowledge_id).toBe('') + expect(defaultForm.provider).toBe('external') + }) + + it('should support immutable form updates', () => { + const form = createFormData({ name: '' }) + const updated = { ...form, name: 'Updated Name' } + + expect(form.name).toBe('') + expect(updated.name).toBe('Updated Name') + // Other fields should remain unchanged + expect(updated.external_knowledge_api_id).toBe(form.external_knowledge_api_id) + }) + + it('should support retrieval model updates', () => { + const form = createFormData() + const updated = { + ...form, + external_retrieval_model: { + ...form.external_retrieval_model, + top_k: 10, + score_threshold_enabled: true, + }, + } + + expect(updated.external_retrieval_model.top_k).toBe(10) + expect(updated.external_retrieval_model.score_threshold_enabled).toBe(true) + // Unchanged field + expect(updated.external_retrieval_model.score_threshold).toBe(0.5) + }) + }) + + describe('API Call Data Contract', () => { + it('should produce a valid API payload from form data', () => { + const form = createFormData() + + // The API expects the full CreateKnowledgeBaseReq + expect(form.name).toBeTruthy() + expect(form.external_knowledge_api_id).toBeTruthy() + expect(form.external_knowledge_id).toBeTruthy() + expect(form.provider).toBe('external') + expect(typeof form.external_retrieval_model.top_k).toBe('number') + expect(typeof form.external_retrieval_model.score_threshold).toBe('number') + expect(typeof form.external_retrieval_model.score_threshold_enabled).toBe('boolean') + }) + + it('should support optional description', () => { + const formWithDesc = createFormData({ description: 'Some description' }) + const formWithoutDesc = createFormData({ description: '' }) + + expect(formWithDesc.description).toBe('Some description') + expect(formWithoutDesc.description).toBe('') + }) + + it('should validate retrieval model bounds', () => { + const form = createFormData({ + external_retrieval_model: { + top_k: 0, + score_threshold: 0, + score_threshold_enabled: false, + }, + }) + + expect(form.external_retrieval_model.top_k).toBe(0) + expect(form.external_retrieval_model.score_threshold).toBe(0) + }) + }) + + describe('External API List Integration', () => { + it('should validate API item structure', () => { + const apiItem = { + id: 'api-1', + name: 'Production API', + settings: { + endpoint: 'https://api.example.com', + api_key: 'key-123', + }, + } + + expect(apiItem).toHaveProperty('id') + expect(apiItem).toHaveProperty('name') + expect(apiItem).toHaveProperty('settings') + expect(apiItem.settings).toHaveProperty('endpoint') + expect(apiItem.settings).toHaveProperty('api_key') + }) + + it('should link API selection to form data', () => { + const selectedApi = { id: 'api-2', name: 'Staging API' } + const form = createFormData({ + external_knowledge_api_id: selectedApi.id, + }) + + expect(form.external_knowledge_api_id).toBe('api-2') + }) + }) +}) diff --git a/web/__tests__/datasets/hit-testing-flow.test.tsx b/web/__tests__/datasets/hit-testing-flow.test.tsx new file mode 100644 index 0000000000..93d6f77d8f --- /dev/null +++ b/web/__tests__/datasets/hit-testing-flow.test.tsx @@ -0,0 +1,404 @@ +/** + * Integration Test: Hit Testing Flow + * + * Tests the query submission → API response → callback chain flow + * by rendering the actual QueryInput component and triggering user interactions. + * Validates that the production onSubmit logic correctly constructs payloads + * and invokes callbacks on success/failure. + */ + +import type { + HitTestingResponse, + Query, +} from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import QueryInput from '@/app/components/datasets/hit-testing/components/query-input' +import { RETRIEVE_METHOD } from '@/types/app' + +// --- Mocks --- + +vi.mock('@/context/dataset-detail', () => ({ + default: {}, + useDatasetDetailContext: vi.fn(() => ({ dataset: undefined })), + useDatasetDetailContextWithSelector: vi.fn(() => false), +})) + +vi.mock('use-context-selector', () => ({ + useContext: vi.fn(() => ({})), + useContextSelector: vi.fn(() => false), + createContext: vi.fn(() => ({})), +})) + +vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ + default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => ( +
+ {textArea} + {actionButton} +
+ ), +})) + +// --- Factories --- + +const createRetrievalConfig = (overrides = {}): RetrievalConfig => ({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_mode: undefined, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + weights: undefined, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + ...overrides, +} as RetrievalConfig) + +const createHitTestingResponse = (numResults: number): HitTestingResponse => ({ + query: { + content: 'What is Dify?', + tsne_position: { x: 0, y: 0 }, + }, + records: Array.from({ length: numResults }, (_, i) => ({ + segment: { + id: `seg-${i}`, + document: { + id: `doc-${i}`, + data_source_type: 'upload_file', + name: `document-${i}.txt`, + doc_type: null as unknown as import('@/models/datasets').DocType, + }, + content: `Result content ${i}`, + sign_content: `Result content ${i}`, + position: i + 1, + word_count: 100 + i * 50, + tokens: 50 + i * 25, + keywords: ['test', 'dify'], + hit_count: i * 5, + index_node_hash: `hash-${i}`, + answer: '', + }, + content: { + id: `seg-${i}`, + document: { + id: `doc-${i}`, + data_source_type: 'upload_file', + name: `document-${i}.txt`, + doc_type: null as unknown as import('@/models/datasets').DocType, + }, + content: `Result content ${i}`, + sign_content: `Result content ${i}`, + position: i + 1, + word_count: 100 + i * 50, + tokens: 50 + i * 25, + keywords: ['test', 'dify'], + hit_count: i * 5, + index_node_hash: `hash-${i}`, + answer: '', + }, + score: 0.95 - i * 0.1, + tsne_position: { x: 0, y: 0 }, + child_chunks: null, + files: [], + })), +}) + +const createTextQuery = (content: string): Query[] => [ + { content, content_type: 'text_query', file_info: null }, +] + +// --- Helpers --- + +const findSubmitButton = () => { + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + expect(submitButton).toBeTruthy() + return submitButton! +} + +// --- Tests --- + +describe('Hit Testing Flow', () => { + const mockHitTestingMutation = vi.fn() + const mockExternalMutation = vi.fn() + const mockSetHitResult = vi.fn() + const mockSetExternalHitResult = vi.fn() + const mockOnUpdateList = vi.fn() + const mockSetQueries = vi.fn() + const mockOnClickRetrievalMethod = vi.fn() + const mockOnSubmit = vi.fn() + + const createDefaultProps = (overrides: Record = {}) => ({ + onUpdateList: mockOnUpdateList, + setHitResult: mockSetHitResult, + setExternalHitResult: mockSetExternalHitResult, + loading: false, + queries: [] as Query[], + setQueries: mockSetQueries, + isExternal: false, + onClickRetrievalMethod: mockOnClickRetrievalMethod, + retrievalConfig: createRetrievalConfig(), + isEconomy: false, + onSubmit: mockOnSubmit, + hitTestingMutation: mockHitTestingMutation, + externalKnowledgeBaseHitTestingMutation: mockExternalMutation, + ...overrides, + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Query Submission → API Call', () => { + it('should call hitTestingMutation with correct payload including retrieval model', async () => { + const retrievalConfig = createRetrievalConfig({ + search_method: RETRIEVE_METHOD.semantic, + top_k: 3, + score_threshold_enabled: false, + }) + mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(3)) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockHitTestingMutation).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'How does RAG work?', + attachment_ids: [], + retrieval_model: expect.objectContaining({ + search_method: RETRIEVE_METHOD.semantic, + top_k: 3, + score_threshold_enabled: false, + }), + }), + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) + }) + }) + + it('should override search_method to keywordSearch when isEconomy is true', async () => { + const retrievalConfig = createRetrievalConfig({ search_method: RETRIEVE_METHOD.semantic }) + mockHitTestingMutation.mockResolvedValue(createHitTestingResponse(1)) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockHitTestingMutation).toHaveBeenCalledWith( + expect.objectContaining({ + retrieval_model: expect.objectContaining({ + search_method: RETRIEVE_METHOD.keywordSearch, + }), + }), + expect.anything(), + ) + }) + }) + + it('should handle empty results by calling setHitResult with empty records', async () => { + const emptyResponse = createHitTestingResponse(0) + mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { + options?.onSuccess?.(emptyResponse) + return emptyResponse + }) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockSetHitResult).toHaveBeenCalledWith( + expect.objectContaining({ records: [] }), + ) + }) + }) + + it('should not call success callbacks when mutation resolves without onSuccess', async () => { + // Simulate a mutation that resolves but does not invoke the onSuccess callback + mockHitTestingMutation.mockResolvedValue(undefined) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockHitTestingMutation).toHaveBeenCalled() + }) + // Success callbacks should not fire when onSuccess is not invoked + expect(mockSetHitResult).not.toHaveBeenCalled() + expect(mockOnUpdateList).not.toHaveBeenCalled() + expect(mockOnSubmit).not.toHaveBeenCalled() + }) + }) + + describe('API Response → Results Data Contract', () => { + it('should produce results with required segment fields for rendering', () => { + const response = createHitTestingResponse(3) + + // Validate each result has the fields needed by ResultItem component + response.records.forEach((record) => { + expect(record.segment).toHaveProperty('id') + expect(record.segment).toHaveProperty('content') + expect(record.segment).toHaveProperty('position') + expect(record.segment).toHaveProperty('word_count') + expect(record.segment).toHaveProperty('document') + expect(record.segment.document).toHaveProperty('name') + expect(record.score).toBeGreaterThanOrEqual(0) + expect(record.score).toBeLessThanOrEqual(1) + }) + }) + + it('should maintain correct score ordering', () => { + const response = createHitTestingResponse(5) + + for (let i = 1; i < response.records.length; i++) { + expect(response.records[i - 1].score).toBeGreaterThanOrEqual(response.records[i].score) + } + }) + + it('should include document metadata for result item display', () => { + const response = createHitTestingResponse(1) + const record = response.records[0] + + expect(record.segment.document.name).toBeTruthy() + expect(record.segment.document.data_source_type).toBeTruthy() + }) + }) + + describe('Successful Submission → Callback Chain', () => { + it('should call setHitResult, onUpdateList, and onSubmit after successful submission', async () => { + const response = createHitTestingResponse(3) + mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { + options?.onSuccess?.(response) + return response + }) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockSetHitResult).toHaveBeenCalledWith(response) + expect(mockOnUpdateList).toHaveBeenCalledTimes(1) + expect(mockOnSubmit).toHaveBeenCalledTimes(1) + }) + }) + + it('should trigger records list refresh via onUpdateList after query', async () => { + const response = createHitTestingResponse(1) + mockHitTestingMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: HitTestingResponse) => void }) => { + options?.onSuccess?.(response) + return response + }) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockOnUpdateList).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe('External KB Hit Testing', () => { + it('should use external mutation with correct payload for external datasets', async () => { + mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => { + const response = { records: [] } + options?.onSuccess?.(response) + return response + }) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockExternalMutation).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'test', + external_retrieval_model: expect.objectContaining({ + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }), + }), + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) + // Internal mutation should NOT be called + expect(mockHitTestingMutation).not.toHaveBeenCalled() + }) + }) + + it('should call setExternalHitResult and onUpdateList on successful external submission', async () => { + const externalResponse = { records: [] } + mockExternalMutation.mockImplementation(async (_params: unknown, options?: { onSuccess?: (data: { records: never[] }) => void }) => { + options?.onSuccess?.(externalResponse) + return externalResponse + }) + + render( + , + ) + + fireEvent.click(findSubmitButton()) + + await waitFor(() => { + expect(mockSetExternalHitResult).toHaveBeenCalledWith(externalResponse) + expect(mockOnUpdateList).toHaveBeenCalledTimes(1) + }) + }) + }) +}) diff --git a/web/__tests__/datasets/metadata-management-flow.test.tsx b/web/__tests__/datasets/metadata-management-flow.test.tsx new file mode 100644 index 0000000000..d8403f0f21 --- /dev/null +++ b/web/__tests__/datasets/metadata-management-flow.test.tsx @@ -0,0 +1,337 @@ +/** + * Integration Test: Metadata Management Flow + * + * Tests the cross-module composition of metadata name validation, type constraints, + * and duplicate detection across the metadata management hooks. + * + * The unit-level use-check-metadata-name.spec.ts tests the validation hook alone. + * This integration test verifies: + * - Name validation combined with existing metadata list (duplicate detection) + * - Metadata type enum constraints matching expected data model + * - Full add/rename workflow: validate name → check duplicates → allow or reject + * - Name uniqueness logic: existing metadata keeps its own name, cannot take another's + */ + +import type { MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types' +import { renderHook } from '@testing-library/react' +import { DataType } from '@/app/components/datasets/metadata/types' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +const { default: useCheckMetadataName } = await import( + '@/app/components/datasets/metadata/hooks/use-check-metadata-name', +) + +// --- Factory functions --- + +const createMetadataItem = ( + id: string, + name: string, + type = DataType.string, + count = 0, +): MetadataItemWithValueLength => ({ + id, + name, + type, + count, +}) + +const createMetadataList = (): MetadataItemWithValueLength[] => [ + createMetadataItem('meta-1', 'author', DataType.string, 5), + createMetadataItem('meta-2', 'created_date', DataType.time, 10), + createMetadataItem('meta-3', 'page_count', DataType.number, 3), + createMetadataItem('meta-4', 'source_url', DataType.string, 8), + createMetadataItem('meta-5', 'version', DataType.number, 2), +] + +describe('Metadata Management Flow - Cross-Module Validation Composition', () => { + describe('Name Validation Flow: Format Rules', () => { + it('should accept valid lowercase names with underscores', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + expect(result.current.checkName('valid_name').errorMsg).toBe('') + expect(result.current.checkName('author').errorMsg).toBe('') + expect(result.current.checkName('page_count').errorMsg).toBe('') + expect(result.current.checkName('v2_field').errorMsg).toBe('') + }) + + it('should reject empty names', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + expect(result.current.checkName('').errorMsg).toBeTruthy() + }) + + it('should reject names with invalid characters', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + expect(result.current.checkName('Author').errorMsg).toBeTruthy() + expect(result.current.checkName('my-field').errorMsg).toBeTruthy() + expect(result.current.checkName('field name').errorMsg).toBeTruthy() + expect(result.current.checkName('1field').errorMsg).toBeTruthy() + expect(result.current.checkName('_private').errorMsg).toBeTruthy() + }) + + it('should reject names exceeding 255 characters', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + const longName = 'a'.repeat(256) + expect(result.current.checkName(longName).errorMsg).toBeTruthy() + + const maxName = 'a'.repeat(255) + expect(result.current.checkName(maxName).errorMsg).toBe('') + }) + }) + + describe('Metadata Type Constraints: Enum Values Match Expected Set', () => { + it('should define exactly three data types', () => { + const typeValues = Object.values(DataType) + expect(typeValues).toHaveLength(3) + }) + + it('should include string, number, and time types', () => { + expect(DataType.string).toBe('string') + expect(DataType.number).toBe('number') + expect(DataType.time).toBe('time') + }) + + it('should use consistent types in metadata items', () => { + const metadataList = createMetadataList() + + const stringItems = metadataList.filter(m => m.type === DataType.string) + const numberItems = metadataList.filter(m => m.type === DataType.number) + const timeItems = metadataList.filter(m => m.type === DataType.time) + + expect(stringItems).toHaveLength(2) + expect(numberItems).toHaveLength(2) + expect(timeItems).toHaveLength(1) + }) + + it('should enforce type-safe metadata item construction', () => { + const item = createMetadataItem('test-1', 'test_field', DataType.number, 0) + + expect(item.id).toBe('test-1') + expect(item.name).toBe('test_field') + expect(item.type).toBe(DataType.number) + expect(item.count).toBe(0) + }) + }) + + describe('Duplicate Name Detection: Add Metadata → Check Name → Detect Duplicates', () => { + it('should detect duplicate names against an existing metadata list', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const checkDuplicate = (newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + return existingMetadata.some(m => m.name === newName) + } + + expect(checkDuplicate('author')).toBe(true) + expect(checkDuplicate('created_date')).toBe(true) + expect(checkDuplicate('page_count')).toBe(true) + }) + + it('should allow names that do not conflict with existing metadata', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const isNameAvailable = (newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + return !existingMetadata.some(m => m.name === newName) + } + + expect(isNameAvailable('category')).toBe(true) + expect(isNameAvailable('file_size')).toBe(true) + expect(isNameAvailable('language')).toBe(true) + }) + + it('should reject names that fail format validation before duplicate check', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + const validateAndCheckDuplicate = (newName: string): { valid: boolean, reason: string } => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return { valid: false, reason: 'format' } + return { valid: true, reason: '' } + } + + expect(validateAndCheckDuplicate('Author').reason).toBe('format') + expect(validateAndCheckDuplicate('').reason).toBe('format') + expect(validateAndCheckDuplicate('valid_name').valid).toBe(true) + }) + }) + + describe('Name Uniqueness Across Edits: Rename Workflow', () => { + it('should allow an existing metadata item to keep its own name', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const isRenameValid = (itemId: string, newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + // Allow keeping the same name (skip self in duplicate check) + return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + } + + // Author keeping its own name should be valid + expect(isRenameValid('meta-1', 'author')).toBe(true) + // page_count keeping its own name should be valid + expect(isRenameValid('meta-3', 'page_count')).toBe(true) + }) + + it('should reject renaming to another existing metadata name', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const isRenameValid = (itemId: string, newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + } + + // Author trying to rename to "page_count" (taken by meta-3) + expect(isRenameValid('meta-1', 'page_count')).toBe(false) + // version trying to rename to "source_url" (taken by meta-4) + expect(isRenameValid('meta-5', 'source_url')).toBe(false) + }) + + it('should allow renaming to a completely new valid name', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const isRenameValid = (itemId: string, newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + } + + expect(isRenameValid('meta-1', 'document_author')).toBe(true) + expect(isRenameValid('meta-2', 'publish_date')).toBe(true) + expect(isRenameValid('meta-3', 'total_pages')).toBe(true) + }) + + it('should reject renaming with an invalid format even if name is unique', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const isRenameValid = (itemId: string, newName: string): boolean => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return false + return !existingMetadata.some(m => m.name === newName && m.id !== itemId) + } + + expect(isRenameValid('meta-1', 'New Author')).toBe(false) + expect(isRenameValid('meta-2', '2024_date')).toBe(false) + expect(isRenameValid('meta-3', '')).toBe(false) + }) + }) + + describe('Full Metadata Management Workflow', () => { + it('should support a complete add-validate-check-duplicate cycle', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const addMetadataField = ( + name: string, + type: DataType, + ): { success: boolean, error?: string } => { + const formatCheck = result.current.checkName(name) + if (formatCheck.errorMsg) + return { success: false, error: 'invalid_format' } + + if (existingMetadata.some(m => m.name === name)) + return { success: false, error: 'duplicate_name' } + + existingMetadata.push(createMetadataItem(`meta-${existingMetadata.length + 1}`, name, type)) + return { success: true } + } + + // Add a valid new field + const result1 = addMetadataField('department', DataType.string) + expect(result1.success).toBe(true) + expect(existingMetadata).toHaveLength(6) + + // Try to add a duplicate + const result2 = addMetadataField('author', DataType.string) + expect(result2.success).toBe(false) + expect(result2.error).toBe('duplicate_name') + expect(existingMetadata).toHaveLength(6) + + // Try to add an invalid name + const result3 = addMetadataField('Invalid Name', DataType.string) + expect(result3.success).toBe(false) + expect(result3.error).toBe('invalid_format') + expect(existingMetadata).toHaveLength(6) + + // Add another valid field + const result4 = addMetadataField('priority_level', DataType.number) + expect(result4.success).toBe(true) + expect(existingMetadata).toHaveLength(7) + }) + + it('should support a complete rename workflow with validation chain', () => { + const { result } = renderHook(() => useCheckMetadataName()) + const existingMetadata = createMetadataList() + + const renameMetadataField = ( + itemId: string, + newName: string, + ): { success: boolean, error?: string } => { + const formatCheck = result.current.checkName(newName) + if (formatCheck.errorMsg) + return { success: false, error: 'invalid_format' } + + if (existingMetadata.some(m => m.name === newName && m.id !== itemId)) + return { success: false, error: 'duplicate_name' } + + const item = existingMetadata.find(m => m.id === itemId) + if (!item) + return { success: false, error: 'not_found' } + + // Simulate the rename in-place + const index = existingMetadata.indexOf(item) + existingMetadata[index] = { ...item, name: newName } + return { success: true } + } + + // Rename author to document_author + expect(renameMetadataField('meta-1', 'document_author').success).toBe(true) + expect(existingMetadata.find(m => m.id === 'meta-1')?.name).toBe('document_author') + + // Try renaming created_date to page_count (already taken) + expect(renameMetadataField('meta-2', 'page_count').error).toBe('duplicate_name') + + // Rename to invalid format + expect(renameMetadataField('meta-3', 'Page Count').error).toBe('invalid_format') + + // Rename non-existent item + expect(renameMetadataField('meta-999', 'something').error).toBe('not_found') + }) + + it('should maintain validation consistency across multiple operations', () => { + const { result } = renderHook(() => useCheckMetadataName()) + + // Validate the same name multiple times for consistency + const name = 'consistent_field' + const results = Array.from({ length: 5 }, () => result.current.checkName(name)) + + expect(results.every(r => r.errorMsg === '')).toBe(true) + + // Validate an invalid name multiple times + const invalidResults = Array.from({ length: 5 }, () => result.current.checkName('Invalid')) + expect(invalidResults.every(r => r.errorMsg !== '')).toBe(true) + }) + }) +}) diff --git a/web/__tests__/datasets/pipeline-datasource-flow.test.tsx b/web/__tests__/datasets/pipeline-datasource-flow.test.tsx new file mode 100644 index 0000000000..dc140e8514 --- /dev/null +++ b/web/__tests__/datasets/pipeline-datasource-flow.test.tsx @@ -0,0 +1,477 @@ +/** + * Integration Test: Pipeline Data Source Store Composition + * + * Tests cross-slice interactions in the pipeline data source Zustand store. + * The unit-level slice specs test each slice in isolation. + * This integration test verifies: + * - Store initialization produces correct defaults across all slices + * - Cross-slice coordination (e.g. credential shared across slices) + * - State isolation: changes in one slice do not affect others + * - Full workflow simulation through credential → source → data path + */ + +import type { NotionPage } from '@/models/common' +import type { CrawlResultItem, FileItem } from '@/models/datasets' +import type { OnlineDriveFile } from '@/models/pipeline' +import { createDataSourceStore } from '@/app/components/datasets/documents/create-from-pipeline/data-source/store' +import { CrawlStep } from '@/models/datasets' +import { OnlineDriveFileType } from '@/models/pipeline' + +// --- Factory functions --- + +const createFileItem = (id: string): FileItem => ({ + fileID: id, + file: { id, name: `${id}.txt`, size: 1024 } as FileItem['file'], + progress: 100, +}) + +const createCrawlResultItem = (url: string, title?: string): CrawlResultItem => ({ + title: title ?? `Page: ${url}`, + markdown: `# ${title ?? url}\n\nContent for ${url}`, + description: `Description for ${url}`, + source_url: url, +}) + +const createOnlineDriveFile = (id: string, name: string, type = OnlineDriveFileType.file): OnlineDriveFile => ({ + id, + name, + size: 2048, + type, +}) + +const createNotionPage = (pageId: string): NotionPage => ({ + page_id: pageId, + page_name: `Page ${pageId}`, + page_icon: null, + is_bound: true, + parent_id: 'parent-1', + type: 'page', + workspace_id: 'ws-1', +}) + +describe('Pipeline Data Source Store Composition - Cross-Slice Integration', () => { + describe('Store Initialization → All Slices Have Correct Defaults', () => { + it('should create a store with all five slices combined', () => { + const store = createDataSourceStore() + const state = store.getState() + + // Common slice defaults + expect(state.currentCredentialId).toBe('') + expect(state.currentNodeIdRef.current).toBe('') + + // Local file slice defaults + expect(state.localFileList).toEqual([]) + expect(state.currentLocalFile).toBeUndefined() + + // Online document slice defaults + expect(state.documentsData).toEqual([]) + expect(state.onlineDocuments).toEqual([]) + expect(state.searchValue).toBe('') + expect(state.selectedPagesId).toEqual(new Set()) + + // Website crawl slice defaults + expect(state.websitePages).toEqual([]) + expect(state.step).toBe(CrawlStep.init) + expect(state.previewIndex).toBe(-1) + + // Online drive slice defaults + expect(state.breadcrumbs).toEqual([]) + expect(state.prefix).toEqual([]) + expect(state.keywords).toBe('') + expect(state.selectedFileIds).toEqual([]) + expect(state.onlineDriveFileList).toEqual([]) + expect(state.bucket).toBe('') + expect(state.hasBucket).toBe(false) + }) + }) + + describe('Cross-Slice Coordination: Shared Credential', () => { + it('should set credential that is accessible from the common slice', () => { + const store = createDataSourceStore() + + store.getState().setCurrentCredentialId('cred-abc-123') + + expect(store.getState().currentCredentialId).toBe('cred-abc-123') + }) + + it('should allow credential update independently of all other slices', () => { + const store = createDataSourceStore() + + store.getState().setLocalFileList([createFileItem('f1')]) + store.getState().setCurrentCredentialId('cred-xyz') + + expect(store.getState().currentCredentialId).toBe('cred-xyz') + expect(store.getState().localFileList).toHaveLength(1) + }) + }) + + describe('Local File Workflow: Set Files → Verify List → Clear', () => { + it('should set and retrieve local file list', () => { + const store = createDataSourceStore() + const files = [createFileItem('f1'), createFileItem('f2'), createFileItem('f3')] + + store.getState().setLocalFileList(files) + + expect(store.getState().localFileList).toHaveLength(3) + expect(store.getState().localFileList[0].fileID).toBe('f1') + expect(store.getState().localFileList[2].fileID).toBe('f3') + }) + + it('should update preview ref when setting file list', () => { + const store = createDataSourceStore() + const files = [createFileItem('f-preview')] + + store.getState().setLocalFileList(files) + + expect(store.getState().previewLocalFileRef.current).toBeDefined() + }) + + it('should clear files by setting empty list', () => { + const store = createDataSourceStore() + + store.getState().setLocalFileList([createFileItem('f1')]) + expect(store.getState().localFileList).toHaveLength(1) + + store.getState().setLocalFileList([]) + expect(store.getState().localFileList).toHaveLength(0) + }) + + it('should set and clear current local file selection', () => { + const store = createDataSourceStore() + const file = { id: 'current-file', name: 'current.txt' } as FileItem['file'] + + store.getState().setCurrentLocalFile(file) + expect(store.getState().currentLocalFile).toBeDefined() + expect(store.getState().currentLocalFile?.id).toBe('current-file') + + store.getState().setCurrentLocalFile(undefined) + expect(store.getState().currentLocalFile).toBeUndefined() + }) + }) + + describe('Online Document Workflow: Set Documents → Select Pages → Verify', () => { + it('should set documents data and online documents', () => { + const store = createDataSourceStore() + const pages = [createNotionPage('page-1'), createNotionPage('page-2')] + + store.getState().setOnlineDocuments(pages) + + expect(store.getState().onlineDocuments).toHaveLength(2) + expect(store.getState().onlineDocuments[0].page_id).toBe('page-1') + }) + + it('should update preview ref when setting online documents', () => { + const store = createDataSourceStore() + const pages = [createNotionPage('page-preview')] + + store.getState().setOnlineDocuments(pages) + + expect(store.getState().previewOnlineDocumentRef.current).toBeDefined() + expect(store.getState().previewOnlineDocumentRef.current?.page_id).toBe('page-preview') + }) + + it('should track selected page IDs', () => { + const store = createDataSourceStore() + const pages = [createNotionPage('p1'), createNotionPage('p2'), createNotionPage('p3')] + + store.getState().setOnlineDocuments(pages) + store.getState().setSelectedPagesId(new Set(['p1', 'p3'])) + + expect(store.getState().selectedPagesId.size).toBe(2) + expect(store.getState().selectedPagesId.has('p1')).toBe(true) + expect(store.getState().selectedPagesId.has('p2')).toBe(false) + expect(store.getState().selectedPagesId.has('p3')).toBe(true) + }) + + it('should manage search value for filtering documents', () => { + const store = createDataSourceStore() + + store.getState().setSearchValue('meeting notes') + + expect(store.getState().searchValue).toBe('meeting notes') + }) + + it('should set and clear current document selection', () => { + const store = createDataSourceStore() + const page = createNotionPage('current-page') + + store.getState().setCurrentDocument(page) + expect(store.getState().currentDocument?.page_id).toBe('current-page') + + store.getState().setCurrentDocument(undefined) + expect(store.getState().currentDocument).toBeUndefined() + }) + }) + + describe('Website Crawl Workflow: Set Pages → Track Step → Preview', () => { + it('should set website pages and update preview ref', () => { + const store = createDataSourceStore() + const pages = [ + createCrawlResultItem('https://example.com'), + createCrawlResultItem('https://example.com/about'), + ] + + store.getState().setWebsitePages(pages) + + expect(store.getState().websitePages).toHaveLength(2) + expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://example.com') + }) + + it('should manage crawl step transitions', () => { + const store = createDataSourceStore() + + expect(store.getState().step).toBe(CrawlStep.init) + + store.getState().setStep(CrawlStep.running) + expect(store.getState().step).toBe(CrawlStep.running) + + store.getState().setStep(CrawlStep.finished) + expect(store.getState().step).toBe(CrawlStep.finished) + }) + + it('should set crawl result with data and timing', () => { + const store = createDataSourceStore() + const result = { + data: [createCrawlResultItem('https://test.com')], + time_consuming: 3.5, + } + + store.getState().setCrawlResult(result) + + expect(store.getState().crawlResult?.data).toHaveLength(1) + expect(store.getState().crawlResult?.time_consuming).toBe(3.5) + }) + + it('should manage preview index for page navigation', () => { + const store = createDataSourceStore() + + store.getState().setPreviewIndex(2) + expect(store.getState().previewIndex).toBe(2) + + store.getState().setPreviewIndex(-1) + expect(store.getState().previewIndex).toBe(-1) + }) + + it('should set and clear current website selection', () => { + const store = createDataSourceStore() + const page = createCrawlResultItem('https://current.com') + + store.getState().setCurrentWebsite(page) + expect(store.getState().currentWebsite?.source_url).toBe('https://current.com') + + store.getState().setCurrentWebsite(undefined) + expect(store.getState().currentWebsite).toBeUndefined() + }) + }) + + describe('Online Drive Workflow: Breadcrumbs → File Selection → Navigation', () => { + it('should manage breadcrumb navigation', () => { + const store = createDataSourceStore() + + store.getState().setBreadcrumbs(['root', 'folder-a', 'subfolder']) + + expect(store.getState().breadcrumbs).toEqual(['root', 'folder-a', 'subfolder']) + }) + + it('should support breadcrumb push/pop pattern', () => { + const store = createDataSourceStore() + + store.getState().setBreadcrumbs(['root']) + store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-1']) + store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'level-2']) + + expect(store.getState().breadcrumbs).toEqual(['root', 'level-1', 'level-2']) + + // Pop back one level + store.getState().setBreadcrumbs(store.getState().breadcrumbs.slice(0, -1)) + expect(store.getState().breadcrumbs).toEqual(['root', 'level-1']) + }) + + it('should manage file list and selection', () => { + const store = createDataSourceStore() + const files = [ + createOnlineDriveFile('drive-1', 'report.pdf'), + createOnlineDriveFile('drive-2', 'data.csv'), + createOnlineDriveFile('drive-3', 'images', OnlineDriveFileType.folder), + ] + + store.getState().setOnlineDriveFileList(files) + expect(store.getState().onlineDriveFileList).toHaveLength(3) + + store.getState().setSelectedFileIds(['drive-1', 'drive-2']) + expect(store.getState().selectedFileIds).toEqual(['drive-1', 'drive-2']) + }) + + it('should update preview ref when selecting files', () => { + const store = createDataSourceStore() + const files = [ + createOnlineDriveFile('drive-a', 'file-a.txt'), + createOnlineDriveFile('drive-b', 'file-b.txt'), + ] + + store.getState().setOnlineDriveFileList(files) + store.getState().setSelectedFileIds(['drive-b']) + + expect(store.getState().previewOnlineDriveFileRef.current?.id).toBe('drive-b') + }) + + it('should manage bucket and prefix for S3-like navigation', () => { + const store = createDataSourceStore() + + store.getState().setBucket('my-data-bucket') + store.getState().setPrefix(['data', '2024']) + store.getState().setHasBucket(true) + + expect(store.getState().bucket).toBe('my-data-bucket') + expect(store.getState().prefix).toEqual(['data', '2024']) + expect(store.getState().hasBucket).toBe(true) + }) + + it('should manage keywords for search filtering', () => { + const store = createDataSourceStore() + + store.getState().setKeywords('quarterly report') + expect(store.getState().keywords).toBe('quarterly report') + }) + }) + + describe('State Isolation: Changes to One Slice Do Not Affect Others', () => { + it('should keep local file state independent from online document state', () => { + const store = createDataSourceStore() + + store.getState().setLocalFileList([createFileItem('local-1')]) + store.getState().setOnlineDocuments([createNotionPage('notion-1')]) + + expect(store.getState().localFileList).toHaveLength(1) + expect(store.getState().onlineDocuments).toHaveLength(1) + + // Clearing local files should not affect online documents + store.getState().setLocalFileList([]) + expect(store.getState().localFileList).toHaveLength(0) + expect(store.getState().onlineDocuments).toHaveLength(1) + }) + + it('should keep website crawl state independent from online drive state', () => { + const store = createDataSourceStore() + + store.getState().setWebsitePages([createCrawlResultItem('https://site.com')]) + store.getState().setOnlineDriveFileList([createOnlineDriveFile('d1', 'file.txt')]) + + expect(store.getState().websitePages).toHaveLength(1) + expect(store.getState().onlineDriveFileList).toHaveLength(1) + + // Clearing website pages should not affect drive files + store.getState().setWebsitePages([]) + expect(store.getState().websitePages).toHaveLength(0) + expect(store.getState().onlineDriveFileList).toHaveLength(1) + }) + + it('should create fully independent store instances', () => { + const storeA = createDataSourceStore() + const storeB = createDataSourceStore() + + storeA.getState().setCurrentCredentialId('cred-A') + storeA.getState().setLocalFileList([createFileItem('fa-1')]) + + expect(storeA.getState().currentCredentialId).toBe('cred-A') + expect(storeB.getState().currentCredentialId).toBe('') + expect(storeB.getState().localFileList).toEqual([]) + }) + }) + + describe('Full Workflow Simulation: Credential → Source → Data → Verify', () => { + it('should support a complete local file upload workflow', () => { + const store = createDataSourceStore() + + // Step 1: Set credential + store.getState().setCurrentCredentialId('upload-cred-1') + + // Step 2: Set file list + const files = [createFileItem('upload-1'), createFileItem('upload-2')] + store.getState().setLocalFileList(files) + + // Step 3: Select current file for preview + store.getState().setCurrentLocalFile(files[0].file) + + // Verify all state is consistent + expect(store.getState().currentCredentialId).toBe('upload-cred-1') + expect(store.getState().localFileList).toHaveLength(2) + expect(store.getState().currentLocalFile?.id).toBe('upload-1') + expect(store.getState().previewLocalFileRef.current).toBeDefined() + }) + + it('should support a complete website crawl workflow', () => { + const store = createDataSourceStore() + + // Step 1: Set credential + store.getState().setCurrentCredentialId('crawl-cred-1') + + // Step 2: Init crawl + store.getState().setStep(CrawlStep.running) + + // Step 3: Crawl completes with results + const crawledPages = [ + createCrawlResultItem('https://docs.example.com/guide'), + createCrawlResultItem('https://docs.example.com/api'), + createCrawlResultItem('https://docs.example.com/faq'), + ] + store.getState().setCrawlResult({ data: crawledPages, time_consuming: 12.5 }) + store.getState().setStep(CrawlStep.finished) + + // Step 4: Set website pages from results + store.getState().setWebsitePages(crawledPages) + + // Step 5: Set preview + store.getState().setPreviewIndex(1) + + // Verify all state + expect(store.getState().currentCredentialId).toBe('crawl-cred-1') + expect(store.getState().step).toBe(CrawlStep.finished) + expect(store.getState().websitePages).toHaveLength(3) + expect(store.getState().crawlResult?.time_consuming).toBe(12.5) + expect(store.getState().previewIndex).toBe(1) + expect(store.getState().previewWebsitePageRef.current?.source_url).toBe('https://docs.example.com/guide') + }) + + it('should support a complete online drive navigation workflow', () => { + const store = createDataSourceStore() + + // Step 1: Set credential + store.getState().setCurrentCredentialId('drive-cred-1') + + // Step 2: Set bucket + store.getState().setBucket('company-docs') + store.getState().setHasBucket(true) + + // Step 3: Navigate into folders + store.getState().setBreadcrumbs(['company-docs']) + store.getState().setPrefix(['projects']) + const folderFiles = [ + createOnlineDriveFile('proj-1', 'project-alpha', OnlineDriveFileType.folder), + createOnlineDriveFile('proj-2', 'project-beta', OnlineDriveFileType.folder), + createOnlineDriveFile('readme', 'README.md', OnlineDriveFileType.file), + ] + store.getState().setOnlineDriveFileList(folderFiles) + + // Step 4: Navigate deeper + store.getState().setBreadcrumbs([...store.getState().breadcrumbs, 'project-alpha']) + store.getState().setPrefix([...store.getState().prefix, 'project-alpha']) + + // Step 5: Select files + store.getState().setOnlineDriveFileList([ + createOnlineDriveFile('doc-1', 'spec.pdf'), + createOnlineDriveFile('doc-2', 'design.fig'), + ]) + store.getState().setSelectedFileIds(['doc-1']) + + // Verify full state + expect(store.getState().currentCredentialId).toBe('drive-cred-1') + expect(store.getState().bucket).toBe('company-docs') + expect(store.getState().breadcrumbs).toEqual(['company-docs', 'project-alpha']) + expect(store.getState().prefix).toEqual(['projects', 'project-alpha']) + expect(store.getState().onlineDriveFileList).toHaveLength(2) + expect(store.getState().selectedFileIds).toEqual(['doc-1']) + expect(store.getState().previewOnlineDriveFileRef.current?.name).toBe('spec.pdf') + }) + }) +}) diff --git a/web/__tests__/datasets/segment-crud.test.tsx b/web/__tests__/datasets/segment-crud.test.tsx new file mode 100644 index 0000000000..9190e17395 --- /dev/null +++ b/web/__tests__/datasets/segment-crud.test.tsx @@ -0,0 +1,301 @@ +/** + * Integration Test: Segment CRUD Flow + * + * Tests segment selection, search/filter, and modal state management across hooks. + * Validates cross-hook data contracts in the completed segment module. + */ + +import type { SegmentDetailModel } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useModalState } from '@/app/components/datasets/documents/detail/completed/hooks/use-modal-state' +import { useSearchFilter } from '@/app/components/datasets/documents/detail/completed/hooks/use-search-filter' +import { useSegmentSelection } from '@/app/components/datasets/documents/detail/completed/hooks/use-segment-selection' + +const createSegment = (id: string, content = 'Test segment content'): SegmentDetailModel => ({ + id, + position: 1, + document_id: 'doc-1', + content, + sign_content: content, + answer: '', + word_count: 50, + tokens: 25, + keywords: ['test'], + index_node_id: 'idx-1', + index_node_hash: 'hash-1', + hit_count: 0, + enabled: true, + disabled_at: 0, + disabled_by: '', + status: 'completed', + created_by: 'user-1', + created_at: Date.now(), + indexing_at: Date.now(), + completed_at: Date.now(), + error: null, + stopped_at: 0, + updated_at: Date.now(), + attachments: [], +} as SegmentDetailModel) + +describe('Segment CRUD Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Search and Filter → Segment List Query', () => { + it('should manage search input with debounce', () => { + vi.useFakeTimers() + const onPageChange = vi.fn() + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.handleInputChange('keyword') + }) + + expect(result.current.inputValue).toBe('keyword') + expect(result.current.searchValue).toBe('') + + act(() => { + vi.advanceTimersByTime(500) + }) + expect(result.current.searchValue).toBe('keyword') + expect(onPageChange).toHaveBeenCalledWith(1) + + vi.useRealTimers() + }) + + it('should manage status filter state', () => { + const onPageChange = vi.fn() + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + // status value 1 maps to !!1 = true (enabled) + act(() => { + result.current.onChangeStatus({ value: 1, name: 'enabled' }) + }) + // onChangeStatus converts: value === 'all' ? 'all' : !!value + expect(result.current.selectedStatus).toBe(true) + + act(() => { + result.current.onClearFilter() + }) + expect(result.current.selectedStatus).toBe('all') + expect(result.current.inputValue).toBe('') + }) + + it('should provide status list for filter dropdown', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() })) + expect(result.current.statusList).toBeInstanceOf(Array) + expect(result.current.statusList.length).toBe(3) // all, disabled, enabled + }) + + it('should compute selectDefaultValue based on selectedStatus', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange: vi.fn() })) + + // Initial state: 'all' + expect(result.current.selectDefaultValue).toBe('all') + + // Set to enabled (true) + act(() => { + result.current.onChangeStatus({ value: 1, name: 'enabled' }) + }) + expect(result.current.selectDefaultValue).toBe(1) + + // Set to disabled (false) + act(() => { + result.current.onChangeStatus({ value: 0, name: 'disabled' }) + }) + expect(result.current.selectDefaultValue).toBe(0) + }) + }) + + describe('Segment Selection → Batch Operations', () => { + const segments = [ + createSegment('seg-1'), + createSegment('seg-2'), + createSegment('seg-3'), + ] + + it('should manage individual segment selection', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + expect(result.current.selectedSegmentIds).toContain('seg-1') + + act(() => { + result.current.onSelected('seg-2') + }) + expect(result.current.selectedSegmentIds).toContain('seg-1') + expect(result.current.selectedSegmentIds).toContain('seg-2') + expect(result.current.selectedSegmentIds).toHaveLength(2) + }) + + it('should toggle selection on repeated click', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + expect(result.current.selectedSegmentIds).toContain('seg-1') + + act(() => { + result.current.onSelected('seg-1') + }) + expect(result.current.selectedSegmentIds).not.toContain('seg-1') + }) + + it('should support select all toggle', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelectedAll() + }) + expect(result.current.selectedSegmentIds).toHaveLength(3) + expect(result.current.isAllSelected).toBe(true) + + act(() => { + result.current.onSelectedAll() + }) + expect(result.current.selectedSegmentIds).toHaveLength(0) + expect(result.current.isAllSelected).toBe(false) + }) + + it('should detect partial selection via isSomeSelected', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + + // After selecting one of three, isSomeSelected should be true + expect(result.current.selectedSegmentIds).toEqual(['seg-1']) + expect(result.current.isSomeSelected).toBe(true) + expect(result.current.isAllSelected).toBe(false) + }) + + it('should clear selection via onCancelBatchOperation', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + result.current.onSelected('seg-2') + }) + expect(result.current.selectedSegmentIds).toHaveLength(2) + + act(() => { + result.current.onCancelBatchOperation() + }) + expect(result.current.selectedSegmentIds).toHaveLength(0) + }) + }) + + describe('Modal State Management', () => { + const onNewSegmentModalChange = vi.fn() + + it('should open segment detail modal on card click', () => { + const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) + + const segment = createSegment('seg-detail-1', 'Detail content') + act(() => { + result.current.onClickCard(segment) + }) + expect(result.current.currSegment.showModal).toBe(true) + expect(result.current.currSegment.segInfo).toBeDefined() + expect(result.current.currSegment.segInfo!.id).toBe('seg-detail-1') + }) + + it('should close segment detail modal', () => { + const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) + + const segment = createSegment('seg-1') + act(() => { + result.current.onClickCard(segment) + }) + expect(result.current.currSegment.showModal).toBe(true) + + act(() => { + result.current.onCloseSegmentDetail() + }) + expect(result.current.currSegment.showModal).toBe(false) + }) + + it('should manage full screen toggle', () => { + const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) + + expect(result.current.fullScreen).toBe(false) + act(() => { + result.current.toggleFullScreen() + }) + expect(result.current.fullScreen).toBe(true) + act(() => { + result.current.toggleFullScreen() + }) + expect(result.current.fullScreen).toBe(false) + }) + + it('should manage collapsed state', () => { + const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) + + expect(result.current.isCollapsed).toBe(true) + act(() => { + result.current.toggleCollapsed() + }) + expect(result.current.isCollapsed).toBe(false) + }) + + it('should manage new child segment modal', () => { + const { result } = renderHook(() => useModalState({ onNewSegmentModalChange })) + + expect(result.current.showNewChildSegmentModal).toBe(false) + act(() => { + result.current.handleAddNewChildChunk('chunk-parent-1') + }) + expect(result.current.showNewChildSegmentModal).toBe(true) + expect(result.current.currChunkId).toBe('chunk-parent-1') + + act(() => { + result.current.onCloseNewChildChunkModal() + }) + expect(result.current.showNewChildSegmentModal).toBe(false) + }) + }) + + describe('Cross-Hook Data Flow: Search → Selection → Modal', () => { + it('should maintain independent state across all three hooks', () => { + const segments = [createSegment('seg-1'), createSegment('seg-2')] + + const { result: filterResult } = renderHook(() => + useSearchFilter({ onPageChange: vi.fn() }), + ) + const { result: selectionResult } = renderHook(() => + useSegmentSelection(segments), + ) + const { result: modalResult } = renderHook(() => + useModalState({ onNewSegmentModalChange: vi.fn() }), + ) + + // Set search filter to enabled + act(() => { + filterResult.current.onChangeStatus({ value: 1, name: 'enabled' }) + }) + + // Select a segment + act(() => { + selectionResult.current.onSelected('seg-1') + }) + + // Open detail modal + act(() => { + modalResult.current.onClickCard(segments[0]) + }) + + // All states should be independent + expect(filterResult.current.selectedStatus).toBe(true) // !!1 + expect(selectionResult.current.selectedSegmentIds).toContain('seg-1') + expect(modalResult.current.currSegment.showModal).toBe(true) + }) + }) +}) diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx index 4088e709d1..06563832f1 100644 --- a/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/hooks.spec.tsx @@ -162,8 +162,10 @@ describe('useEmbeddedChatbot', () => { await waitFor(() => { expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1') }) - expect(result.current.pinnedConversationList).toEqual(pinnedData.data) - expect(result.current.conversationList).toEqual(listData.data) + await waitFor(() => { + expect(result.current.pinnedConversationList).toEqual(pinnedData.data) + expect(result.current.conversationList).toEqual(listData.data) + }) }) }) diff --git a/web/app/components/datasets/__tests__/chunk.spec.tsx b/web/app/components/datasets/__tests__/chunk.spec.tsx new file mode 100644 index 0000000000..eea972cb17 --- /dev/null +++ b/web/app/components/datasets/__tests__/chunk.spec.tsx @@ -0,0 +1,309 @@ +import type { QA } from '@/models/datasets' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkContainer, ChunkLabel, QAPreview } from '../chunk' + +vi.mock('../../base/icons/src/public/knowledge', () => ({ + SelectionMod: (props: React.ComponentProps<'svg'>) => ( + + ), +})) + +function createQA(overrides: Partial = {}): QA { + return { + question: 'What is Dify?', + answer: 'Dify is an open-source LLM app development platform.', + ...overrides, + } +} + +describe('ChunkLabel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render the label text', () => { + render() + + expect(screen.getByText('Chunk #1')).toBeInTheDocument() + }) + + it('should render the character count with unit', () => { + render() + + expect(screen.getByText('256 characters')).toBeInTheDocument() + }) + + it('should render the SelectionMod icon', () => { + render() + + expect(screen.getByTestId('selection-mod-icon')).toBeInTheDocument() + }) + + it('should render a middle dot separator between label and count', () => { + render() + + expect(screen.getByText('·')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should display zero character count', () => { + render() + + expect(screen.getByText('0 characters')).toBeInTheDocument() + }) + + it('should display large character counts', () => { + render() + + expect(screen.getByText('999999 characters')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render with empty label', () => { + render() + + expect(screen.getByText('50 characters')).toBeInTheDocument() + }) + + it('should render with special characters in label', () => { + render() + + expect(screen.getByText('Chunk <#1> & \'test\'')).toBeInTheDocument() + }) + }) +}) + +// Tests for ChunkContainer - wraps ChunkLabel with children content area +describe('ChunkContainer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render ChunkLabel with correct props', () => { + render( + + Content here + , + ) + + expect(screen.getByText('Chunk #1')).toBeInTheDocument() + expect(screen.getByText('200 characters')).toBeInTheDocument() + }) + + it('should render children in the content area', () => { + render( + +

Paragraph content

+
, + ) + + expect(screen.getByText('Paragraph content')).toBeInTheDocument() + }) + + it('should render the SelectionMod icon via ChunkLabel', () => { + render( + + Content + , + ) + + expect(screen.getByTestId('selection-mod-icon')).toBeInTheDocument() + }) + }) + + describe('Structure', () => { + it('should have space-y-2 on the outer container', () => { + const { container } = render( + Content, + ) + + expect(container.firstElementChild).toHaveClass('space-y-2') + }) + + it('should render children inside a styled content div', () => { + render( + + Test child + , + ) + + const contentDiv = screen.getByText('Test child').parentElement + expect(contentDiv).toHaveClass('body-md-regular', 'text-text-secondary') + }) + }) + + describe('Edge Cases', () => { + it('should render without children', () => { + const { container } = render( + , + ) + + expect(container.firstElementChild).toBeInTheDocument() + expect(screen.getByText('Empty')).toBeInTheDocument() + }) + + it('should render multiple children', () => { + render( + + First + Second + , + ) + + expect(screen.getByText('First')).toBeInTheDocument() + expect(screen.getByText('Second')).toBeInTheDocument() + }) + + it('should render with string children', () => { + render( + + Plain text content + , + ) + + expect(screen.getByText('Plain text content')).toBeInTheDocument() + }) + }) +}) + +// Tests for QAPreview - displays question and answer pair +describe('QAPreview', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render the question text', () => { + const qa = createQA() + render() + + expect(screen.getByText('What is Dify?')).toBeInTheDocument() + }) + + it('should render the answer text', () => { + const qa = createQA() + render() + + expect(screen.getByText('Dify is an open-source LLM app development platform.')).toBeInTheDocument() + }) + + it('should render Q and A labels', () => { + const qa = createQA() + render() + + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('A')).toBeInTheDocument() + }) + }) + + describe('Structure', () => { + it('should render Q label as a label element', () => { + const qa = createQA() + render() + + const qLabel = screen.getByText('Q') + expect(qLabel.tagName).toBe('LABEL') + }) + + it('should render A label as a label element', () => { + const qa = createQA() + render() + + const aLabel = screen.getByText('A') + expect(aLabel.tagName).toBe('LABEL') + }) + + it('should render question in a p element', () => { + const qa = createQA() + render() + + const questionEl = screen.getByText(qa.question) + expect(questionEl.tagName).toBe('P') + }) + + it('should render answer in a p element', () => { + const qa = createQA() + render() + + const answerEl = screen.getByText(qa.answer) + expect(answerEl.tagName).toBe('P') + }) + + it('should have the outer container with flex column layout', () => { + const qa = createQA() + const { container } = render() + + expect(container.firstElementChild).toHaveClass('flex', 'flex-col', 'gap-y-2') + }) + + it('should apply text styling classes to question paragraph', () => { + const qa = createQA() + render() + + const questionEl = screen.getByText(qa.question) + expect(questionEl).toHaveClass('body-md-regular', 'text-text-secondary') + }) + + it('should apply text styling classes to answer paragraph', () => { + const qa = createQA() + render() + + const answerEl = screen.getByText(qa.answer) + expect(answerEl).toHaveClass('body-md-regular', 'text-text-secondary') + }) + }) + + describe('Edge Cases', () => { + it('should render with empty question', () => { + const qa = createQA({ question: '' }) + render() + + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText('A')).toBeInTheDocument() + }) + + it('should render with empty answer', () => { + const qa = createQA({ answer: '' }) + render() + + expect(screen.getByText('Q')).toBeInTheDocument() + expect(screen.getByText(qa.question)).toBeInTheDocument() + }) + + it('should render with long text', () => { + const longText = 'x'.repeat(1000) + const qa = createQA({ question: longText, answer: longText }) + render() + + const elements = screen.getAllByText(longText) + expect(elements).toHaveLength(2) + }) + + it('should render with special characters in question and answer', () => { + const qa = createQA({ + question: 'What about & "quotes"?', + answer: 'It handles \'single\' & "double" quotes.', + }) + render() + + expect(screen.getByText('What about & "quotes"?')).toBeInTheDocument() + expect(screen.getByText('It handles \'single\' & "double" quotes.')).toBeInTheDocument() + }) + + it('should render with multiline text', () => { + const qa = createQA({ + question: 'Line1\nLine2', + answer: 'Answer1\nAnswer2', + }) + render() + + expect(screen.getByText(/Line1/)).toBeInTheDocument() + expect(screen.getByText(/Answer1/)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/loading.spec.tsx b/web/app/components/datasets/__tests__/loading.spec.tsx similarity index 92% rename from web/app/components/datasets/loading.spec.tsx rename to web/app/components/datasets/__tests__/loading.spec.tsx index 0b291d727f..7e35399485 100644 --- a/web/app/components/datasets/loading.spec.tsx +++ b/web/app/components/datasets/__tests__/loading.spec.tsx @@ -1,6 +1,6 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import DatasetsLoading from './loading' +import DatasetsLoading from '../loading' afterEach(() => { cleanup() diff --git a/web/app/components/datasets/no-linked-apps-panel.spec.tsx b/web/app/components/datasets/__tests__/no-linked-apps-panel.spec.tsx similarity index 78% rename from web/app/components/datasets/no-linked-apps-panel.spec.tsx rename to web/app/components/datasets/__tests__/no-linked-apps-panel.spec.tsx index aa66e43fbd..d6f0dfeabb 100644 --- a/web/app/components/datasets/no-linked-apps-panel.spec.tsx +++ b/web/app/components/datasets/__tests__/no-linked-apps-panel.spec.tsx @@ -1,13 +1,6 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import NoLinkedAppsPanel from './no-linked-apps-panel' - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import NoLinkedAppsPanel from '../no-linked-apps-panel' // Mock useDocLink vi.mock('@/context/i18n', () => ({ @@ -21,17 +14,17 @@ afterEach(() => { describe('NoLinkedAppsPanel', () => { it('should render without crashing', () => { render() - expect(screen.getByText('datasetMenus.emptyTip')).toBeInTheDocument() + expect(screen.getByText('common.datasetMenus.emptyTip')).toBeInTheDocument() }) it('should render the empty tip text', () => { render() - expect(screen.getByText('datasetMenus.emptyTip')).toBeInTheDocument() + expect(screen.getByText('common.datasetMenus.emptyTip')).toBeInTheDocument() }) it('should render the view doc link', () => { render() - expect(screen.getByText('datasetMenus.viewDoc')).toBeInTheDocument() + expect(screen.getByText('common.datasetMenus.viewDoc')).toBeInTheDocument() }) it('should render link with correct href', () => { diff --git a/web/app/components/datasets/api/index.spec.tsx b/web/app/components/datasets/api/__tests__/index.spec.tsx similarity index 95% rename from web/app/components/datasets/api/index.spec.tsx rename to web/app/components/datasets/api/__tests__/index.spec.tsx index 33ee656a23..f3c5e2ffc3 100644 --- a/web/app/components/datasets/api/index.spec.tsx +++ b/web/app/components/datasets/api/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import ApiIndex from './index' +import ApiIndex from '../index' afterEach(() => { cleanup() diff --git a/web/app/components/datasets/chunk.spec.tsx b/web/app/components/datasets/chunk.spec.tsx deleted file mode 100644 index d3dc011aef..0000000000 --- a/web/app/components/datasets/chunk.spec.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' -import { ChunkContainer, ChunkLabel, QAPreview } from './chunk' - -afterEach(() => { - cleanup() -}) - -describe('ChunkLabel', () => { - it('should render label text', () => { - render() - expect(screen.getByText('Chunk 1')).toBeInTheDocument() - }) - - it('should render character count', () => { - render() - expect(screen.getByText('150 characters')).toBeInTheDocument() - }) - - it('should render separator dot', () => { - render() - expect(screen.getByText('·')).toBeInTheDocument() - }) - - it('should render with zero character count', () => { - render() - expect(screen.getByText('0 characters')).toBeInTheDocument() - }) - - it('should render with large character count', () => { - render() - expect(screen.getByText('999999 characters')).toBeInTheDocument() - }) -}) - -describe('ChunkContainer', () => { - it('should render label and character count', () => { - render(Content) - expect(screen.getByText('Container 1')).toBeInTheDocument() - expect(screen.getByText('200 characters')).toBeInTheDocument() - }) - - it('should render children content', () => { - render(Test Content) - expect(screen.getByText('Test Content')).toBeInTheDocument() - }) - - it('should render with complex children', () => { - render( - -
- Nested content -
-
, - ) - expect(screen.getByTestId('child-div')).toBeInTheDocument() - expect(screen.getByText('Nested content')).toBeInTheDocument() - }) - - it('should render empty children', () => { - render({null}) - expect(screen.getByText('Empty')).toBeInTheDocument() - }) -}) - -describe('QAPreview', () => { - const mockQA = { - question: 'What is the meaning of life?', - answer: 'The meaning of life is 42.', - } - - it('should render question text', () => { - render() - expect(screen.getByText('What is the meaning of life?')).toBeInTheDocument() - }) - - it('should render answer text', () => { - render() - expect(screen.getByText('The meaning of life is 42.')).toBeInTheDocument() - }) - - it('should render Q label', () => { - render() - expect(screen.getByText('Q')).toBeInTheDocument() - }) - - it('should render A label', () => { - render() - expect(screen.getByText('A')).toBeInTheDocument() - }) - - it('should render with empty strings', () => { - render() - expect(screen.getByText('Q')).toBeInTheDocument() - expect(screen.getByText('A')).toBeInTheDocument() - }) - - it('should render with long text', () => { - const longQuestion = 'Q'.repeat(500) - const longAnswer = 'A'.repeat(500) - render() - expect(screen.getByText(longQuestion)).toBeInTheDocument() - expect(screen.getByText(longAnswer)).toBeInTheDocument() - }) - - it('should render with special characters', () => { - render(?', answer: '& special chars!' }} />) - expect(screen.getByText('What about \n\t& < > "' mockFetchFilePreview.mockResolvedValue({ content: specialContent }) - // Act const { container } = renderFilePreview() // Assert - Should render as text, not execute scripts @@ -607,25 +506,20 @@ describe('FilePreview', () => { }) it('should handle preview content with unicode', async () => { - // Arrange const unicodeContent = '中文内容 🚀 émojis & spëcîal çhàrs' mockFetchFilePreview.mockResolvedValue({ content: unicodeContent }) - // Act renderFilePreview() - // Assert await waitFor(() => { expect(screen.getByText(unicodeContent)).toBeInTheDocument() }) }) it('should handle preview content with newlines', async () => { - // Arrange const multilineContent = 'Line 1\nLine 2\nLine 3' mockFetchFilePreview.mockResolvedValue({ content: multilineContent }) - // Act const { container } = renderFilePreview() // Assert - Content should be in the DOM @@ -639,10 +533,8 @@ describe('FilePreview', () => { }) it('should handle null content from API', async () => { - // Arrange mockFetchFilePreview.mockResolvedValue({ content: null as unknown as string }) - // Act const { container } = renderFilePreview() // Assert - Should not crash @@ -652,16 +544,12 @@ describe('FilePreview', () => { }) }) - // -------------------------------------------------------------------------- // Side Effects and Cleanup Tests - // -------------------------------------------------------------------------- describe('Side Effects and Cleanup', () => { it('should trigger effect when file prop changes', async () => { - // Arrange const file1 = createMockFile({ id: 'file-1' }) const file2 = createMockFile({ id: 'file-2' }) - // Act const { rerender } = render( , ) @@ -672,19 +560,16 @@ describe('FilePreview', () => { rerender() - // Assert await waitFor(() => { expect(mockFetchFilePreview).toHaveBeenCalledTimes(2) }) }) it('should not trigger effect when hidePreview changes', async () => { - // Arrange const file = createMockFile() const hidePreview1 = vi.fn() const hidePreview2 = vi.fn() - // Act const { rerender } = render( , ) @@ -703,11 +588,9 @@ describe('FilePreview', () => { }) it('should handle rapid file changes', async () => { - // Arrange const files = Array.from({ length: 5 }, (_, i) => createMockFile({ id: `file-${i}` })) - // Act const { rerender } = render( , ) @@ -723,12 +606,10 @@ describe('FilePreview', () => { }) it('should handle unmount during loading', async () => { - // Arrange mockFetchFilePreview.mockImplementation( () => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)), ) - // Act const { unmount } = renderFilePreview() // Unmount before API resolves @@ -739,10 +620,8 @@ describe('FilePreview', () => { }) it('should handle file changing from defined to undefined', async () => { - // Arrange const file = createMockFile() - // Act const { rerender, container } = render( , ) @@ -759,26 +638,19 @@ describe('FilePreview', () => { }) }) - // -------------------------------------------------------------------------- // getFileName Helper Tests - // -------------------------------------------------------------------------- describe('getFileName Helper', () => { it('should extract name without extension for simple filename', async () => { - // Arrange const file = createMockFile({ name: 'document.pdf' }) - // Act renderFilePreview({ file }) - // Assert expect(screen.getByText('document')).toBeInTheDocument() }) it('should handle filename with multiple dots', async () => { - // Arrange const file = createMockFile({ name: 'file.name.with.dots.txt' }) - // Act renderFilePreview({ file }) // Assert - Should join all parts except last with comma @@ -786,10 +658,8 @@ describe('FilePreview', () => { }) it('should return empty for filename without dot', async () => { - // Arrange const file = createMockFile({ name: 'nodotfile' }) - // Act const { container } = renderFilePreview({ file }) // Assert - slice(0, -1) on single element array returns empty @@ -799,7 +669,6 @@ describe('FilePreview', () => { }) it('should return empty string when file is undefined', async () => { - // Arrange & Act const { container } = renderFilePreview({ file: undefined }) // Assert - File name area should have empty first span @@ -808,38 +677,27 @@ describe('FilePreview', () => { }) }) - // -------------------------------------------------------------------------- - // Accessibility Tests - // -------------------------------------------------------------------------- describe('Accessibility', () => { it('should have clickable close button with visual indicator', async () => { - // Arrange & Act const { container } = renderFilePreview() - // Assert const closeButton = container.querySelector('.cursor-pointer') expect(closeButton).toBeInTheDocument() expect(closeButton).toHaveClass('cursor-pointer') }) it('should have proper heading structure', async () => { - // Arrange & Act renderFilePreview() - // Assert expect(screen.getByText('datasetCreation.stepOne.filePreview')).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- // Error Handling Tests - // -------------------------------------------------------------------------- describe('Error Handling', () => { it('should not crash on API network error', async () => { - // Arrange mockFetchFilePreview.mockRejectedValue(new Error('Network Error')) - // Act const { container } = renderFilePreview() // Assert - Component should still render @@ -849,26 +707,20 @@ describe('FilePreview', () => { }) it('should not crash on API timeout', async () => { - // Arrange mockFetchFilePreview.mockRejectedValue(new Error('Timeout')) - // Act const { container } = renderFilePreview() - // Assert await waitFor(() => { expect(container.firstChild).toBeInTheDocument() }) }) it('should not crash on malformed API response', async () => { - // Arrange mockFetchFilePreview.mockResolvedValue({} as { content: string }) - // Act const { container } = renderFilePreview() - // Assert await waitFor(() => { expect(container.firstChild).toBeInTheDocument() }) diff --git a/web/app/components/datasets/create/file-uploader/index.spec.tsx b/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx similarity index 87% rename from web/app/components/datasets/create/file-uploader/index.spec.tsx rename to web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx index 91f65652f3..da337efce2 100644 --- a/web/app/components/datasets/create/file-uploader/index.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx @@ -1,26 +1,9 @@ import type { CustomFile as File, FileItem } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PROGRESS_NOT_STARTED } from './constants' -import FileUploader from './index' +import { PROGRESS_NOT_STARTED } from '../constants' +import FileUploader from '../index' -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'stepOne.uploader.title': 'Upload Files', - 'stepOne.uploader.button': 'Drag and drop files, or', - 'stepOne.uploader.buttonSingleFile': 'Drag and drop file, or', - 'stepOne.uploader.browse': 'Browse', - 'stepOne.uploader.tip': 'Supports various file types', - } - return translations[key] || key - }, - }), -})) - -// Mock ToastContext const mockNotify = vi.fn() vi.mock('use-context-selector', async () => { const actual = await vi.importActual('use-context-selector') @@ -118,22 +101,22 @@ describe('FileUploader', () => { describe('rendering', () => { it('should render the component', () => { render() - expect(screen.getByText('Upload Files')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.uploader.title')).toBeInTheDocument() }) it('should render dropzone when no files', () => { render() - expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.button/)).toBeInTheDocument() }) it('should render browse button', () => { render() - expect(screen.getByText('Browse')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.uploader.browse')).toBeInTheDocument() }) it('should apply custom title className', () => { render() - const title = screen.getByText('Upload Files') + const title = screen.getByText('datasetCreation.stepOne.uploader.title') expect(title).toHaveClass('custom-class') }) }) @@ -162,19 +145,19 @@ describe('FileUploader', () => { describe('batch upload mode', () => { it('should show dropzone with batch upload enabled', () => { render() - expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.button/)).toBeInTheDocument() }) it('should show single file text when batch upload disabled', () => { render() - expect(screen.getByText(/Drag and drop file/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.buttonSingleFile/)).toBeInTheDocument() }) it('should hide dropzone when not batch upload and has files', () => { const fileList = [createMockFileItem()] render() - expect(screen.queryByText(/Drag and drop/i)).not.toBeInTheDocument() + expect(screen.queryByText(/datasetCreation\.stepOne\.uploader\.button/)).not.toBeInTheDocument() }) }) @@ -217,7 +200,7 @@ describe('FileUploader', () => { render() // The browse label should trigger file input click - const browseLabel = screen.getByText('Browse') + const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse') expect(browseLabel).toHaveClass('cursor-pointer') }) }) diff --git a/web/app/components/datasets/create/file-uploader/components/file-list-item.spec.tsx b/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx similarity index 98% rename from web/app/components/datasets/create/file-uploader/components/file-list-item.spec.tsx rename to web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx index 4da20a7bf7..dd88af4395 100644 --- a/web/app/components/datasets/create/file-uploader/components/file-list-item.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx @@ -1,9 +1,9 @@ -import type { FileListItemProps } from './file-list-item' +import type { FileListItemProps } from '../file-list-item' import type { CustomFile as File, FileItem } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants' -import FileListItem from './file-list-item' +import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants' +import FileListItem from '../file-list-item' // Mock theme hook - can be changed per test let mockTheme = 'light' diff --git a/web/app/components/datasets/create/file-uploader/components/upload-dropzone.spec.tsx b/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx similarity index 84% rename from web/app/components/datasets/create/file-uploader/components/upload-dropzone.spec.tsx rename to web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx index 112d61250b..ee769c110e 100644 --- a/web/app/components/datasets/create/file-uploader/components/upload-dropzone.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx @@ -1,33 +1,12 @@ import type { RefObject } from 'react' -import type { UploadDropzoneProps } from './upload-dropzone' +import type { UploadDropzoneProps } from '../upload-dropzone' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import UploadDropzone from './upload-dropzone' +import UploadDropzone from '../upload-dropzone' // Helper to create mock ref objects for testing const createMockRef = (value: T | null = null): RefObject => ({ current: value }) -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: Record) => { - const translations: Record = { - 'stepOne.uploader.button': 'Drag and drop files, or', - 'stepOne.uploader.buttonSingleFile': 'Drag and drop file, or', - 'stepOne.uploader.browse': 'Browse', - 'stepOne.uploader.tip': 'Supports {{supportTypes}}, Max {{size}}MB each, up to {{batchCount}} files at a time, {{totalCount}} files total', - } - let result = translations[key] || key - if (options && typeof options === 'object') { - Object.entries(options).forEach(([k, v]) => { - result = result.replace(`{{${k}}}`, String(v)) - }) - } - return result - }, - }), -})) - describe('UploadDropzone', () => { const defaultProps: UploadDropzoneProps = { dropRef: createMockRef() as RefObject, @@ -73,17 +52,17 @@ describe('UploadDropzone', () => { it('should render browse label when extensions are allowed', () => { render() - expect(screen.getByText('Browse')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.uploader.browse')).toBeInTheDocument() }) it('should not render browse label when no extensions allowed', () => { render() - expect(screen.queryByText('Browse')).not.toBeInTheDocument() + expect(screen.queryByText('datasetCreation.stepOne.uploader.browse')).not.toBeInTheDocument() }) it('should render file size and count limits', () => { render() - const tipText = screen.getByText(/Supports.*Max.*15MB/i) + const tipText = screen.getByText(/datasetCreation\.stepOne\.uploader\.tip/) expect(tipText).toBeInTheDocument() }) }) @@ -111,12 +90,12 @@ describe('UploadDropzone', () => { describe('text content', () => { it('should show batch upload text when supportBatchUpload is true', () => { render() - expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.button/)).toBeInTheDocument() }) it('should show single file text when supportBatchUpload is false', () => { render() - expect(screen.getByText(/Drag and drop file/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.buttonSingleFile/)).toBeInTheDocument() }) }) @@ -146,7 +125,7 @@ describe('UploadDropzone', () => { const onSelectFile = vi.fn() render() - const browseLabel = screen.getByText('Browse') + const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse') fireEvent.click(browseLabel) expect(onSelectFile).toHaveBeenCalledTimes(1) @@ -195,7 +174,7 @@ describe('UploadDropzone', () => { it('should have cursor-pointer on browse label', () => { render() - const browseLabel = screen.getByText('Browse') + const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse') expect(browseLabel).toHaveClass('cursor-pointer') }) }) diff --git a/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.spec.tsx b/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx similarity index 99% rename from web/app/components/datasets/create/file-uploader/hooks/use-file-upload.spec.tsx rename to web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx index 222f038c84..b5d1a96554 100644 --- a/web/app/components/datasets/create/file-uploader/hooks/use-file-upload.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/hooks/__tests__/use-file-upload.spec.tsx @@ -4,15 +4,14 @@ import { act, render, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ToastContext } from '@/app/components/base/toast' -import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants' +import { PROGRESS_COMPLETE, PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants' // Import after mocks -import { useFileUpload } from './use-file-upload' +import { useFileUpload } from '../use-file-upload' // Mock notify function const mockNotify = vi.fn() const mockClose = vi.fn() -// Mock ToastContext vi.mock('use-context-selector', async () => { const actual = await vi.importActual('use-context-selector') return { @@ -44,12 +43,6 @@ vi.mock('@/service/use-common', () => ({ })) // Mock i18n -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - // Mock locale vi.mock('@/context/i18n', () => ({ useLocale: () => 'en-US', @@ -59,7 +52,6 @@ vi.mock('@/i18n-config/language', () => ({ LanguagesSupported: ['en-US', 'zh-Hans'], })) -// Mock config vi.mock('@/config', () => ({ IS_CE_EDITION: false, })) diff --git a/web/app/components/datasets/create/notion-page-preview/index.spec.tsx b/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/datasets/create/notion-page-preview/index.spec.tsx rename to web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx index 78b54dc8af..572677ced7 100644 --- a/web/app/components/datasets/create/notion-page-preview/index.spec.tsx +++ b/web/app/components/datasets/create/notion-page-preview/__tests__/index.spec.tsx @@ -2,7 +2,7 @@ import type { MockedFunction } from 'vitest' import type { NotionPage } from '@/models/common' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { fetchNotionPagePreview } from '@/service/datasets' -import NotionPagePreview from './index' +import NotionPagePreview from '../index' // Mock the fetchNotionPagePreview service vi.mock('@/service/datasets', () => ({ @@ -85,13 +85,10 @@ const findLoadingSpinner = (container: HTMLElement) => { return container.querySelector('.spin-animation') } -// ============================================================================ // NotionPagePreview Component Tests -// ============================================================================ // Note: Branch coverage is ~88% because line 29 (`if (!currentPage) return`) // is defensive code that cannot be reached - getPreviewContent is only called // from useEffect when currentPage is truthy. -// ============================================================================ describe('NotionPagePreview', () => { beforeEach(() => { vi.clearAllMocks() @@ -106,31 +103,23 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- // Rendering Tests - Verify component renders properly - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', async () => { - // Arrange & Act await renderNotionPagePreview() - // Assert expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() }) it('should render page preview header', async () => { - // Arrange & Act await renderNotionPagePreview() - // Assert expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() }) it('should render close button with XMarkIcon', async () => { - // Arrange & Act const { container } = await renderNotionPagePreview() - // Assert const closeButton = container.querySelector('.cursor-pointer') expect(closeButton).toBeInTheDocument() const xMarkIcon = closeButton?.querySelector('svg') @@ -138,30 +127,23 @@ describe('NotionPagePreview', () => { }) it('should render page name', async () => { - // Arrange const page = createMockNotionPage({ page_name: 'My Notion Page' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(screen.getByText('My Notion Page')).toBeInTheDocument() }) it('should apply correct CSS classes to container', async () => { - // Arrange & Act const { container } = await renderNotionPagePreview() - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('h-full') }) it('should render NotionIcon component', async () => { - // Arrange const page = createMockNotionPage() - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) // Assert - NotionIcon should be rendered (either as img or div or svg) @@ -170,15 +152,11 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- // NotionIcon Rendering Tests - // -------------------------------------------------------------------------- describe('NotionIcon Rendering', () => { it('should render default icon when page_icon is null', async () => { - // Arrange const page = createMockNotionPage({ page_icon: null }) - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) // Assert - Should render RiFileTextLine icon (svg) @@ -187,33 +165,25 @@ describe('NotionPagePreview', () => { }) it('should render emoji icon when page_icon has emoji type', async () => { - // Arrange const page = createMockNotionPageWithEmojiIcon('📝') - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(screen.getByText('📝')).toBeInTheDocument() }) it('should render image icon when page_icon has url type', async () => { - // Arrange const page = createMockNotionPageWithUrlIcon('https://example.com/icon.png') - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) - // Assert const img = container.querySelector('img[alt="page icon"]') expect(img).toBeInTheDocument() expect(img).toHaveAttribute('src', 'https://example.com/icon.png') }) }) - // -------------------------------------------------------------------------- // Loading State Tests - // -------------------------------------------------------------------------- describe('Loading State', () => { it('should show loading indicator initially', async () => { // Arrange - Delay API response to keep loading state @@ -230,13 +200,10 @@ describe('NotionPagePreview', () => { }) it('should hide loading indicator after content loads', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({ content: 'Loaded content' }) - // Act const { container } = await renderNotionPagePreview() - // Assert expect(screen.getByText('Loaded content')).toBeInTheDocument() // Loading should be gone const loadingElement = findLoadingSpinner(container) @@ -244,7 +211,6 @@ describe('NotionPagePreview', () => { }) it('should show loading when currentPage changes', async () => { - // Arrange const page1 = createMockNotionPage({ page_id: 'page-1', page_name: 'Page 1' }) const page2 = createMockNotionPage({ page_id: 'page-2', page_name: 'Page 2' }) @@ -291,24 +257,19 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- // API Call Tests - // -------------------------------------------------------------------------- describe('API Calls', () => { it('should call fetchNotionPagePreview with correct parameters', async () => { - // Arrange const page = createMockNotionPage({ page_id: 'test-page-id', type: 'database', }) - // Act await renderNotionPagePreview({ currentPage: page, notionCredentialId: 'test-credential-id', }) - // Assert expect(mockFetchNotionPagePreview).toHaveBeenCalledWith({ pageID: 'test-page-id', pageType: 'database', @@ -317,19 +278,15 @@ describe('NotionPagePreview', () => { }) it('should not call fetchNotionPagePreview when currentPage is undefined', async () => { - // Arrange & Act await renderNotionPagePreview({ currentPage: undefined }, false) - // Assert expect(mockFetchNotionPagePreview).not.toHaveBeenCalled() }) it('should call fetchNotionPagePreview again when currentPage changes', async () => { - // Arrange const page1 = createMockNotionPage({ page_id: 'page-1' }) const page2 = createMockNotionPage({ page_id: 'page-2' }) - // Act const { rerender } = render( , ) @@ -346,7 +303,6 @@ describe('NotionPagePreview', () => { rerender() }) - // Assert await waitFor(() => { expect(mockFetchNotionPagePreview).toHaveBeenCalledWith({ pageID: 'page-2', @@ -358,21 +314,16 @@ describe('NotionPagePreview', () => { }) it('should handle API success and display content', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({ content: 'Notion page preview content from API' }) - // Act await renderNotionPagePreview() - // Assert expect(screen.getByText('Notion page preview content from API')).toBeInTheDocument() }) it('should handle API error gracefully', async () => { - // Arrange mockFetchNotionPagePreview.mockRejectedValue(new Error('Network error')) - // Act const { container } = await renderNotionPagePreview({}, false) // Assert - Component should not crash @@ -384,10 +335,8 @@ describe('NotionPagePreview', () => { }) it('should handle empty content response', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({ content: '' }) - // Act const { container } = await renderNotionPagePreview() // Assert - Should still render without loading @@ -396,42 +345,30 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should call hidePreview when close button is clicked', async () => { - // Arrange const hidePreview = vi.fn() const { container } = await renderNotionPagePreview({ hidePreview }) - // Act const closeButton = container.querySelector('.cursor-pointer') as HTMLElement fireEvent.click(closeButton) - // Assert expect(hidePreview).toHaveBeenCalledTimes(1) }) it('should handle multiple clicks on close button', async () => { - // Arrange const hidePreview = vi.fn() const { container } = await renderNotionPagePreview({ hidePreview }) - // Act const closeButton = container.querySelector('.cursor-pointer') as HTMLElement fireEvent.click(closeButton) fireEvent.click(closeButton) fireEvent.click(closeButton) - // Assert expect(hidePreview).toHaveBeenCalledTimes(3) }) }) - // -------------------------------------------------------------------------- - // State Management Tests - // -------------------------------------------------------------------------- describe('State Management', () => { it('should initialize with loading state true', async () => { // Arrange - Keep loading indefinitely (never resolves) @@ -440,24 +377,19 @@ describe('NotionPagePreview', () => { // Act - Don't wait for content const { container } = await renderNotionPagePreview({}, false) - // Assert const loadingElement = findLoadingSpinner(container) expect(loadingElement).toBeInTheDocument() }) it('should update previewContent state after successful fetch', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({ content: 'New preview content' }) - // Act await renderNotionPagePreview() - // Assert expect(screen.getByText('New preview content')).toBeInTheDocument() }) it('should reset loading to true when currentPage changes', async () => { - // Arrange const page1 = createMockNotionPage({ page_id: 'page-1' }) const page2 = createMockNotionPage({ page_id: 'page-2' }) @@ -465,7 +397,6 @@ describe('NotionPagePreview', () => { .mockResolvedValueOnce({ content: 'Content 1' }) .mockImplementationOnce(() => new Promise(() => { /* never resolves */ })) - // Act const { rerender, container } = render( , ) @@ -487,7 +418,6 @@ describe('NotionPagePreview', () => { }) it('should replace old content with new content when page changes', async () => { - // Arrange const page1 = createMockNotionPage({ page_id: 'page-1' }) const page2 = createMockNotionPage({ page_id: 'page-2' }) @@ -497,7 +427,6 @@ describe('NotionPagePreview', () => { .mockResolvedValueOnce({ content: 'Content 1' }) .mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve })) - // Act const { rerender } = render( , ) @@ -523,24 +452,17 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- - // Props Testing - // -------------------------------------------------------------------------- describe('Props', () => { describe('currentPage prop', () => { it('should render correctly with currentPage prop', async () => { - // Arrange const page = createMockNotionPage({ page_name: 'My Test Page' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(screen.getByText('My Test Page')).toBeInTheDocument() }) it('should render correctly without currentPage prop (undefined)', async () => { - // Arrange & Act await renderNotionPagePreview({ currentPage: undefined }, false) // Assert - Header should still render @@ -548,10 +470,8 @@ describe('NotionPagePreview', () => { }) it('should handle page with empty name', async () => { - // Arrange const page = createMockNotionPage({ page_name: '' }) - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) // Assert - Should not crash @@ -559,52 +479,40 @@ describe('NotionPagePreview', () => { }) it('should handle page with very long name', async () => { - // Arrange const longName = 'a'.repeat(200) const page = createMockNotionPage({ page_name: longName }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(screen.getByText(longName)).toBeInTheDocument() }) it('should handle page with special characters in name', async () => { - // Arrange const page = createMockNotionPage({ page_name: 'Page with & "chars"' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(screen.getByText('Page with & "chars"')).toBeInTheDocument() }) it('should handle page with unicode characters in name', async () => { - // Arrange const page = createMockNotionPage({ page_name: '中文页面名称 🚀 日本語' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(screen.getByText('中文页面名称 🚀 日本語')).toBeInTheDocument() }) }) describe('notionCredentialId prop', () => { it('should pass notionCredentialId to API call', async () => { - // Arrange const page = createMockNotionPage() - // Act await renderNotionPagePreview({ currentPage: page, notionCredentialId: 'my-credential-id', }) - // Assert expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( expect.objectContaining({ credentialID: 'my-credential-id' }), ) @@ -613,10 +521,8 @@ describe('NotionPagePreview', () => { describe('hidePreview prop', () => { it('should accept hidePreview callback', async () => { - // Arrange const hidePreview = vi.fn() - // Act await renderNotionPagePreview({ hidePreview }) // Assert - No errors thrown @@ -625,15 +531,10 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- - // Edge Cases Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle page with undefined page_id', async () => { - // Arrange const page = createMockNotionPage({ page_id: undefined as unknown as string }) - // Act await renderNotionPagePreview({ currentPage: page }) // Assert - API should still be called (with undefined pageID) @@ -641,36 +542,28 @@ describe('NotionPagePreview', () => { }) it('should handle page with empty string page_id', async () => { - // Arrange const page = createMockNotionPage({ page_id: '' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( expect.objectContaining({ pageID: '' }), ) }) it('should handle very long preview content', async () => { - // Arrange const longContent = 'x'.repeat(10000) mockFetchNotionPagePreview.mockResolvedValue({ content: longContent }) - // Act await renderNotionPagePreview() - // Assert expect(screen.getByText(longContent)).toBeInTheDocument() }) it('should handle preview content with special characters safely', async () => { - // Arrange const specialContent = '\n\t& < > "' mockFetchNotionPagePreview.mockResolvedValue({ content: specialContent }) - // Act const { container } = await renderNotionPagePreview() // Assert - Should render as text, not execute scripts @@ -680,26 +573,20 @@ describe('NotionPagePreview', () => { }) it('should handle preview content with unicode', async () => { - // Arrange const unicodeContent = '中文内容 🚀 émojis & spëcîal çhàrs' mockFetchNotionPagePreview.mockResolvedValue({ content: unicodeContent }) - // Act await renderNotionPagePreview() - // Assert expect(screen.getByText(unicodeContent)).toBeInTheDocument() }) it('should handle preview content with newlines', async () => { - // Arrange const multilineContent = 'Line 1\nLine 2\nLine 3' mockFetchNotionPagePreview.mockResolvedValue({ content: multilineContent }) - // Act const { container } = await renderNotionPagePreview() - // Assert const contentDiv = container.querySelector('[class*="fileContent"]') expect(contentDiv).toBeInTheDocument() expect(contentDiv?.textContent).toContain('Line 1') @@ -708,10 +595,8 @@ describe('NotionPagePreview', () => { }) it('should handle null content from API', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({ content: null as unknown as string }) - // Act const { container } = await renderNotionPagePreview() // Assert - Should not crash @@ -719,29 +604,22 @@ describe('NotionPagePreview', () => { }) it('should handle different page types', async () => { - // Arrange const databasePage = createMockNotionPage({ type: 'database' }) - // Act await renderNotionPagePreview({ currentPage: databasePage }) - // Assert expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( expect.objectContaining({ pageType: 'database' }), ) }) }) - // -------------------------------------------------------------------------- // Side Effects and Cleanup Tests - // -------------------------------------------------------------------------- describe('Side Effects and Cleanup', () => { it('should trigger effect when currentPage prop changes', async () => { - // Arrange const page1 = createMockNotionPage({ page_id: 'page-1' }) const page2 = createMockNotionPage({ page_id: 'page-2' }) - // Act const { rerender } = render( , ) @@ -754,19 +632,16 @@ describe('NotionPagePreview', () => { rerender() }) - // Assert await waitFor(() => { expect(mockFetchNotionPagePreview).toHaveBeenCalledTimes(2) }) }) it('should not trigger effect when hidePreview changes', async () => { - // Arrange const page = createMockNotionPage() const hidePreview1 = vi.fn() const hidePreview2 = vi.fn() - // Act const { rerender } = render( , ) @@ -785,10 +660,8 @@ describe('NotionPagePreview', () => { }) it('should not trigger effect when notionCredentialId changes', async () => { - // Arrange const page = createMockNotionPage() - // Act const { rerender } = render( , ) @@ -806,11 +679,9 @@ describe('NotionPagePreview', () => { }) it('should handle rapid page changes', async () => { - // Arrange const pages = Array.from({ length: 5 }, (_, i) => createMockNotionPage({ page_id: `page-${i}` })) - // Act const { rerender } = render( , ) @@ -829,7 +700,6 @@ describe('NotionPagePreview', () => { }) it('should handle unmount during loading', async () => { - // Arrange mockFetchNotionPagePreview.mockImplementation( () => new Promise(resolve => setTimeout(() => resolve({ content: 'delayed' }), 1000)), ) @@ -845,10 +715,8 @@ describe('NotionPagePreview', () => { }) it('should handle page changing from defined to undefined', async () => { - // Arrange const page = createMockNotionPage() - // Act const { rerender, container } = render( , ) @@ -867,38 +735,27 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- - // Accessibility Tests - // -------------------------------------------------------------------------- describe('Accessibility', () => { it('should have clickable close button with visual indicator', async () => { - // Arrange & Act const { container } = await renderNotionPagePreview() - // Assert const closeButton = container.querySelector('.cursor-pointer') expect(closeButton).toBeInTheDocument() expect(closeButton).toHaveClass('cursor-pointer') }) it('should have proper heading structure', async () => { - // Arrange & Act await renderNotionPagePreview() - // Assert expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- // Error Handling Tests - // -------------------------------------------------------------------------- describe('Error Handling', () => { it('should not crash on API network error', async () => { - // Arrange mockFetchNotionPagePreview.mockRejectedValue(new Error('Network Error')) - // Act const { container } = await renderNotionPagePreview({}, false) // Assert - Component should still render @@ -908,122 +765,92 @@ describe('NotionPagePreview', () => { }) it('should not crash on API timeout', async () => { - // Arrange mockFetchNotionPagePreview.mockRejectedValue(new Error('Timeout')) - // Act const { container } = await renderNotionPagePreview({}, false) - // Assert await waitFor(() => { expect(container.firstChild).toBeInTheDocument() }) }) it('should not crash on malformed API response', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({} as { content: string }) - // Act const { container } = await renderNotionPagePreview() - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should handle 404 error gracefully', async () => { - // Arrange mockFetchNotionPagePreview.mockRejectedValue(new Error('404 Not Found')) - // Act const { container } = await renderNotionPagePreview({}, false) - // Assert await waitFor(() => { expect(container.firstChild).toBeInTheDocument() }) }) it('should handle 500 error gracefully', async () => { - // Arrange mockFetchNotionPagePreview.mockRejectedValue(new Error('500 Internal Server Error')) - // Act const { container } = await renderNotionPagePreview({}, false) - // Assert await waitFor(() => { expect(container.firstChild).toBeInTheDocument() }) }) it('should handle authorization error gracefully', async () => { - // Arrange mockFetchNotionPagePreview.mockRejectedValue(new Error('401 Unauthorized')) - // Act const { container } = await renderNotionPagePreview({}, false) - // Assert await waitFor(() => { expect(container.firstChild).toBeInTheDocument() }) }) }) - // -------------------------------------------------------------------------- // Page Type Variations Tests - // -------------------------------------------------------------------------- describe('Page Type Variations', () => { it('should handle page type', async () => { - // Arrange const page = createMockNotionPage({ type: 'page' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( expect.objectContaining({ pageType: 'page' }), ) }) it('should handle database type', async () => { - // Arrange const page = createMockNotionPage({ type: 'database' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( expect.objectContaining({ pageType: 'database' }), ) }) it('should handle unknown type', async () => { - // Arrange const page = createMockNotionPage({ type: 'unknown_type' }) - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(mockFetchNotionPagePreview).toHaveBeenCalledWith( expect.objectContaining({ pageType: 'unknown_type' }), ) }) }) - // -------------------------------------------------------------------------- // Icon Type Variations Tests - // -------------------------------------------------------------------------- describe('Icon Type Variations', () => { it('should handle page with null icon', async () => { - // Arrange const page = createMockNotionPage({ page_icon: null }) - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) // Assert - Should render default icon @@ -1032,31 +859,24 @@ describe('NotionPagePreview', () => { }) it('should handle page with emoji icon object', async () => { - // Arrange const page = createMockNotionPageWithEmojiIcon('📄') - // Act await renderNotionPagePreview({ currentPage: page }) - // Assert expect(screen.getByText('📄')).toBeInTheDocument() }) it('should handle page with url icon object', async () => { - // Arrange const page = createMockNotionPageWithUrlIcon('https://example.com/custom-icon.png') - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) - // Assert const img = container.querySelector('img[alt="page icon"]') expect(img).toBeInTheDocument() expect(img).toHaveAttribute('src', 'https://example.com/custom-icon.png') }) it('should handle page with icon object having null values', async () => { - // Arrange const page = createMockNotionPage({ page_icon: { type: null, @@ -1065,7 +885,6 @@ describe('NotionPagePreview', () => { }, }) - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) // Assert - Should render, likely with default/fallback @@ -1073,7 +892,6 @@ describe('NotionPagePreview', () => { }) it('should handle page with icon object having empty url', async () => { - // Arrange // Suppress console.error for this test as we're intentionally testing empty src edge case const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(vi.fn()) @@ -1085,7 +903,6 @@ describe('NotionPagePreview', () => { }, }) - // Act const { container } = await renderNotionPagePreview({ currentPage: page }) // Assert - Component should not crash, may render img or fallback @@ -1100,32 +917,24 @@ describe('NotionPagePreview', () => { }) }) - // -------------------------------------------------------------------------- // Content Display Tests - // -------------------------------------------------------------------------- describe('Content Display', () => { it('should display content in fileContent div with correct class', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({ content: 'Test content' }) - // Act const { container } = await renderNotionPagePreview() - // Assert const contentDiv = container.querySelector('[class*="fileContent"]') expect(contentDiv).toBeInTheDocument() expect(contentDiv).toHaveTextContent('Test content') }) it('should preserve whitespace in content', async () => { - // Arrange const contentWithWhitespace = ' indented content\n more indent' mockFetchNotionPagePreview.mockResolvedValue({ content: contentWithWhitespace }) - // Act const { container } = await renderNotionPagePreview() - // Assert const contentDiv = container.querySelector('[class*="fileContent"]') expect(contentDiv).toBeInTheDocument() // The CSS class has white-space: pre-line @@ -1133,13 +942,10 @@ describe('NotionPagePreview', () => { }) it('should display empty string content without loading', async () => { - // Arrange mockFetchNotionPagePreview.mockResolvedValue({ content: '' }) - // Act const { container } = await renderNotionPagePreview() - // Assert const loadingElement = findLoadingSpinner(container) expect(loadingElement).not.toBeInTheDocument() const contentDiv = container.querySelector('[class*="fileContent"]') diff --git a/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f00ff121cc --- /dev/null +++ b/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx @@ -0,0 +1,561 @@ +import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types' +import type { NotionPage } from '@/models/common' +import type { CrawlOptions, CrawlResultItem, DataSet, FileItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { Plan } from '@/app/components/billing/type' +import { DataSourceType } from '@/models/datasets' +import StepOne from '../index' + +// Mock config for website crawl features +vi.mock('@/config', () => ({ + ENABLE_WEBSITE_FIRECRAWL: true, + ENABLE_WEBSITE_JINAREADER: false, + ENABLE_WEBSITE_WATERCRAWL: false, +})) + +// Mock dataset detail context +let mockDatasetDetail: DataSet | undefined +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset: DataSet | undefined }) => DataSet | undefined) => { + return selector({ dataset: mockDatasetDetail }) + }, +})) + +// Mock provider context +let mockPlan = { + type: Plan.professional, + usage: { vectorSpace: 50, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, + total: { vectorSpace: 100, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, +} +let mockEnableBilling = false + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: mockPlan, + enableBilling: mockEnableBilling, + }), +})) + +vi.mock('../../file-uploader', () => ({ + default: ({ onPreview, fileList }: { onPreview: (file: File) => void, fileList: FileItem[] }) => ( +
+ {fileList.length} + +
+ ), +})) + +vi.mock('../../website', () => ({ + default: ({ onPreview }: { onPreview: (item: CrawlResultItem) => void }) => ( +
+ +
+ ), +})) + +vi.mock('../../empty-dataset-creation-modal', () => ({ + default: ({ show, onHide }: { show: boolean, onHide: () => void }) => ( + show + ? ( +
+ +
+ ) + : null + ), +})) + +// NotionConnector is a base component - imported directly without mock +// It only depends on i18n which is globally mocked + +vi.mock('@/app/components/base/notion-page-selector', () => ({ + NotionPageSelector: ({ onPreview }: { onPreview: (page: NotionPage) => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/billing/vector-space-full', () => ({ + default: () =>
Vector Space Full
, +})) + +vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ + default: ({ show, onClose }: { show: boolean, onClose: () => void }) => ( + show + ? ( +
+ +
+ ) + : null + ), +})) + +vi.mock('../../file-preview', () => ({ + default: ({ file, hidePreview }: { file: File, hidePreview: () => void }) => ( +
+ {file.name} + +
+ ), +})) + +vi.mock('../../notion-page-preview', () => ({ + default: ({ currentPage, hidePreview }: { currentPage: NotionPage, hidePreview: () => void }) => ( +
+ {currentPage.page_id} + +
+ ), +})) + +// WebsitePreview is a sibling component without API dependencies - imported directly +// It only depends on i18n which is globally mocked + +vi.mock('../upgrade-card', () => ({ + default: () =>
Upgrade Card
, +})) + +const createMockCustomFile = (overrides: { id?: string, name?: string } = {}) => { + const file = new File(['test content'], overrides.name ?? 'test.txt', { type: 'text/plain' }) + return Object.assign(file, { + id: overrides.id ?? 'uploaded-id', + extension: 'txt', + mime_type: 'text/plain', + created_by: 'user-1', + created_at: Date.now(), + }) +} + +const createMockFileItem = (overrides: Partial = {}): FileItem => ({ + fileID: `file-${Date.now()}`, + file: createMockCustomFile(overrides.file as { id?: string, name?: string }), + progress: 100, + ...overrides, +}) + +const createMockNotionPage = (overrides: Partial = {}): NotionPage => ({ + page_id: `page-${Date.now()}`, + type: 'page', + ...overrides, +} as NotionPage) + +const createMockCrawlResult = (overrides: Partial = {}): CrawlResultItem => ({ + title: 'Test Page', + markdown: 'Test content', + description: 'Test description', + source_url: 'https://example.com', + ...overrides, +}) + +const createMockDataSourceAuth = (overrides: Partial = {}): DataSourceAuth => ({ + credential_id: 'cred-1', + provider: 'notion_datasource', + plugin_id: 'plugin-1', + credentials_list: [{ id: 'cred-1', name: 'Workspace 1' }], + ...overrides, +} as DataSourceAuth) + +const defaultProps = { + dataSourceType: DataSourceType.FILE, + dataSourceTypeDisable: false, + onSetting: vi.fn(), + files: [] as FileItem[], + updateFileList: vi.fn(), + updateFile: vi.fn(), + notionPages: [] as NotionPage[], + notionCredentialId: '', + updateNotionPages: vi.fn(), + updateNotionCredentialId: vi.fn(), + onStepChange: vi.fn(), + changeType: vi.fn(), + websitePages: [] as CrawlResultItem[], + updateWebsitePages: vi.fn(), + onWebsiteCrawlProviderChange: vi.fn(), + onWebsiteCrawlJobIdChange: vi.fn(), + crawlOptions: { + crawl_sub_pages: true, + only_main_content: true, + includes: '', + excludes: '', + limit: 10, + max_depth: '', + use_sitemap: true, + } as CrawlOptions, + onCrawlOptionsChange: vi.fn(), + authedDataSourceList: [] as DataSourceAuth[], +} + +// NOTE: Child component unit tests (usePreviewState, DataSourceTypeSelector, +// NextStepButton, PreviewPanel) have been moved to their own dedicated spec files: +// - ./hooks/use-preview-state.spec.ts +// - ./components/data-source-type-selector.spec.tsx +// - ./components/next-step-button.spec.tsx +// - ./components/preview-panel.spec.tsx +// This file now focuses exclusively on StepOne parent component tests. + +// StepOne Component Tests +describe('StepOne', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDatasetDetail = undefined + mockPlan = { + type: Plan.professional, + usage: { vectorSpace: 50, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, + total: { vectorSpace: 100, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, + } + mockEnableBilling = false + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() + }) + + it('should render DataSourceTypeSelector when not editing existing dataset', () => { + render() + + expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument() + }) + + it('should render FileUploader when dataSourceType is FILE', () => { + render() + + expect(screen.getByTestId('file-uploader')).toBeInTheDocument() + }) + + it('should render NotionConnector when dataSourceType is NOTION and not authenticated', () => { + render() + + // Assert - NotionConnector shows sync title and connect button + expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.connect/i })).toBeInTheDocument() + }) + + it('should render NotionPageSelector when dataSourceType is NOTION and authenticated', () => { + const authedDataSourceList = [createMockDataSourceAuth()] + + render() + + expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument() + }) + + it('should render Website when dataSourceType is WEB', () => { + render() + + expect(screen.getByTestId('website')).toBeInTheDocument() + }) + + it('should render empty dataset creation link when no datasetId', () => { + render() + + expect(screen.getByText('datasetCreation.stepOne.emptyDatasetCreation')).toBeInTheDocument() + }) + + it('should not render empty dataset creation link when datasetId exists', () => { + render() + + expect(screen.queryByText('datasetCreation.stepOne.emptyDatasetCreation')).not.toBeInTheDocument() + }) + }) + + // Props Tests + describe('Props', () => { + it('should pass files to FileUploader', () => { + const files = [createMockFileItem()] + + render() + + expect(screen.getByTestId('file-count')).toHaveTextContent('1') + }) + + it('should call onSetting when NotionConnector connect button is clicked', () => { + const onSetting = vi.fn() + render() + + // Act - The NotionConnector's button calls onSetting + fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.connect/i })) + + expect(onSetting).toHaveBeenCalledTimes(1) + }) + + it('should call changeType when data source type is changed', () => { + const changeType = vi.fn() + render() + + fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) + + expect(changeType).toHaveBeenCalledWith(DataSourceType.NOTION) + }) + }) + + describe('State Management', () => { + it('should open empty dataset modal when link is clicked', () => { + render() + + fireEvent.click(screen.getByText('datasetCreation.stepOne.emptyDatasetCreation')) + + expect(screen.getByTestId('empty-dataset-modal')).toBeInTheDocument() + }) + + it('should close empty dataset modal when close is clicked', () => { + render() + fireEvent.click(screen.getByText('datasetCreation.stepOne.emptyDatasetCreation')) + + fireEvent.click(screen.getByTestId('close-modal')) + + expect(screen.queryByTestId('empty-dataset-modal')).not.toBeInTheDocument() + }) + }) + + describe('Memoization', () => { + it('should correctly compute isNotionAuthed based on authedDataSourceList', () => { + // Arrange - No auth + const { rerender } = render() + // NotionConnector shows the sync title when not authenticated + expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() + + // Act - Add auth + const authedDataSourceList = [createMockDataSourceAuth()] + rerender() + + expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument() + }) + + it('should correctly compute fileNextDisabled when files are empty', () => { + render() + + // Assert - Button should be disabled + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() + }) + + it('should correctly compute fileNextDisabled when files are loaded', () => { + const files = [createMockFileItem()] + + render() + + // Assert - Button should be enabled + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() + }) + + it('should correctly compute fileNextDisabled when some files are not uploaded', () => { + // Arrange - Create a file item without id (not yet uploaded) + const file = new File(['test'], 'test.txt', { type: 'text/plain' }) + const fileItem: FileItem = { + fileID: 'temp-id', + file: Object.assign(file, { id: undefined, extension: 'txt', mime_type: 'text/plain' }), + progress: 0, + } + + render() + + // Assert - Button should be disabled + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() + }) + }) + + describe('Callbacks', () => { + it('should call onStepChange when next button is clicked with valid files', () => { + const onStepChange = vi.fn() + const files = [createMockFileItem()] + render() + + fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) + + expect(onStepChange).toHaveBeenCalledTimes(1) + }) + + it('should show plan upgrade modal when batch upload not supported and multiple files', () => { + mockEnableBilling = true + mockPlan.type = Plan.sandbox + const files = [createMockFileItem(), createMockFileItem()] + render() + + fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) + + expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() + }) + + it('should show upgrade card when in sandbox plan with files', () => { + mockEnableBilling = true + mockPlan.type = Plan.sandbox + const files = [createMockFileItem()] + + render() + + expect(screen.getByTestId('upgrade-card')).toBeInTheDocument() + }) + }) + + // Vector Space Full Tests + describe('Vector Space Full', () => { + it('should show VectorSpaceFull when vector space is full and billing is enabled', () => { + mockEnableBilling = true + mockPlan.usage.vectorSpace = 100 + mockPlan.total.vectorSpace = 100 + const files = [createMockFileItem()] + + render() + + expect(screen.getByTestId('vector-space-full')).toBeInTheDocument() + }) + + it('should disable next button when vector space is full', () => { + mockEnableBilling = true + mockPlan.usage.vectorSpace = 100 + mockPlan.total.vectorSpace = 100 + const files = [createMockFileItem()] + + render() + + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() + }) + }) + + // Preview Integration Tests + describe('Preview Integration', () => { + it('should show file preview when file preview button is clicked', () => { + render() + + fireEvent.click(screen.getByTestId('preview-file')) + + expect(screen.getByTestId('file-preview')).toBeInTheDocument() + }) + + it('should hide file preview when hide button is clicked', () => { + render() + fireEvent.click(screen.getByTestId('preview-file')) + + fireEvent.click(screen.getByTestId('hide-file-preview')) + + expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument() + }) + + it('should show notion page preview when preview button is clicked', () => { + const authedDataSourceList = [createMockDataSourceAuth()] + render() + + fireEvent.click(screen.getByTestId('preview-notion')) + + expect(screen.getByTestId('notion-page-preview')).toBeInTheDocument() + }) + + it('should show website preview when preview button is clicked', () => { + render() + + fireEvent.click(screen.getByTestId('preview-website')) + + // Assert - Check for pagePreview title which is shown by WebsitePreview + expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty notionPages array', () => { + const authedDataSourceList = [createMockDataSourceAuth()] + + render() + + // Assert - Button should be disabled when no pages selected + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() + }) + + it('should handle empty websitePages array', () => { + render() + + // Assert - Button should be disabled when no pages crawled + expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() + }) + + it('should handle empty authedDataSourceList', () => { + render() + + // Assert - Should show NotionConnector with connect button + expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() + }) + + it('should handle authedDataSourceList without notion credentials', () => { + const authedDataSourceList = [createMockDataSourceAuth({ credentials_list: [] })] + + render() + + // Assert - Should show NotionConnector with connect button + expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() + }) + + it('should clear previews when switching data source types', () => { + render() + fireEvent.click(screen.getByTestId('preview-file')) + expect(screen.getByTestId('file-preview')).toBeInTheDocument() + + // Act - Change to NOTION + fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) + + // Assert - File preview should be cleared + expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument() + }) + }) + + describe('Integration', () => { + it('should complete file upload flow', () => { + const onStepChange = vi.fn() + const files = [createMockFileItem()] + + render() + fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) + + expect(onStepChange).toHaveBeenCalled() + }) + + it('should complete notion page selection flow', () => { + const onStepChange = vi.fn() + const authedDataSourceList = [createMockDataSourceAuth()] + const notionPages = [createMockNotionPage()] + + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) + + expect(onStepChange).toHaveBeenCalled() + }) + + it('should complete website crawl flow', () => { + const onStepChange = vi.fn() + const websitePages = [createMockCrawlResult()] + + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) + + expect(onStepChange).toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx b/web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx new file mode 100644 index 0000000000..ab7d0f0225 --- /dev/null +++ b/web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx @@ -0,0 +1,89 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import UpgradeCard from '../upgrade-card' + +const mockSetShowPricingModal = vi.fn() + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowPricingModal: mockSetShowPricingModal, + }), +})) + +vi.mock('@/app/components/billing/upgrade-btn', () => ({ + default: ({ onClick, className }: { onClick?: () => void, className?: string }) => ( + + ), +})) + +describe('UpgradeCard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + + // Assert - title and description i18n keys are rendered + expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument() + }) + + it('should render the upgrade title text', () => { + render() + + expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument() + }) + + it('should render the upgrade description text', () => { + render() + + expect(screen.getByText(/uploadMultipleFiles\.description/i)).toBeInTheDocument() + }) + + it('should render the upgrade button', () => { + render() + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call setShowPricingModal when upgrade button is clicked', () => { + render() + + fireEvent.click(screen.getByRole('button')) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1) + }) + + it('should not call setShowPricingModal without user interaction', () => { + render() + + expect(mockSetShowPricingModal).not.toHaveBeenCalled() + }) + + it('should call setShowPricingModal on each button click', () => { + render() + + const button = screen.getByRole('button') + fireEvent.click(button) + fireEvent.click(button) + + expect(mockSetShowPricingModal).toHaveBeenCalledTimes(2) + }) + }) + + describe('Memoization', () => { + it('should maintain rendering after rerender with same props', () => { + const { rerender } = render() + + rerender() + + expect(screen.getByText(/uploadMultipleFiles\.title/i)).toBeInTheDocument() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/step-one/components/__tests__/data-source-type-selector.spec.tsx b/web/app/components/datasets/create/step-one/components/__tests__/data-source-type-selector.spec.tsx new file mode 100644 index 0000000000..aeb1afad26 --- /dev/null +++ b/web/app/components/datasets/create/step-one/components/__tests__/data-source-type-selector.spec.tsx @@ -0,0 +1,66 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DataSourceType } from '@/models/datasets' + +// Mock config to control web crawl feature flags +vi.mock('@/config', () => ({ + ENABLE_WEBSITE_FIRECRAWL: true, + ENABLE_WEBSITE_JINAREADER: true, + ENABLE_WEBSITE_WATERCRAWL: false, +})) + +// Mock CSS module +vi.mock('../../../index.module.css', () => ({ + default: { + dataSourceItem: 'ds-item', + active: 'active', + disabled: 'disabled', + datasetIcon: 'icon', + notion: 'notion-icon', + web: 'web-icon', + }, +})) + +const { default: DataSourceTypeSelector } = await import('../data-source-type-selector') + +describe('DataSourceTypeSelector', () => { + const defaultProps = { + currentType: DataSourceType.FILE, + disabled: false, + onChange: vi.fn(), + onClearPreviews: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('should render file, notion, and web options', () => { + render() + expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.dataSourceType.web')).toBeInTheDocument() + }) + + it('should render as a 3-column grid', () => { + const { container } = render() + expect(container.firstElementChild).toHaveClass('grid-cols-3') + }) + }) + + describe('interactions', () => { + it('should call onChange and onClearPreviews on type click', () => { + render() + fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) + expect(defaultProps.onChange).toHaveBeenCalledWith(DataSourceType.NOTION) + expect(defaultProps.onClearPreviews).toHaveBeenCalledWith(DataSourceType.NOTION) + }) + + it('should not call onChange when disabled', () => { + render() + fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) + expect(defaultProps.onChange).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/datasets/create/step-one/components/__tests__/next-step-button.spec.tsx b/web/app/components/datasets/create/step-one/components/__tests__/next-step-button.spec.tsx new file mode 100644 index 0000000000..58d124d867 --- /dev/null +++ b/web/app/components/datasets/create/step-one/components/__tests__/next-step-button.spec.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import NextStepButton from '../next-step-button' + +describe('NextStepButton', () => { + const defaultProps = { + disabled: false, + onClick: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render button text', () => { + render() + expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument() + }) + + it('should render a primary variant button', () => { + render() + const btn = screen.getByRole('button') + expect(btn).toBeInTheDocument() + }) + + it('should call onClick when clicked', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(defaultProps.onClick).toHaveBeenCalledOnce() + }) + + it('should be disabled when disabled prop is true', () => { + render() + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should not call onClick when disabled', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(defaultProps.onClick).not.toHaveBeenCalled() + }) + + it('should render arrow icon', () => { + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx b/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx new file mode 100644 index 0000000000..f495dd9f3f --- /dev/null +++ b/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx @@ -0,0 +1,119 @@ +import type { NotionPage } from '@/models/common' +import type { CrawlResultItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock child components - paths must match source file's imports (relative to source) +vi.mock('../../../file-preview', () => ({ + default: ({ file, hidePreview }: { file: { name: string }, hidePreview: () => void }) => ( +
+ {file.name} + +
+ ), +})) + +vi.mock('../../../notion-page-preview', () => ({ + default: ({ currentPage, hidePreview }: { currentPage: { page_name: string }, hidePreview: () => void }) => ( +
+ {currentPage.page_name} + +
+ ), +})) + +vi.mock('../../../website/preview', () => ({ + default: ({ payload, hidePreview }: { payload: { title: string }, hidePreview: () => void }) => ( +
+ {payload.title} + +
+ ), +})) + +vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ + default: ({ show, onClose, title }: { show: boolean, onClose: () => void, title: string }) => show + ? ( +
+ {title} + +
+ ) + : null, +})) + +const { default: PreviewPanel } = await import('../preview-panel') + +describe('PreviewPanel', () => { + const defaultProps = { + currentFile: undefined, + currentNotionPage: undefined, + currentWebsite: undefined, + notionCredentialId: 'cred-1', + isShowPlanUpgradeModal: false, + hideFilePreview: vi.fn(), + hideNotionPagePreview: vi.fn(), + hideWebsitePreview: vi.fn(), + hidePlanUpgradeModal: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('should render nothing when no preview is active', () => { + const { container } = render() + expect(container.querySelector('[data-testid]')).toBeNull() + }) + + it('should render file preview when currentFile is set', () => { + render() + expect(screen.getByTestId('file-preview')).toBeInTheDocument() + expect(screen.getByText('test.pdf')).toBeInTheDocument() + }) + + it('should render notion preview when currentNotionPage is set', () => { + render() + expect(screen.getByTestId('notion-preview')).toBeInTheDocument() + expect(screen.getByText('My Page')).toBeInTheDocument() + }) + + it('should render website preview when currentWebsite is set', () => { + render() + expect(screen.getByTestId('website-preview')).toBeInTheDocument() + expect(screen.getByText('My Site')).toBeInTheDocument() + }) + + it('should render plan upgrade modal when isShowPlanUpgradeModal is true', () => { + render() + expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() + }) + }) + + describe('interactions', () => { + it('should call hideFilePreview when file preview close clicked', () => { + render() + fireEvent.click(screen.getByTestId('close-file')) + expect(defaultProps.hideFilePreview).toHaveBeenCalledOnce() + }) + + it('should call hidePlanUpgradeModal when modal close clicked', () => { + render() + fireEvent.click(screen.getByTestId('close-modal')) + expect(defaultProps.hidePlanUpgradeModal).toHaveBeenCalledOnce() + }) + + it('should call hideNotionPagePreview when notion preview close clicked', () => { + render() + fireEvent.click(screen.getByTestId('close-notion')) + expect(defaultProps.hideNotionPagePreview).toHaveBeenCalledOnce() + }) + + it('should call hideWebsitePreview when website preview close clicked', () => { + render() + fireEvent.click(screen.getByTestId('close-website')) + expect(defaultProps.hideWebsitePreview).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/web/app/components/datasets/create/step-one/hooks/__tests__/use-preview-state.spec.ts b/web/app/components/datasets/create/step-one/hooks/__tests__/use-preview-state.spec.ts new file mode 100644 index 0000000000..9ab71d78e9 --- /dev/null +++ b/web/app/components/datasets/create/step-one/hooks/__tests__/use-preview-state.spec.ts @@ -0,0 +1,60 @@ +import type { NotionPage } from '@/models/common' +import type { CrawlResultItem } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import usePreviewState from '../use-preview-state' + +describe('usePreviewState', () => { + it('should initialize with all previews undefined', () => { + const { result } = renderHook(() => usePreviewState()) + + expect(result.current.currentFile).toBeUndefined() + expect(result.current.currentNotionPage).toBeUndefined() + expect(result.current.currentWebsite).toBeUndefined() + }) + + it('should show and hide file preview', () => { + const { result } = renderHook(() => usePreviewState()) + const file = new File(['content'], 'test.pdf') + + act(() => { + result.current.showFilePreview(file) + }) + expect(result.current.currentFile).toBe(file) + + act(() => { + result.current.hideFilePreview() + }) + expect(result.current.currentFile).toBeUndefined() + }) + + it('should show and hide notion page preview', () => { + const { result } = renderHook(() => usePreviewState()) + const page = { page_id: 'p1', page_name: 'Test' } as unknown as NotionPage + + act(() => { + result.current.showNotionPagePreview(page) + }) + expect(result.current.currentNotionPage).toBe(page) + + act(() => { + result.current.hideNotionPagePreview() + }) + expect(result.current.currentNotionPage).toBeUndefined() + }) + + it('should show and hide website preview', () => { + const { result } = renderHook(() => usePreviewState()) + const website = { title: 'Example', source_url: 'https://example.com' } as unknown as CrawlResultItem + + act(() => { + result.current.showWebsitePreview(website) + }) + expect(result.current.currentWebsite).toBe(website) + + act(() => { + result.current.hideWebsitePreview() + }) + expect(result.current.currentWebsite).toBeUndefined() + }) +}) diff --git a/web/app/components/datasets/create/step-one/index.spec.tsx b/web/app/components/datasets/create/step-one/index.spec.tsx deleted file mode 100644 index 1ff77dc1f6..0000000000 --- a/web/app/components/datasets/create/step-one/index.spec.tsx +++ /dev/null @@ -1,1204 +0,0 @@ -import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types' -import type { NotionPage } from '@/models/common' -import type { CrawlOptions, CrawlResultItem, DataSet, FileItem } from '@/models/datasets' -import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' -import { Plan } from '@/app/components/billing/type' -import { DataSourceType } from '@/models/datasets' -import { DataSourceTypeSelector, NextStepButton, PreviewPanel } from './components' -import { usePreviewState } from './hooks' -import StepOne from './index' - -// ========================================== -// Mock External Dependencies -// ========================================== - -// Mock config for website crawl features -vi.mock('@/config', () => ({ - ENABLE_WEBSITE_FIRECRAWL: true, - ENABLE_WEBSITE_JINAREADER: false, - ENABLE_WEBSITE_WATERCRAWL: false, -})) - -// Mock dataset detail context -let mockDatasetDetail: DataSet | undefined -vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (state: { dataset: DataSet | undefined }) => DataSet | undefined) => { - return selector({ dataset: mockDatasetDetail }) - }, -})) - -// Mock provider context -let mockPlan = { - type: Plan.professional, - usage: { vectorSpace: 50, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, - total: { vectorSpace: 100, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, -} -let mockEnableBilling = false - -vi.mock('@/context/provider-context', () => ({ - useProviderContext: () => ({ - plan: mockPlan, - enableBilling: mockEnableBilling, - }), -})) - -// Mock child components -vi.mock('../file-uploader', () => ({ - default: ({ onPreview, fileList }: { onPreview: (file: File) => void, fileList: FileItem[] }) => ( -
- {fileList.length} - -
- ), -})) - -vi.mock('../website', () => ({ - default: ({ onPreview }: { onPreview: (item: CrawlResultItem) => void }) => ( -
- -
- ), -})) - -vi.mock('../empty-dataset-creation-modal', () => ({ - default: ({ show, onHide }: { show: boolean, onHide: () => void }) => ( - show - ? ( -
- -
- ) - : null - ), -})) - -// NotionConnector is a base component - imported directly without mock -// It only depends on i18n which is globally mocked - -vi.mock('@/app/components/base/notion-page-selector', () => ({ - NotionPageSelector: ({ onPreview }: { onPreview: (page: NotionPage) => void }) => ( -
- -
- ), -})) - -vi.mock('@/app/components/billing/vector-space-full', () => ({ - default: () =>
Vector Space Full
, -})) - -vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ - default: ({ show, onClose }: { show: boolean, onClose: () => void }) => ( - show - ? ( -
- -
- ) - : null - ), -})) - -vi.mock('../file-preview', () => ({ - default: ({ file, hidePreview }: { file: File, hidePreview: () => void }) => ( -
- {file.name} - -
- ), -})) - -vi.mock('../notion-page-preview', () => ({ - default: ({ currentPage, hidePreview }: { currentPage: NotionPage, hidePreview: () => void }) => ( -
- {currentPage.page_id} - -
- ), -})) - -// WebsitePreview is a sibling component without API dependencies - imported directly -// It only depends on i18n which is globally mocked - -vi.mock('./upgrade-card', () => ({ - default: () =>
Upgrade Card
, -})) - -// ========================================== -// Test Data Builders -// ========================================== - -const createMockCustomFile = (overrides: { id?: string, name?: string } = {}) => { - const file = new File(['test content'], overrides.name ?? 'test.txt', { type: 'text/plain' }) - return Object.assign(file, { - id: overrides.id ?? 'uploaded-id', - extension: 'txt', - mime_type: 'text/plain', - created_by: 'user-1', - created_at: Date.now(), - }) -} - -const createMockFileItem = (overrides: Partial = {}): FileItem => ({ - fileID: `file-${Date.now()}`, - file: createMockCustomFile(overrides.file as { id?: string, name?: string }), - progress: 100, - ...overrides, -}) - -const createMockNotionPage = (overrides: Partial = {}): NotionPage => ({ - page_id: `page-${Date.now()}`, - type: 'page', - ...overrides, -} as NotionPage) - -const createMockCrawlResult = (overrides: Partial = {}): CrawlResultItem => ({ - title: 'Test Page', - markdown: 'Test content', - description: 'Test description', - source_url: 'https://example.com', - ...overrides, -}) - -const createMockDataSourceAuth = (overrides: Partial = {}): DataSourceAuth => ({ - credential_id: 'cred-1', - provider: 'notion_datasource', - plugin_id: 'plugin-1', - credentials_list: [{ id: 'cred-1', name: 'Workspace 1' }], - ...overrides, -} as DataSourceAuth) - -const defaultProps = { - dataSourceType: DataSourceType.FILE, - dataSourceTypeDisable: false, - onSetting: vi.fn(), - files: [] as FileItem[], - updateFileList: vi.fn(), - updateFile: vi.fn(), - notionPages: [] as NotionPage[], - notionCredentialId: '', - updateNotionPages: vi.fn(), - updateNotionCredentialId: vi.fn(), - onStepChange: vi.fn(), - changeType: vi.fn(), - websitePages: [] as CrawlResultItem[], - updateWebsitePages: vi.fn(), - onWebsiteCrawlProviderChange: vi.fn(), - onWebsiteCrawlJobIdChange: vi.fn(), - crawlOptions: { - crawl_sub_pages: true, - only_main_content: true, - includes: '', - excludes: '', - limit: 10, - max_depth: '', - use_sitemap: true, - } as CrawlOptions, - onCrawlOptionsChange: vi.fn(), - authedDataSourceList: [] as DataSourceAuth[], -} - -// ========================================== -// usePreviewState Hook Tests -// ========================================== -describe('usePreviewState Hook', () => { - // -------------------------------------------------------------------------- - // Initial State Tests - // -------------------------------------------------------------------------- - describe('Initial State', () => { - it('should initialize with all preview states undefined', () => { - // Arrange & Act - const { result } = renderHook(() => usePreviewState()) - - // Assert - expect(result.current.currentFile).toBeUndefined() - expect(result.current.currentNotionPage).toBeUndefined() - expect(result.current.currentWebsite).toBeUndefined() - }) - }) - - // -------------------------------------------------------------------------- - // File Preview Tests - // -------------------------------------------------------------------------- - describe('File Preview', () => { - it('should show file preview when showFilePreview is called', () => { - // Arrange - const { result } = renderHook(() => usePreviewState()) - const mockFile = new File(['test'], 'test.txt') - - // Act - act(() => { - result.current.showFilePreview(mockFile) - }) - - // Assert - expect(result.current.currentFile).toBe(mockFile) - }) - - it('should hide file preview when hideFilePreview is called', () => { - // Arrange - const { result } = renderHook(() => usePreviewState()) - const mockFile = new File(['test'], 'test.txt') - - act(() => { - result.current.showFilePreview(mockFile) - }) - - // Act - act(() => { - result.current.hideFilePreview() - }) - - // Assert - expect(result.current.currentFile).toBeUndefined() - }) - }) - - // -------------------------------------------------------------------------- - // Notion Page Preview Tests - // -------------------------------------------------------------------------- - describe('Notion Page Preview', () => { - it('should show notion page preview when showNotionPagePreview is called', () => { - // Arrange - const { result } = renderHook(() => usePreviewState()) - const mockPage = createMockNotionPage() - - // Act - act(() => { - result.current.showNotionPagePreview(mockPage) - }) - - // Assert - expect(result.current.currentNotionPage).toBe(mockPage) - }) - - it('should hide notion page preview when hideNotionPagePreview is called', () => { - // Arrange - const { result } = renderHook(() => usePreviewState()) - const mockPage = createMockNotionPage() - - act(() => { - result.current.showNotionPagePreview(mockPage) - }) - - // Act - act(() => { - result.current.hideNotionPagePreview() - }) - - // Assert - expect(result.current.currentNotionPage).toBeUndefined() - }) - }) - - // -------------------------------------------------------------------------- - // Website Preview Tests - // -------------------------------------------------------------------------- - describe('Website Preview', () => { - it('should show website preview when showWebsitePreview is called', () => { - // Arrange - const { result } = renderHook(() => usePreviewState()) - const mockWebsite = createMockCrawlResult() - - // Act - act(() => { - result.current.showWebsitePreview(mockWebsite) - }) - - // Assert - expect(result.current.currentWebsite).toBe(mockWebsite) - }) - - it('should hide website preview when hideWebsitePreview is called', () => { - // Arrange - const { result } = renderHook(() => usePreviewState()) - const mockWebsite = createMockCrawlResult() - - act(() => { - result.current.showWebsitePreview(mockWebsite) - }) - - // Act - act(() => { - result.current.hideWebsitePreview() - }) - - // Assert - expect(result.current.currentWebsite).toBeUndefined() - }) - }) - - // -------------------------------------------------------------------------- - // Callback Stability Tests (Memoization) - // -------------------------------------------------------------------------- - describe('Callback Stability', () => { - it('should maintain stable showFilePreview callback reference', () => { - // Arrange - const { result, rerender } = renderHook(() => usePreviewState()) - const initialCallback = result.current.showFilePreview - - // Act - rerender() - - // Assert - expect(result.current.showFilePreview).toBe(initialCallback) - }) - - it('should maintain stable hideFilePreview callback reference', () => { - // Arrange - const { result, rerender } = renderHook(() => usePreviewState()) - const initialCallback = result.current.hideFilePreview - - // Act - rerender() - - // Assert - expect(result.current.hideFilePreview).toBe(initialCallback) - }) - - it('should maintain stable showNotionPagePreview callback reference', () => { - // Arrange - const { result, rerender } = renderHook(() => usePreviewState()) - const initialCallback = result.current.showNotionPagePreview - - // Act - rerender() - - // Assert - expect(result.current.showNotionPagePreview).toBe(initialCallback) - }) - - it('should maintain stable hideNotionPagePreview callback reference', () => { - // Arrange - const { result, rerender } = renderHook(() => usePreviewState()) - const initialCallback = result.current.hideNotionPagePreview - - // Act - rerender() - - // Assert - expect(result.current.hideNotionPagePreview).toBe(initialCallback) - }) - - it('should maintain stable showWebsitePreview callback reference', () => { - // Arrange - const { result, rerender } = renderHook(() => usePreviewState()) - const initialCallback = result.current.showWebsitePreview - - // Act - rerender() - - // Assert - expect(result.current.showWebsitePreview).toBe(initialCallback) - }) - - it('should maintain stable hideWebsitePreview callback reference', () => { - // Arrange - const { result, rerender } = renderHook(() => usePreviewState()) - const initialCallback = result.current.hideWebsitePreview - - // Act - rerender() - - // Assert - expect(result.current.hideWebsitePreview).toBe(initialCallback) - }) - }) -}) - -// ========================================== -// DataSourceTypeSelector Component Tests -// ========================================== -describe('DataSourceTypeSelector', () => { - const defaultSelectorProps = { - currentType: DataSourceType.FILE, - disabled: false, - onChange: vi.fn(), - onClearPreviews: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- - describe('Rendering', () => { - it('should render all data source options when web is enabled', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument() - expect(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')).toBeInTheDocument() - expect(screen.getByText('datasetCreation.stepOne.dataSourceType.web')).toBeInTheDocument() - }) - - it('should highlight active type', () => { - // Arrange & Act - const { container } = render( - , - ) - - // Assert - The active item should have the active class - const items = container.querySelectorAll('[class*="dataSourceItem"]') - expect(items.length).toBeGreaterThan(0) - }) - }) - - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- - describe('User Interactions', () => { - it('should call onChange when a type is clicked', () => { - // Arrange - const onChange = vi.fn() - render() - - // Act - fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) - - // Assert - expect(onChange).toHaveBeenCalledWith(DataSourceType.NOTION) - }) - - it('should call onClearPreviews when a type is clicked', () => { - // Arrange - const onClearPreviews = vi.fn() - render() - - // Act - fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.web')) - - // Assert - expect(onClearPreviews).toHaveBeenCalledWith(DataSourceType.WEB) - }) - - it('should not call onChange when disabled', () => { - // Arrange - const onChange = vi.fn() - render() - - // Act - fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) - - // Assert - expect(onChange).not.toHaveBeenCalled() - }) - - it('should not call onClearPreviews when disabled', () => { - // Arrange - const onClearPreviews = vi.fn() - render() - - // Act - fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) - - // Assert - expect(onClearPreviews).not.toHaveBeenCalled() - }) - }) -}) - -// ========================================== -// NextStepButton Component Tests -// ========================================== -describe('NextStepButton', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- - describe('Rendering', () => { - it('should render with correct label', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByText('datasetCreation.stepOne.button')).toBeInTheDocument() - }) - - it('should render with arrow icon', () => { - // Arrange & Act - const { container } = render() - - // Assert - const svgIcon = container.querySelector('svg') - expect(svgIcon).toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Props Tests - // -------------------------------------------------------------------------- - describe('Props', () => { - it('should be disabled when disabled prop is true', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByRole('button')).toBeDisabled() - }) - - it('should be enabled when disabled prop is false', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByRole('button')).not.toBeDisabled() - }) - - it('should call onClick when clicked and not disabled', () => { - // Arrange - const onClick = vi.fn() - render() - - // Act - fireEvent.click(screen.getByRole('button')) - - // Assert - expect(onClick).toHaveBeenCalledTimes(1) - }) - - it('should not call onClick when clicked and disabled', () => { - // Arrange - const onClick = vi.fn() - render() - - // Act - fireEvent.click(screen.getByRole('button')) - - // Assert - expect(onClick).not.toHaveBeenCalled() - }) - }) -}) - -// ========================================== -// PreviewPanel Component Tests -// ========================================== -describe('PreviewPanel', () => { - const defaultPreviewProps = { - currentFile: undefined as File | undefined, - currentNotionPage: undefined as NotionPage | undefined, - currentWebsite: undefined as CrawlResultItem | undefined, - notionCredentialId: 'cred-1', - isShowPlanUpgradeModal: false, - hideFilePreview: vi.fn(), - hideNotionPagePreview: vi.fn(), - hideWebsitePreview: vi.fn(), - hidePlanUpgradeModal: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - // -------------------------------------------------------------------------- - // Conditional Rendering Tests - // -------------------------------------------------------------------------- - describe('Conditional Rendering', () => { - it('should not render FilePreview when currentFile is undefined', () => { - // Arrange & Act - render() - - // Assert - expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument() - }) - - it('should render FilePreview when currentFile is defined', () => { - // Arrange - const file = new File(['test'], 'test.txt') - - // Act - render() - - // Assert - expect(screen.getByTestId('file-preview')).toBeInTheDocument() - }) - - it('should not render NotionPagePreview when currentNotionPage is undefined', () => { - // Arrange & Act - render() - - // Assert - expect(screen.queryByTestId('notion-page-preview')).not.toBeInTheDocument() - }) - - it('should render NotionPagePreview when currentNotionPage is defined', () => { - // Arrange - const page = createMockNotionPage() - - // Act - render() - - // Assert - expect(screen.getByTestId('notion-page-preview')).toBeInTheDocument() - }) - - it('should not render WebsitePreview when currentWebsite is undefined', () => { - // Arrange & Act - render() - - // Assert - pagePreview is the title shown in WebsitePreview - expect(screen.queryByText('datasetCreation.stepOne.pagePreview')).not.toBeInTheDocument() - }) - - it('should render WebsitePreview when currentWebsite is defined', () => { - // Arrange - const website = createMockCrawlResult() - - // Act - render() - - // Assert - Check for the preview title and source URL - expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() - expect(screen.getByText(website.source_url)).toBeInTheDocument() - }) - - it('should not render PlanUpgradeModal when isShowPlanUpgradeModal is false', () => { - // Arrange & Act - render() - - // Assert - expect(screen.queryByTestId('plan-upgrade-modal')).not.toBeInTheDocument() - }) - - it('should render PlanUpgradeModal when isShowPlanUpgradeModal is true', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Event Handler Tests - // -------------------------------------------------------------------------- - describe('Event Handlers', () => { - it('should call hideFilePreview when file preview close is clicked', () => { - // Arrange - const hideFilePreview = vi.fn() - const file = new File(['test'], 'test.txt') - render() - - // Act - fireEvent.click(screen.getByTestId('hide-file-preview')) - - // Assert - expect(hideFilePreview).toHaveBeenCalledTimes(1) - }) - - it('should call hideNotionPagePreview when notion preview close is clicked', () => { - // Arrange - const hideNotionPagePreview = vi.fn() - const page = createMockNotionPage() - render() - - // Act - fireEvent.click(screen.getByTestId('hide-notion-preview')) - - // Assert - expect(hideNotionPagePreview).toHaveBeenCalledTimes(1) - }) - - it('should call hideWebsitePreview when website preview close is clicked', () => { - // Arrange - const hideWebsitePreview = vi.fn() - const website = createMockCrawlResult() - const { container } = render() - - // Act - Find the close button (div with cursor-pointer class containing the XMarkIcon) - const closeButton = container.querySelector('.cursor-pointer') - expect(closeButton).toBeInTheDocument() - fireEvent.click(closeButton!) - - // Assert - expect(hideWebsitePreview).toHaveBeenCalledTimes(1) - }) - - it('should call hidePlanUpgradeModal when modal close is clicked', () => { - // Arrange - const hidePlanUpgradeModal = vi.fn() - render() - - // Act - fireEvent.click(screen.getByTestId('close-upgrade-modal')) - - // Assert - expect(hidePlanUpgradeModal).toHaveBeenCalledTimes(1) - }) - }) -}) - -// ========================================== -// StepOne Component Tests -// ========================================== -describe('StepOne', () => { - beforeEach(() => { - vi.clearAllMocks() - mockDatasetDetail = undefined - mockPlan = { - type: Plan.professional, - usage: { vectorSpace: 50, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, - total: { vectorSpace: 100, buildApps: 0, documentsUploadQuota: 0, vectorStorageQuota: 0 }, - } - mockEnableBilling = false - }) - - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- - describe('Rendering', () => { - it('should render without crashing', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() - }) - - it('should render DataSourceTypeSelector when not editing existing dataset', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument() - }) - - it('should render FileUploader when dataSourceType is FILE', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByTestId('file-uploader')).toBeInTheDocument() - }) - - it('should render NotionConnector when dataSourceType is NOTION and not authenticated', () => { - // Arrange & Act - render() - - // Assert - NotionConnector shows sync title and connect button - expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.connect/i })).toBeInTheDocument() - }) - - it('should render NotionPageSelector when dataSourceType is NOTION and authenticated', () => { - // Arrange - const authedDataSourceList = [createMockDataSourceAuth()] - - // Act - render() - - // Assert - expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument() - }) - - it('should render Website when dataSourceType is WEB', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByTestId('website')).toBeInTheDocument() - }) - - it('should render empty dataset creation link when no datasetId', () => { - // Arrange & Act - render() - - // Assert - expect(screen.getByText('datasetCreation.stepOne.emptyDatasetCreation')).toBeInTheDocument() - }) - - it('should not render empty dataset creation link when datasetId exists', () => { - // Arrange & Act - render() - - // Assert - expect(screen.queryByText('datasetCreation.stepOne.emptyDatasetCreation')).not.toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Props Tests - // -------------------------------------------------------------------------- - describe('Props', () => { - it('should pass files to FileUploader', () => { - // Arrange - const files = [createMockFileItem()] - - // Act - render() - - // Assert - expect(screen.getByTestId('file-count')).toHaveTextContent('1') - }) - - it('should call onSetting when NotionConnector connect button is clicked', () => { - // Arrange - const onSetting = vi.fn() - render() - - // Act - The NotionConnector's button calls onSetting - fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.connect/i })) - - // Assert - expect(onSetting).toHaveBeenCalledTimes(1) - }) - - it('should call changeType when data source type is changed', () => { - // Arrange - const changeType = vi.fn() - render() - - // Act - fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) - - // Assert - expect(changeType).toHaveBeenCalledWith(DataSourceType.NOTION) - }) - }) - - // -------------------------------------------------------------------------- - // State Management Tests - // -------------------------------------------------------------------------- - describe('State Management', () => { - it('should open empty dataset modal when link is clicked', () => { - // Arrange - render() - - // Act - fireEvent.click(screen.getByText('datasetCreation.stepOne.emptyDatasetCreation')) - - // Assert - expect(screen.getByTestId('empty-dataset-modal')).toBeInTheDocument() - }) - - it('should close empty dataset modal when close is clicked', () => { - // Arrange - render() - fireEvent.click(screen.getByText('datasetCreation.stepOne.emptyDatasetCreation')) - - // Act - fireEvent.click(screen.getByTestId('close-modal')) - - // Assert - expect(screen.queryByTestId('empty-dataset-modal')).not.toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- - describe('Memoization', () => { - it('should correctly compute isNotionAuthed based on authedDataSourceList', () => { - // Arrange - No auth - const { rerender } = render() - // NotionConnector shows the sync title when not authenticated - expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() - - // Act - Add auth - const authedDataSourceList = [createMockDataSourceAuth()] - rerender() - - // Assert - expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument() - }) - - it('should correctly compute fileNextDisabled when files are empty', () => { - // Arrange & Act - render() - - // Assert - Button should be disabled - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() - }) - - it('should correctly compute fileNextDisabled when files are loaded', () => { - // Arrange - const files = [createMockFileItem()] - - // Act - render() - - // Assert - Button should be enabled - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() - }) - - it('should correctly compute fileNextDisabled when some files are not uploaded', () => { - // Arrange - Create a file item without id (not yet uploaded) - const file = new File(['test'], 'test.txt', { type: 'text/plain' }) - const fileItem: FileItem = { - fileID: 'temp-id', - file: Object.assign(file, { id: undefined, extension: 'txt', mime_type: 'text/plain' }), - progress: 0, - } - - // Act - render() - - // Assert - Button should be disabled - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() - }) - }) - - // -------------------------------------------------------------------------- - // Callback Tests - // -------------------------------------------------------------------------- - describe('Callbacks', () => { - it('should call onStepChange when next button is clicked with valid files', () => { - // Arrange - const onStepChange = vi.fn() - const files = [createMockFileItem()] - render() - - // Act - fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - - // Assert - expect(onStepChange).toHaveBeenCalledTimes(1) - }) - - it('should show plan upgrade modal when batch upload not supported and multiple files', () => { - // Arrange - mockEnableBilling = true - mockPlan.type = Plan.sandbox - const files = [createMockFileItem(), createMockFileItem()] - render() - - // Act - fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - - // Assert - expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() - }) - - it('should show upgrade card when in sandbox plan with files', () => { - // Arrange - mockEnableBilling = true - mockPlan.type = Plan.sandbox - const files = [createMockFileItem()] - - // Act - render() - - // Assert - expect(screen.getByTestId('upgrade-card')).toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Vector Space Full Tests - // -------------------------------------------------------------------------- - describe('Vector Space Full', () => { - it('should show VectorSpaceFull when vector space is full and billing is enabled', () => { - // Arrange - mockEnableBilling = true - mockPlan.usage.vectorSpace = 100 - mockPlan.total.vectorSpace = 100 - const files = [createMockFileItem()] - - // Act - render() - - // Assert - expect(screen.getByTestId('vector-space-full')).toBeInTheDocument() - }) - - it('should disable next button when vector space is full', () => { - // Arrange - mockEnableBilling = true - mockPlan.usage.vectorSpace = 100 - mockPlan.total.vectorSpace = 100 - const files = [createMockFileItem()] - - // Act - render() - - // Assert - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() - }) - }) - - // -------------------------------------------------------------------------- - // Preview Integration Tests - // -------------------------------------------------------------------------- - describe('Preview Integration', () => { - it('should show file preview when file preview button is clicked', () => { - // Arrange - render() - - // Act - fireEvent.click(screen.getByTestId('preview-file')) - - // Assert - expect(screen.getByTestId('file-preview')).toBeInTheDocument() - }) - - it('should hide file preview when hide button is clicked', () => { - // Arrange - render() - fireEvent.click(screen.getByTestId('preview-file')) - - // Act - fireEvent.click(screen.getByTestId('hide-file-preview')) - - // Assert - expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument() - }) - - it('should show notion page preview when preview button is clicked', () => { - // Arrange - const authedDataSourceList = [createMockDataSourceAuth()] - render() - - // Act - fireEvent.click(screen.getByTestId('preview-notion')) - - // Assert - expect(screen.getByTestId('notion-page-preview')).toBeInTheDocument() - }) - - it('should show website preview when preview button is clicked', () => { - // Arrange - render() - - // Act - fireEvent.click(screen.getByTestId('preview-website')) - - // Assert - Check for pagePreview title which is shown by WebsitePreview - expect(screen.getByText('datasetCreation.stepOne.pagePreview')).toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Edge Cases - // -------------------------------------------------------------------------- - describe('Edge Cases', () => { - it('should handle empty notionPages array', () => { - // Arrange - const authedDataSourceList = [createMockDataSourceAuth()] - - // Act - render() - - // Assert - Button should be disabled when no pages selected - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() - }) - - it('should handle empty websitePages array', () => { - // Arrange & Act - render() - - // Assert - Button should be disabled when no pages crawled - expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeDisabled() - }) - - it('should handle empty authedDataSourceList', () => { - // Arrange & Act - render() - - // Assert - Should show NotionConnector with connect button - expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() - }) - - it('should handle authedDataSourceList without notion credentials', () => { - // Arrange - const authedDataSourceList = [createMockDataSourceAuth({ credentials_list: [] })] - - // Act - render() - - // Assert - Should show NotionConnector with connect button - expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() - }) - - it('should clear previews when switching data source types', () => { - // Arrange - render() - fireEvent.click(screen.getByTestId('preview-file')) - expect(screen.getByTestId('file-preview')).toBeInTheDocument() - - // Act - Change to NOTION - fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')) - - // Assert - File preview should be cleared - expect(screen.queryByTestId('file-preview')).not.toBeInTheDocument() - }) - }) - - // -------------------------------------------------------------------------- - // Integration Tests - // -------------------------------------------------------------------------- - describe('Integration', () => { - it('should complete file upload flow', () => { - // Arrange - const onStepChange = vi.fn() - const files = [createMockFileItem()] - - // Act - render() - fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - - // Assert - expect(onStepChange).toHaveBeenCalled() - }) - - it('should complete notion page selection flow', () => { - // Arrange - const onStepChange = vi.fn() - const authedDataSourceList = [createMockDataSourceAuth()] - const notionPages = [createMockNotionPage()] - - // Act - render( - , - ) - fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - - // Assert - expect(onStepChange).toHaveBeenCalled() - }) - - it('should complete website crawl flow', () => { - // Arrange - const onStepChange = vi.fn() - const websitePages = [createMockCrawlResult()] - - // Act - render( - , - ) - fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - - // Assert - expect(onStepChange).toHaveBeenCalled() - }) - }) -}) diff --git a/web/app/components/datasets/create/step-three/index.spec.tsx b/web/app/components/datasets/create/step-three/__tests__/index.spec.tsx similarity index 84% rename from web/app/components/datasets/create/step-three/index.spec.tsx rename to web/app/components/datasets/create/step-three/__tests__/index.spec.tsx index 74c5912a1b..1b64aea60a 100644 --- a/web/app/components/datasets/create/step-three/index.spec.tsx +++ b/web/app/components/datasets/create/step-three/__tests__/index.spec.tsx @@ -1,10 +1,10 @@ -import type { createDocumentResponse, FullDocumentDetail, IconInfo } from '@/models/datasets' +import type { createDocumentResponse, DataSet, FullDocumentDetail, IconInfo } from '@/models/datasets' import { render, screen } from '@testing-library/react' import { RETRIEVE_METHOD } from '@/types/app' -import StepThree from './index' +import StepThree from '../index' // Mock the EmbeddingProcess component since it has complex async logic -vi.mock('../embedding-process', () => ({ +vi.mock('../../embedding-process', () => ({ default: vi.fn(({ datasetId, batchId, documents, indexingType, retrievalMethod }) => (
{datasetId} @@ -98,97 +98,74 @@ const renderStepThree = (props: Partial[0]> = {}) = return render() } -// ============================================================================ // StepThree Component Tests -// ============================================================================ describe('StepThree', () => { beforeEach(() => { vi.clearAllMocks() mockMediaType = 'pc' }) - // -------------------------------------------------------------------------- // Rendering Tests - Verify component renders properly - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act renderStepThree() - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() }) it('should render with creation title when datasetId is not provided', () => { - // Arrange & Act renderStepThree() - // Assert expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepThree.creationContent')).toBeInTheDocument() }) it('should render with addition title when datasetId is provided', () => { - // Arrange & Act renderStepThree({ datasetId: 'existing-dataset-123', datasetName: 'Existing Dataset', }) - // Assert expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument() expect(screen.queryByText('datasetCreation.stepThree.creationTitle')).not.toBeInTheDocument() }) it('should render label text in creation mode', () => { - // Arrange & Act renderStepThree() - // Assert expect(screen.getByText('datasetCreation.stepThree.label')).toBeInTheDocument() }) it('should render side tip panel on desktop', () => { - // Arrange mockMediaType = 'pc' - // Act renderStepThree() - // Assert expect(screen.getByText('datasetCreation.stepThree.sideTipTitle')).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepThree.sideTipContent')).toBeInTheDocument() expect(screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')).toBeInTheDocument() }) it('should not render side tip panel on mobile', () => { - // Arrange mockMediaType = 'mobile' - // Act renderStepThree() - // Assert expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument() expect(screen.queryByText('datasetCreation.stepThree.sideTipContent')).not.toBeInTheDocument() }) it('should render EmbeddingProcess component', () => { - // Arrange & Act renderStepThree() - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() }) it('should render documentation link with correct href on desktop', () => { - // Arrange mockMediaType = 'pc' - // Act renderStepThree() - // Assert const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore') expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/use-dify/knowledge/integrate-knowledge-within-application') expect(link).toHaveAttribute('target', '_blank') @@ -196,70 +173,53 @@ describe('StepThree', () => { }) it('should apply correct container classes', () => { - // Arrange & Act const { container } = renderStepThree() - // Assert const outerDiv = container.firstChild as HTMLElement expect(outerDiv).toHaveClass('flex', 'h-full', 'max-h-full', 'w-full', 'justify-center', 'overflow-y-auto') }) }) - // -------------------------------------------------------------------------- // Props Testing - Test all prop variations - // -------------------------------------------------------------------------- describe('Props', () => { describe('datasetId prop', () => { it('should render creation mode when datasetId is undefined', () => { - // Arrange & Act renderStepThree({ datasetId: undefined }) - // Assert expect(screen.getByText('datasetCreation.stepThree.creationTitle')).toBeInTheDocument() }) it('should render addition mode when datasetId is provided', () => { - // Arrange & Act renderStepThree({ datasetId: 'dataset-123' }) - // Assert expect(screen.getByText('datasetCreation.stepThree.additionTitle')).toBeInTheDocument() }) it('should pass datasetId to EmbeddingProcess', () => { - // Arrange const datasetId = 'my-dataset-id' - // Act renderStepThree({ datasetId }) - // Assert expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent(datasetId) }) it('should use creationCache dataset id when datasetId is not provided', () => { - // Arrange const creationCache = createMockCreationCache() - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('dataset-123') }) }) describe('datasetName prop', () => { it('should display datasetName in creation mode', () => { - // Arrange & Act renderStepThree({ datasetName: 'My Custom Dataset' }) - // Assert expect(screen.getByText('My Custom Dataset')).toBeInTheDocument() }) it('should display datasetName in addition mode description', () => { - // Arrange & Act renderStepThree({ datasetId: 'dataset-123', datasetName: 'Existing Dataset Name', @@ -271,45 +231,35 @@ describe('StepThree', () => { }) it('should fallback to creationCache dataset name when datasetName is not provided', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.dataset!.name = 'Cache Dataset Name' - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByText('Cache Dataset Name')).toBeInTheDocument() }) }) describe('indexingType prop', () => { it('should pass indexingType to EmbeddingProcess', () => { - // Arrange & Act renderStepThree({ indexingType: 'high_quality' }) - // Assert expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('high_quality') }) it('should use creationCache indexing_technique when indexingType is not provided', () => { - // Arrange const creationCache = createMockCreationCache() - creationCache.dataset!.indexing_technique = 'economy' as any + creationCache.dataset!.indexing_technique = 'economy' as unknown as DataSet['indexing_technique'] - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('economy') }) it('should prefer creationCache indexing_technique over indexingType prop', () => { - // Arrange const creationCache = createMockCreationCache() - creationCache.dataset!.indexing_technique = 'cache_technique' as any + creationCache.dataset!.indexing_technique = 'cache_technique' as unknown as DataSet['indexing_technique'] - // Act renderStepThree({ creationCache, indexingType: 'prop_technique' }) // Assert - creationCache takes precedence @@ -319,60 +269,47 @@ describe('StepThree', () => { describe('retrievalMethod prop', () => { it('should pass retrievalMethod to EmbeddingProcess', () => { - // Arrange & Act renderStepThree({ retrievalMethod: RETRIEVE_METHOD.semantic }) - // Assert expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('semantic_search') }) it('should use creationCache retrieval method when retrievalMethod is not provided', () => { - // Arrange const creationCache = createMockCreationCache() - creationCache.dataset!.retrieval_model_dict = { search_method: 'full_text_search' } as any + creationCache.dataset!.retrieval_model_dict = { search_method: 'full_text_search' } as unknown as DataSet['retrieval_model_dict'] - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('full_text_search') }) }) describe('creationCache prop', () => { it('should pass batchId from creationCache to EmbeddingProcess', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.batch = 'custom-batch-123' - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('custom-batch-123') }) it('should pass documents from creationCache to EmbeddingProcess', () => { - // Arrange const creationCache = createMockCreationCache() - creationCache.documents = [createMockDocument(), createMockDocument(), createMockDocument()] as any + creationCache.documents = [createMockDocument(), createMockDocument(), createMockDocument()] as unknown as createDocumentResponse['documents'] - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('3') }) it('should use icon_info from creationCache dataset', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.dataset!.icon_info = createMockIconInfo({ icon: '🚀', icon_background: '#FF0000', }) - // Act const { container } = renderStepThree({ creationCache }) // Assert - Check AppIcon component receives correct props @@ -381,7 +318,6 @@ describe('StepThree', () => { }) it('should handle undefined creationCache', () => { - // Arrange & Act renderStepThree({ creationCache: undefined }) // Assert - Should not crash, use fallback values @@ -390,14 +326,12 @@ describe('StepThree', () => { }) it('should handle creationCache with undefined dataset', () => { - // Arrange const creationCache: createDocumentResponse = { dataset: undefined, batch: 'batch-123', documents: [], } - // Act renderStepThree({ creationCache }) // Assert - Should use default icon info @@ -406,12 +340,9 @@ describe('StepThree', () => { }) }) - // -------------------------------------------------------------------------- // Edge Cases Tests - Test null, undefined, empty values and boundaries - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle all props being undefined', () => { - // Arrange & Act renderStepThree({ datasetId: undefined, datasetName: undefined, @@ -426,7 +357,6 @@ describe('StepThree', () => { }) it('should handle empty string datasetId', () => { - // Arrange & Act renderStepThree({ datasetId: '' }) // Assert - Empty string is falsy, should show creation mode @@ -434,7 +364,6 @@ describe('StepThree', () => { }) it('should handle empty string datasetName', () => { - // Arrange & Act renderStepThree({ datasetName: '' }) // Assert - Should not crash @@ -442,23 +371,18 @@ describe('StepThree', () => { }) it('should handle empty documents array in creationCache', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.documents = [] - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('0') }) it('should handle creationCache with missing icon_info', () => { - // Arrange const creationCache = createMockCreationCache() - creationCache.dataset!.icon_info = undefined as any + creationCache.dataset!.icon_info = undefined as unknown as IconInfo - // Act renderStepThree({ creationCache }) // Assert - Should use default icon info @@ -466,10 +390,8 @@ describe('StepThree', () => { }) it('should handle very long datasetName', () => { - // Arrange const longName = 'A'.repeat(500) - // Act renderStepThree({ datasetName: longName }) // Assert - Should render without crashing @@ -477,10 +399,8 @@ describe('StepThree', () => { }) it('should handle special characters in datasetName', () => { - // Arrange const specialName = 'Dataset & "quotes" \'apostrophe\'' - // Act renderStepThree({ datasetName: specialName }) // Assert - Should render safely as text @@ -488,22 +408,17 @@ describe('StepThree', () => { }) it('should handle unicode characters in datasetName', () => { - // Arrange const unicodeName = '数据集名称 🚀 émojis & spëcîal çhàrs' - // Act renderStepThree({ datasetName: unicodeName }) - // Assert expect(screen.getByText(unicodeName)).toBeInTheDocument() }) it('should handle creationCache with null dataset name', () => { - // Arrange const creationCache = createMockCreationCache() - creationCache.dataset!.name = null as any + creationCache.dataset!.name = null as unknown as string - // Act const { container } = renderStepThree({ creationCache }) // Assert - Should not crash @@ -511,13 +426,10 @@ describe('StepThree', () => { }) }) - // -------------------------------------------------------------------------- // Conditional Rendering Tests - Test mode switching behavior - // -------------------------------------------------------------------------- describe('Conditional Rendering', () => { describe('Creation Mode (no datasetId)', () => { it('should show AppIcon component', () => { - // Arrange & Act const { container } = renderStepThree() // Assert - AppIcon should be rendered @@ -526,7 +438,6 @@ describe('StepThree', () => { }) it('should show Divider component', () => { - // Arrange & Act const { container } = renderStepThree() // Assert - Divider should be rendered (it adds hr with specific classes) @@ -535,20 +446,16 @@ describe('StepThree', () => { }) it('should show dataset name input area', () => { - // Arrange const datasetName = 'Test Dataset Name' - // Act renderStepThree({ datasetName }) - // Assert expect(screen.getByText(datasetName)).toBeInTheDocument() }) }) describe('Addition Mode (with datasetId)', () => { it('should not show AppIcon component', () => { - // Arrange & Act renderStepThree({ datasetId: 'dataset-123' }) // Assert - Creation section should not be rendered @@ -556,7 +463,6 @@ describe('StepThree', () => { }) it('should show addition description with dataset name', () => { - // Arrange & Act renderStepThree({ datasetId: 'dataset-123', datasetName: 'My Dataset', @@ -569,10 +475,8 @@ describe('StepThree', () => { describe('Mobile vs Desktop', () => { it('should show side panel on tablet', () => { - // Arrange mockMediaType = 'tablet' - // Act renderStepThree() // Assert - Tablet is not mobile, should show side panel @@ -580,21 +484,16 @@ describe('StepThree', () => { }) it('should not show side panel on mobile', () => { - // Arrange mockMediaType = 'mobile' - // Act renderStepThree() - // Assert expect(screen.queryByText('datasetCreation.stepThree.sideTipTitle')).not.toBeInTheDocument() }) it('should render EmbeddingProcess on mobile', () => { - // Arrange mockMediaType = 'mobile' - // Act renderStepThree() // Assert - Main content should still be rendered @@ -603,64 +502,48 @@ describe('StepThree', () => { }) }) - // -------------------------------------------------------------------------- // EmbeddingProcess Integration Tests - Verify correct props are passed - // -------------------------------------------------------------------------- describe('EmbeddingProcess Integration', () => { it('should pass correct datasetId to EmbeddingProcess with datasetId prop', () => { - // Arrange & Act renderStepThree({ datasetId: 'direct-dataset-id' }) - // Assert expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('direct-dataset-id') }) it('should pass creationCache dataset id when datasetId prop is undefined', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.dataset!.id = 'cache-dataset-id' - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('cache-dataset-id') }) it('should pass empty string for datasetId when both sources are undefined', () => { - // Arrange & Act renderStepThree() - // Assert expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('') }) it('should pass batchId from creationCache', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.batch = 'test-batch-456' - // Act renderStepThree({ creationCache }) - // Assert expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('test-batch-456') }) it('should pass empty string for batchId when creationCache is undefined', () => { - // Arrange & Act renderStepThree() - // Assert expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('') }) it('should prefer datasetId prop over creationCache dataset id', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.dataset!.id = 'cache-id' - // Act renderStepThree({ datasetId: 'prop-id', creationCache }) // Assert - datasetId prop takes precedence @@ -668,12 +551,9 @@ describe('StepThree', () => { }) }) - // -------------------------------------------------------------------------- // Icon Rendering Tests - Verify AppIcon behavior - // -------------------------------------------------------------------------- describe('Icon Rendering', () => { it('should use default icon info when creationCache is undefined', () => { - // Arrange & Act const { container } = renderStepThree() // Assert - Default background color should be applied @@ -683,7 +563,6 @@ describe('StepThree', () => { }) it('should use icon_info from creationCache when available', () => { - // Arrange const creationCache = createMockCreationCache() creationCache.dataset!.icon_info = { icon: '🎉', @@ -692,7 +571,6 @@ describe('StepThree', () => { icon_url: '', } - // Act const { container } = renderStepThree({ creationCache }) // Assert - Custom background color should be applied @@ -702,11 +580,9 @@ describe('StepThree', () => { }) it('should use default icon when creationCache dataset icon_info is undefined', () => { - // Arrange const creationCache = createMockCreationCache() - delete (creationCache.dataset as any).icon_info + delete (creationCache.dataset as Partial).icon_info - // Act const { container } = renderStepThree({ creationCache }) // Assert - Component should still render with default icon @@ -714,15 +590,11 @@ describe('StepThree', () => { }) }) - // -------------------------------------------------------------------------- // Layout Tests - Verify correct CSS classes and structure - // -------------------------------------------------------------------------- describe('Layout', () => { it('should have correct outer container classes', () => { - // Arrange & Act const { container } = renderStepThree() - // Assert const outerDiv = container.firstChild as HTMLElement expect(outerDiv).toHaveClass('flex') expect(outerDiv).toHaveClass('h-full') @@ -730,49 +602,37 @@ describe('StepThree', () => { }) it('should have correct inner container classes', () => { - // Arrange & Act const { container } = renderStepThree() - // Assert const innerDiv = container.querySelector('.max-w-\\[960px\\]') expect(innerDiv).toBeInTheDocument() expect(innerDiv).toHaveClass('shrink-0', 'grow') }) it('should have content wrapper with correct max width', () => { - // Arrange & Act const { container } = renderStepThree() - // Assert const contentWrapper = container.querySelector('.max-w-\\[640px\\]') expect(contentWrapper).toBeInTheDocument() }) it('should have side tip panel with correct width on desktop', () => { - // Arrange mockMediaType = 'pc' - // Act const { container } = renderStepThree() - // Assert const sidePanel = container.querySelector('.w-\\[328px\\]') expect(sidePanel).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- // Accessibility Tests - Verify accessibility features - // -------------------------------------------------------------------------- describe('Accessibility', () => { it('should have correct link attributes for external documentation link', () => { - // Arrange mockMediaType = 'pc' - // Act renderStepThree() - // Assert const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore') expect(link.tagName).toBe('A') expect(link).toHaveAttribute('target', '_blank') @@ -780,35 +640,27 @@ describe('StepThree', () => { }) it('should have semantic heading structure in creation mode', () => { - // Arrange & Act renderStepThree() - // Assert const title = screen.getByText('datasetCreation.stepThree.creationTitle') expect(title).toBeInTheDocument() expect(title.className).toContain('title-2xl-semi-bold') }) it('should have semantic heading structure in addition mode', () => { - // Arrange & Act renderStepThree({ datasetId: 'dataset-123' }) - // Assert const title = screen.getByText('datasetCreation.stepThree.additionTitle') expect(title).toBeInTheDocument() expect(title.className).toContain('title-2xl-semi-bold') }) }) - // -------------------------------------------------------------------------- // Side Panel Tests - Verify side panel behavior - // -------------------------------------------------------------------------- describe('Side Panel', () => { it('should render RiBookOpenLine icon in side panel', () => { - // Arrange mockMediaType = 'pc' - // Act const { container } = renderStepThree() // Assert - Icon should be present in side panel @@ -817,25 +669,19 @@ describe('StepThree', () => { }) it('should have correct side panel section background', () => { - // Arrange mockMediaType = 'pc' - // Act const { container } = renderStepThree() - // Assert const sidePanel = container.querySelector('.bg-background-section') expect(sidePanel).toBeInTheDocument() }) it('should have correct padding for side panel', () => { - // Arrange mockMediaType = 'pc' - // Act const { container } = renderStepThree() - // Assert const sidePanelWrapper = container.querySelector('.pr-8') expect(sidePanelWrapper).toBeInTheDocument() }) diff --git a/web/app/components/datasets/create/step-two/index.spec.tsx b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx similarity index 81% rename from web/app/components/datasets/create/step-two/index.spec.tsx rename to web/app/components/datasets/create/step-two/__tests__/index.spec.tsx index 7145920f60..9a0a9630ea 100644 --- a/web/app/components/datasets/create/step-two/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/__tests__/index.spec.tsx @@ -10,12 +10,12 @@ import type { Rules, } from '@/models/datasets' import type { RetrievalConfig } from '@/types/app' -import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { ChunkingMode, DataSourceType, ProcessMode } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import { PreviewPanel } from './components/preview-panel' -import { StepTwoFooter } from './components/step-two-footer' +import { PreviewPanel } from '../components/preview-panel' +import { StepTwoFooter } from '../components/step-two-footer' import { DEFAULT_MAXIMUM_CHUNK_LENGTH, DEFAULT_OVERLAP, @@ -27,15 +27,11 @@ import { useIndexingEstimate, usePreviewState, useSegmentationState, -} from './hooks' -import escape from './hooks/escape' -import unescape from './hooks/unescape' +} from '../hooks' +import escape from '../hooks/escape' +import unescape from '../hooks/unescape' +import StepTwo from '../index' -// ============================================ -// Mock external dependencies -// ============================================ - -// Mock dataset detail context const mockDataset = { id: 'test-dataset-id', doc_form: ChunkingMode.text, @@ -60,10 +56,6 @@ vi.mock('@/context/dataset-detail', () => ({ selector({ dataset: mockCurrentDataset, mutateDatasetRes: mockMutateDatasetRes }), })) -// Note: @/context/i18n is globally mocked in vitest.setup.ts, no need to mock here -// Note: @/hooks/use-breakpoints uses real import - -// Mock model hooks const mockEmbeddingModelList = [ { provider: 'openai', model: 'text-embedding-ada-002' }, { provider: 'cohere', model: 'embed-english-v3.0' }, @@ -99,7 +91,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () useDefaultModel: () => ({ data: mockDefaultEmbeddingModel }), })) -// Mock service hooks const mockFetchDefaultProcessRuleMutate = vi.fn() vi.mock('@/service/knowledge/use-create-dataset', () => ({ useFetchDefaultProcessRule: ({ onSuccess }: { onSuccess: (data: { rules: Rules, limits: { indexing_max_segmentation_tokens_length: number } }) => void }) => ({ @@ -170,18 +161,55 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), })) -// Note: @/app/components/base/toast - uses real import (base component) -// Note: @/app/components/datasets/common/check-rerank-model - uses real import -// Note: @/app/components/base/float-right-container - uses real import (base component) +// Enable IS_CE_EDITION to show QA checkbox in tests +vi.mock('@/config', async () => { + const actual = await vi.importActual('@/config') + return { ...actual, IS_CE_EDITION: true } +}) + +// Mock PreviewDocumentPicker to allow testing handlePickerChange +vi.mock('@/app/components/datasets/common/document-picker/preview-document-picker', () => ({ + // eslint-disable-next-line ts/no-explicit-any + default: ({ onChange, value, files }: { onChange: (item: any) => void, value: any, files: any[] }) => ( +
+ {value?.name} + {files?.map((f: { id: string, name: string }) => ( + + ))} +
+ ), +})) -// Mock checkShowMultiModalTip - requires complex model list structure vi.mock('@/app/components/datasets/settings/utils', () => ({ checkShowMultiModalTip: () => false, })) -// ============================================ -// Test data factories -// ============================================ +// Mock complex child components to avoid deep dependency chains when rendering StepTwo +vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ + default: ({ onSelect, readonly }: { onSelect?: (val: Record) => void, readonly?: boolean }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({ + default: ({ disabled }: { disabled?: boolean }) => ( +
+ Retrieval Config +
+ ), +})) + +vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', () => ({ + default: ({ disabled }: { disabled?: boolean }) => ( +
+ Economical Config +
+ ), +})) const createMockFile = (overrides?: Partial): CustomFile => ({ id: 'file-1', @@ -248,9 +276,7 @@ const createMockEstimate = (overrides?: Partial): ...overrides, }) -// ============================================ // Utility Functions Tests (escape/unescape) -// ============================================ describe('escape utility', () => { beforeEach(() => { @@ -371,10 +397,6 @@ describe('unescape utility', () => { }) }) -// ============================================ -// useSegmentationState Hook Tests -// ============================================ - describe('useSegmentationState', () => { beforeEach(() => { vi.clearAllMocks() @@ -713,9 +735,7 @@ describe('useSegmentationState', () => { }) }) -// ============================================ // useIndexingConfig Hook Tests -// ============================================ describe('useIndexingConfig', () => { beforeEach(() => { @@ -887,9 +907,7 @@ describe('useIndexingConfig', () => { }) }) -// ============================================ // usePreviewState Hook Tests -// ============================================ describe('usePreviewState', () => { beforeEach(() => { @@ -1116,9 +1134,7 @@ describe('usePreviewState', () => { }) }) -// ============================================ // useDocumentCreation Hook Tests -// ============================================ describe('useDocumentCreation', () => { beforeEach(() => { @@ -1540,9 +1556,7 @@ describe('useDocumentCreation', () => { }) }) -// ============================================ // useIndexingEstimate Hook Tests -// ============================================ describe('useIndexingEstimate', () => { beforeEach(() => { @@ -1682,9 +1696,7 @@ describe('useIndexingEstimate', () => { }) }) -// ============================================ // StepTwoFooter Component Tests -// ============================================ describe('StepTwoFooter', () => { beforeEach(() => { @@ -1774,9 +1786,7 @@ describe('StepTwoFooter', () => { }) }) -// ============================================ // PreviewPanel Component Tests -// ============================================ describe('PreviewPanel', () => { beforeEach(() => { @@ -1955,10 +1965,6 @@ describe('PreviewPanel', () => { }) }) -// ============================================ -// Edge Cases Tests -// ============================================ - describe('Edge Cases', () => { beforeEach(() => { vi.clearAllMocks() @@ -2072,9 +2078,7 @@ describe('Edge Cases', () => { }) }) -// ============================================ // Integration Scenarios -// ============================================ describe('Integration Scenarios', () => { beforeEach(() => { @@ -2195,3 +2199,357 @@ describe('Integration Scenarios', () => { }) }) }) + +// StepTwo Component Tests + +describe('StepTwo Component', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCurrentDataset = null + }) + + afterEach(() => { + cleanup() + }) + + const defaultStepTwoProps = { + dataSourceType: DataSourceType.FILE, + files: [createMockFile()], + isAPIKeySet: true, + onSetting: vi.fn(), + notionCredentialId: '', + onStepChange: vi.fn(), + } + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + + it('should show general chunking options when not in upload', () => { + render() + // Should render the segmentation section + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + + it('should show footer with Previous and Next buttons', () => { + render() + expect(screen.getByText(/stepTwo\.previousStep/i)).toBeInTheDocument() + expect(screen.getByText(/stepTwo\.nextStep/i)).toBeInTheDocument() + }) + }) + + describe('Initialization', () => { + it('should fetch default process rule when not in setting mode', () => { + render() + expect(mockFetchDefaultProcessRuleMutate).toHaveBeenCalledWith('/datasets/process-rule') + }) + + it('should apply config from rules when in setting mode with document detail', () => { + const docDetail = createMockDocumentDetail() + render( + , + ) + // Should not fetch default rule when isSetting + expect(mockFetchDefaultProcessRuleMutate).not.toHaveBeenCalled() + }) + }) + + describe('User Interactions', () => { + it('should call onStepChange(-1) when Previous button is clicked', () => { + const onStepChange = vi.fn() + render() + fireEvent.click(screen.getByText(/stepTwo\.previousStep/i)) + expect(onStepChange).toHaveBeenCalledWith(-1) + }) + + it('should trigger handleCreate when Next Step button is clicked', async () => { + const onStepChange = vi.fn() + render() + await act(async () => { + fireEvent.click(screen.getByText(/stepTwo\.nextStep/i)) + }) + // handleCreate validates, builds params, and calls executeCreation + // which calls onStepChange(1) on success + expect(onStepChange).toHaveBeenCalledWith(1) + }) + + it('should trigger updatePreview when preview button is clicked', () => { + render() + // GeneralChunkingOptions renders a "Preview Chunk" button + const previewButtons = screen.getAllByText(/stepTwo\.previewChunk/i) + fireEvent.click(previewButtons[0]) + // updatePreview calls estimateHook.fetchEstimate() + // No error means the handler executed successfully + }) + + it('should trigger handleDocFormChange through parent-child option switch', () => { + render() + // ParentChildOptions renders an OptionCard; find the title element and click its parent card + const parentChildTitles = screen.getAllByText(/stepTwo\.parentChild/i) + // The first match is the title; click it to trigger onDocFormChange + fireEvent.click(parentChildTitles[0]) + // handleDocFormChange sets docForm, segmentationType, and resets estimate + }) + }) + + describe('Conditional Rendering', () => { + it('should show options based on currentDataset doc_form', () => { + mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.parentChild } + render( + , + ) + // When currentDataset has parentChild doc_form, should show parent-child option + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + + it('should render setting mode with Save/Cancel buttons', () => { + const docDetail = createMockDocumentDetail() + render( + , + ) + expect(screen.getByText(/stepTwo\.save/i)).toBeInTheDocument() + expect(screen.getByText(/stepTwo\.cancel/i)).toBeInTheDocument() + }) + + it('should call onCancel when Cancel button is clicked in setting mode', () => { + const onCancel = vi.fn() + const docDetail = createMockDocumentDetail() + render( + , + ) + fireEvent.click(screen.getByText(/stepTwo\.cancel/i)) + expect(onCancel).toHaveBeenCalled() + }) + + it('should trigger handleCreate (Save) in setting mode', async () => { + const onSave = vi.fn() + const docDetail = createMockDocumentDetail() + render( + , + ) + await act(async () => { + fireEvent.click(screen.getByText(/stepTwo\.save/i)) + }) + // handleCreate → validateParams → buildCreationParams → executeCreation → onSave + expect(onSave).toHaveBeenCalled() + }) + + it('should show both general and parent-child options in create page', () => { + render() + // When isInInit (no datasetId, no isSetting), both options should show + expect(screen.getByText('datasetCreation.stepTwo.general')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepTwo.parentChild')).toBeInTheDocument() + }) + + it('should only show parent-child option when dataset has parentChild doc_form', () => { + mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.parentChild } + render( + , + ) + // showGeneralOption should be false (parentChild not in [text, qa]) + // showParentChildOption should be true + expect(screen.getByText('datasetCreation.stepTwo.parentChild')).toBeInTheDocument() + }) + + it('should show general option only when dataset has text doc_form', () => { + mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.text } + render( + , + ) + // showGeneralOption should be true (text is in [text, qa]) + expect(screen.getByText('datasetCreation.stepTwo.general')).toBeInTheDocument() + }) + }) + + describe('Upload in Dataset', () => { + it('should show general option when in upload with text doc_form', () => { + mockCurrentDataset = { ...mockDataset, doc_form: ChunkingMode.text } + render( + , + ) + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + + it('should show general option for empty dataset (no doc_form)', () => { + // eslint-disable-next-line ts/no-explicit-any + mockCurrentDataset = { ...mockDataset, doc_form: undefined as any } + render( + , + ) + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + + it('should show both options in empty dataset upload', () => { + // eslint-disable-next-line ts/no-explicit-any + mockCurrentDataset = { ...mockDataset, doc_form: undefined as any } + render( + , + ) + // isUploadInEmptyDataset=true shows both options + expect(screen.getByText('datasetCreation.stepTwo.general')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepTwo.parentChild')).toBeInTheDocument() + }) + }) + + describe('Indexing Mode', () => { + it('should render indexing mode section', () => { + render() + // IndexingModeSection renders the index mode title + expect(screen.getByText(/stepTwo\.indexMode/i)).toBeInTheDocument() + }) + + it('should render embedding model selector when QUALIFIED', () => { + render() + // ModelSelector is mocked and rendered with data-testid + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + }) + + it('should render retrieval method config', () => { + render() + // RetrievalMethodConfig is mocked with data-testid + expect(screen.getByTestId('retrieval-method-config')).toBeInTheDocument() + }) + + it('should disable model and retrieval config when datasetId has existing data source', () => { + mockCurrentDataset = { ...mockDataset, data_source_type: DataSourceType.FILE } + render( + , + ) + // isModelAndRetrievalConfigDisabled should be true + const modelSelector = screen.getByTestId('model-selector') + expect(modelSelector).toHaveAttribute('data-readonly', 'true') + }) + }) + + describe('Preview Panel', () => { + it('should render preview panel', () => { + render() + expect(screen.getByText('datasetCreation.stepTwo.preview')).toBeInTheDocument() + }) + + it('should hide document picker in setting mode', () => { + const docDetail = createMockDocumentDetail() + render( + , + ) + // Preview panel should still render + expect(screen.getByText('datasetCreation.stepTwo.preview')).toBeInTheDocument() + }) + }) + + describe('Handler Functions - Uncovered Paths', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCurrentDataset = null + }) + + afterEach(() => { + cleanup() + }) + + it('should switch to QUALIFIED when selecting parentChild in ECONOMICAL mode', async () => { + render() + await vi.waitFor(() => { + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + const parentChildTitles = screen.getAllByText(/stepTwo\.parentChild/i) + fireEvent.click(parentChildTitles[0]) + }) + + it('should open QA confirm dialog and confirm switch when QA selected in ECONOMICAL mode', async () => { + render() + await vi.waitFor(() => { + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + const qaCheckbox = screen.getByText(/stepTwo\.useQALanguage/i) + fireEvent.click(qaCheckbox) + // Dialog should open → click Switch to confirm (triggers handleQAConfirm) + const switchButton = await screen.findByText(/stepTwo\.switch/i) + expect(switchButton).toBeInTheDocument() + fireEvent.click(switchButton) + }) + + it('should close QA confirm dialog when cancel is clicked', async () => { + render() + await vi.waitFor(() => { + expect(screen.getByText(/stepTwo\.segmentation/i)).toBeInTheDocument() + }) + // Open QA confirm dialog + const qaCheckbox = screen.getByText(/stepTwo\.useQALanguage/i) + fireEvent.click(qaCheckbox) + const dialogCancelButtons = await screen.findAllByText(/stepTwo\.cancel/i) + fireEvent.click(dialogCancelButtons[0]) + }) + + it('should handle picker change when selecting a different file', () => { + const files = [ + createMockFile({ id: 'file-1', name: 'first.pdf', extension: 'pdf' }), + createMockFile({ id: 'file-2', name: 'second.pdf', extension: 'pdf' }), + ] + render() + const pickerButton = screen.getByTestId('picker-file-2') + fireEvent.click(pickerButton) + }) + + it('should show error toast when preview is clicked with maxChunkLength exceeding limit', () => { + // Set a high maxChunkLength via the DOM attribute + document.body.setAttribute('data-public-indexing-max-segmentation-tokens-length', '100') + render() + // The default maxChunkLength (1024) now exceeds the limit (100) + const previewButtons = screen.getAllByText(/stepTwo\.previewChunk/i) + fireEvent.click(previewButtons[0]) + // Restore + document.body.removeAttribute('data-public-indexing-max-segmentation-tokens-length') + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx new file mode 100644 index 0000000000..8d5779fd78 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/__tests__/general-chunking-options.spec.tsx @@ -0,0 +1,168 @@ +import type { PreProcessingRule } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode } from '@/models/datasets' +import { GeneralChunkingOptions } from '../general-chunking-options' + +vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({ + default: ({ onSummaryIndexSettingChange }: { onSummaryIndexSettingChange?: (val: Record) => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/config', () => ({ + IS_CE_EDITION: true, +})) + +const ns = 'datasetCreation' + +const createRules = (): PreProcessingRule[] => [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, +] + +const defaultProps = { + segmentIdentifier: '\\n', + maxChunkLength: 500, + overlap: 50, + rules: createRules(), + currentDocForm: ChunkingMode.text, + docLanguage: 'English', + isActive: true, + isInUpload: false, + isNotUploadInEmptyDataset: false, + hasCurrentDatasetDocForm: false, + onSegmentIdentifierChange: vi.fn(), + onMaxChunkLengthChange: vi.fn(), + onOverlapChange: vi.fn(), + onRuleToggle: vi.fn(), + onDocFormChange: vi.fn(), + onDocLanguageChange: vi.fn(), + onPreview: vi.fn(), + onReset: vi.fn(), + locale: 'en', +} + +describe('GeneralChunkingOptions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render general chunking title', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.general`)).toBeInTheDocument() + }) + + it('should render delimiter, max length and overlap inputs when active', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.separator`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.maxLength`)).toBeInTheDocument() + expect(screen.getAllByText(new RegExp(`${ns}.stepTwo.overlap`)).length).toBeGreaterThan(0) + }) + + it('should render preprocessing rules as checkboxes', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.removeExtraSpaces`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.removeUrlEmails`)).toBeInTheDocument() + }) + + it('should render preview and reset buttons when active', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.previewChunk`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.reset`)).toBeInTheDocument() + }) + + it('should not render body when not active', () => { + render() + expect(screen.queryByText(`${ns}.stepTwo.separator`)).not.toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onPreview when preview button clicked', () => { + const onPreview = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.previewChunk`)) + expect(onPreview).toHaveBeenCalledOnce() + }) + + it('should call onReset when reset button clicked', () => { + const onReset = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.reset`)) + expect(onReset).toHaveBeenCalledOnce() + }) + + it('should call onRuleToggle when rule clicked', () => { + const onRuleToggle = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.removeUrlEmails`)) + expect(onRuleToggle).toHaveBeenCalledWith('remove_urls_emails') + }) + + it('should call onDocFormChange with text mode when card switched', () => { + const onDocFormChange = vi.fn() + render() + // OptionCard fires onSwitched which calls onDocFormChange(ChunkingMode.text) + // Since isActive=false, clicking the card triggers the switch + const titleEl = screen.getByText(`${ns}.stepTwo.general`) + fireEvent.click(titleEl.closest('[class*="rounded-xl"]')!) + expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.text) + }) + }) + + describe('QA Mode (CE Edition)', () => { + it('should render QA language checkbox', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.useQALanguage`)).toBeInTheDocument() + }) + + it('should toggle QA mode when checkbox clicked', () => { + const onDocFormChange = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.useQALanguage`)) + expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.qa) + }) + + it('should toggle back to text mode from QA mode', () => { + const onDocFormChange = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.useQALanguage`)) + expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.text) + }) + + it('should not toggle QA mode when hasCurrentDatasetDocForm is true', () => { + const onDocFormChange = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.useQALanguage`)) + expect(onDocFormChange).not.toHaveBeenCalled() + }) + + it('should show QA warning tip when in QA mode', () => { + render() + expect(screen.getAllByText(`${ns}.stepTwo.QATip`).length).toBeGreaterThan(0) + }) + }) + + describe('Summary Index Setting', () => { + it('should render SummaryIndexSetting when showSummaryIndexSetting is true', () => { + render() + expect(screen.getByTestId('summary-index-setting')).toBeInTheDocument() + }) + + it('should not render SummaryIndexSetting when showSummaryIndexSetting is false', () => { + render() + expect(screen.queryByTestId('summary-index-setting')).not.toBeInTheDocument() + }) + + it('should call onSummaryIndexSettingChange', () => { + const onSummaryIndexSettingChange = vi.fn() + render() + fireEvent.click(screen.getByTestId('summary-toggle')) + expect(onSummaryIndexSettingChange).toHaveBeenCalledWith({ enable: true }) + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx new file mode 100644 index 0000000000..43a944dcd4 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx @@ -0,0 +1,213 @@ +import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { RetrievalConfig } from '@/types/app' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode } from '@/models/datasets' +import { IndexingType } from '../../hooks' +import { IndexingModeSection } from '../indexing-mode-section' + +vi.mock('next/link', () => ({ + default: ({ children, href, ...props }: { children?: React.ReactNode, href?: string, className?: string }) => {children}, +})) + +// Mock external domain components +vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({ + default: ({ onChange, disabled }: { value?: RetrievalConfig, onChange?: (val: Record) => void, disabled?: boolean }) => ( +
+ +
+ ), +})) + +vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', () => ({ + default: ({ disabled }: { value?: RetrievalConfig, onChange?: (val: Record) => void, disabled?: boolean }) => ( +
+ Economical Config +
+ ), +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ + default: ({ onSelect, readonly }: { onSelect?: (val: Record) => void, readonly?: boolean }) => ( +
+ +
+ ), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, +})) + +const ns = 'datasetCreation' + +const createDefaultModel = (overrides?: Partial): DefaultModel => ({ + provider: 'openai', + model: 'text-embedding-ada-002', + ...overrides, +}) + +const createRetrievalConfig = (): RetrievalConfig => ({ + search_method: 'semantic_search' as RetrievalConfig['search_method'], + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0, +}) + +const defaultProps = { + indexType: IndexingType.QUALIFIED, + hasSetIndexType: false, + docForm: ChunkingMode.text, + embeddingModel: createDefaultModel(), + embeddingModelList: [], + retrievalConfig: createRetrievalConfig(), + showMultiModalTip: false, + isModelAndRetrievalConfigDisabled: false, + isQAConfirmDialogOpen: false, + onIndexTypeChange: vi.fn(), + onEmbeddingModelChange: vi.fn(), + onRetrievalConfigChange: vi.fn(), + onQAConfirmDialogClose: vi.fn(), + onQAConfirmDialogConfirm: vi.fn(), +} + +describe('IndexingModeSection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render index mode title', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.indexMode`)).toBeInTheDocument() + }) + + it('should render qualified option when not locked to economical', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.qualified`)).toBeInTheDocument() + }) + + it('should render economical option when not locked to qualified', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.economical`)).toBeInTheDocument() + }) + + it('should only show qualified option when hasSetIndexType and type is qualified', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.qualified`)).toBeInTheDocument() + expect(screen.queryByText(`${ns}.stepTwo.economical`)).not.toBeInTheDocument() + }) + + it('should only show economical option when hasSetIndexType and type is economical', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.economical`)).toBeInTheDocument() + expect(screen.queryByText(`${ns}.stepTwo.qualified`)).not.toBeInTheDocument() + }) + }) + + describe('Embedding Model', () => { + it('should show model selector when indexType is qualified', () => { + render() + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + }) + + it('should not show model selector when indexType is economical', () => { + render() + expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument() + }) + + it('should mark model selector as readonly when disabled', () => { + render() + expect(screen.getByTestId('model-selector')).toHaveAttribute('data-readonly', 'true') + }) + + it('should call onEmbeddingModelChange when model selected', () => { + const onEmbeddingModelChange = vi.fn() + render() + fireEvent.click(screen.getByText('Select Model')) + expect(onEmbeddingModelChange).toHaveBeenCalledWith({ provider: 'openai', model: 'text-embedding-3-small' }) + }) + }) + + describe('Retrieval Config', () => { + it('should show RetrievalMethodConfig when qualified', () => { + render() + expect(screen.getByTestId('retrieval-method-config')).toBeInTheDocument() + }) + + it('should show EconomicalRetrievalMethodConfig when economical', () => { + render() + expect(screen.getByTestId('economical-retrieval-config')).toBeInTheDocument() + }) + + it('should call onRetrievalConfigChange from qualified config', () => { + const onRetrievalConfigChange = vi.fn() + render() + fireEvent.click(screen.getByText('Change Retrieval')) + expect(onRetrievalConfigChange).toHaveBeenCalledWith({ search_method: 'updated' }) + }) + }) + + describe('Index Type Switching', () => { + it('should call onIndexTypeChange when switching to qualified', () => { + const onIndexTypeChange = vi.fn() + render() + const qualifiedCard = screen.getByText(`${ns}.stepTwo.qualified`).closest('[class*="rounded-xl"]')! + fireEvent.click(qualifiedCard) + expect(onIndexTypeChange).toHaveBeenCalledWith(IndexingType.QUALIFIED) + }) + + it('should disable economical when docForm is QA', () => { + render() + // The economical option card should have disabled styling + const economicalText = screen.getByText(`${ns}.stepTwo.economical`) + const card = economicalText.closest('[class*="rounded-xl"]') + expect(card).toHaveClass('pointer-events-none') + }) + }) + + describe('High Quality Tip', () => { + it('should show high quality tip when qualified is selected and not locked', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.highQualityTip`)).toBeInTheDocument() + }) + + it('should not show high quality tip when index type is locked', () => { + render() + expect(screen.queryByText(`${ns}.stepTwo.highQualityTip`)).not.toBeInTheDocument() + }) + }) + + describe('QA Confirm Dialog', () => { + it('should call onQAConfirmDialogClose when cancel clicked', () => { + const onClose = vi.fn() + render() + const cancelBtns = screen.getAllByText(`${ns}.stepTwo.cancel`) + fireEvent.click(cancelBtns[0]) + expect(onClose).toHaveBeenCalled() + }) + + it('should call onQAConfirmDialogConfirm when confirm clicked', () => { + const onConfirm = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.switch`)) + expect(onConfirm).toHaveBeenCalled() + }) + }) + + describe('Dataset Settings Link', () => { + it('should show settings link when economical and hasSetIndexType', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.datasetSettingLink`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.datasetSettingLink`).closest('a')).toHaveAttribute('href', '/datasets/ds-123/settings') + }) + + it('should show settings link under model selector when disabled', () => { + render() + const links = screen.getAllByText(`${ns}.stepTwo.datasetSettingLink`) + expect(links.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx new file mode 100644 index 0000000000..e48e87560c --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/__tests__/inputs.spec.tsx @@ -0,0 +1,92 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DelimiterInput, MaxLengthInput, OverlapInput } from '../inputs' + +// i18n mock returns namespaced keys like "datasetCreation.stepTwo.separator" +const ns = 'datasetCreation' + +describe('DelimiterInput', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render separator label', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.separator`)).toBeInTheDocument() + }) + + it('should render text input with placeholder', () => { + render() + const input = screen.getByPlaceholderText(`${ns}.stepTwo.separatorPlaceholder`) + expect(input).toBeInTheDocument() + expect(input).toHaveAttribute('type', 'text') + }) + + it('should pass through value and onChange props', () => { + const onChange = vi.fn() + render() + expect(screen.getByDisplayValue('test-val')).toBeInTheDocument() + }) + + it('should render tooltip content', () => { + render() + // Tooltip triggers render; component mounts without error + expect(screen.getByText(`${ns}.stepTwo.separator`)).toBeInTheDocument() + }) +}) + +describe('MaxLengthInput', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render max length label', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.maxLength`)).toBeInTheDocument() + }) + + it('should render number input', () => { + render() + const input = screen.getByRole('spinbutton') + expect(input).toBeInTheDocument() + }) + + it('should accept value prop', () => { + render() + expect(screen.getByDisplayValue('500')).toBeInTheDocument() + }) + + it('should have min of 1', () => { + render() + const input = screen.getByRole('spinbutton') + expect(input).toHaveAttribute('min', '1') + }) +}) + +describe('OverlapInput', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render overlap label', () => { + render() + expect(screen.getAllByText(new RegExp(`${ns}.stepTwo.overlap`)).length).toBeGreaterThan(0) + }) + + it('should render number input', () => { + render() + const input = screen.getByRole('spinbutton') + expect(input).toBeInTheDocument() + }) + + it('should accept value prop', () => { + render() + expect(screen.getByDisplayValue('50')).toBeInTheDocument() + }) + + it('should have min of 1', () => { + render() + const input = screen.getByRole('spinbutton') + expect(input).toHaveAttribute('min', '1') + }) +}) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx new file mode 100644 index 0000000000..e543efec86 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/__tests__/option-card.spec.tsx @@ -0,0 +1,160 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OptionCard, OptionCardHeader } from '../option-card' + +// Override global next/image auto-mock: tests assert on rendered elements +vi.mock('next/image', () => ({ + default: ({ src, alt, ...props }: { src?: string, alt?: string, width?: number, height?: number }) => ( + {alt} + ), +})) + +describe('OptionCardHeader', () => { + const defaultProps = { + icon: icon, + title: Test Title, + description: 'Test description', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render icon, title and description', () => { + render() + expect(screen.getByTestId('icon')).toBeInTheDocument() + expect(screen.getByText('Test Title')).toBeInTheDocument() + expect(screen.getByText('Test description')).toBeInTheDocument() + }) + + it('should show effect image when active and effectImg provided', () => { + const { container } = render( + , + ) + const img = container.querySelector('img') + expect(img).toBeInTheDocument() + }) + + it('should not show effect image when not active', () => { + const { container } = render( + , + ) + expect(container.querySelector('img')).not.toBeInTheDocument() + }) + + it('should apply cursor-pointer when not disabled', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('cursor-pointer') + }) + + it('should not apply cursor-pointer when disabled', () => { + const { container } = render() + expect(container.firstChild).not.toHaveClass('cursor-pointer') + }) + + it('should apply activeClassName when active', () => { + const { container } = render( + , + ) + expect(container.firstChild).toHaveClass('custom-active') + }) + + it('should not apply activeClassName when not active', () => { + const { container } = render( + , + ) + expect(container.firstChild).not.toHaveClass('custom-active') + }) +}) + +describe('OptionCard', () => { + const defaultProps = { + icon: icon, + title: Card Title as React.ReactNode, + description: 'Card description', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render header content', () => { + render() + expect(screen.getByText('Card Title')).toBeInTheDocument() + expect(screen.getByText('Card description')).toBeInTheDocument() + }) + + it('should call onSwitched when clicked while not active and not disabled', () => { + const onSwitched = vi.fn() + const { container } = render( + , + ) + fireEvent.click(container.firstChild!) + expect(onSwitched).toHaveBeenCalledOnce() + }) + + it('should not call onSwitched when already active', () => { + const onSwitched = vi.fn() + const { container } = render( + , + ) + fireEvent.click(container.firstChild!) + expect(onSwitched).not.toHaveBeenCalled() + }) + + it('should not call onSwitched when disabled', () => { + const onSwitched = vi.fn() + const { container } = render( + , + ) + fireEvent.click(container.firstChild!) + expect(onSwitched).not.toHaveBeenCalled() + }) + + it('should show children and actions when active', () => { + render( + Action}> +
Body Content
+
, + ) + expect(screen.getByText('Body Content')).toBeInTheDocument() + expect(screen.getByText('Action')).toBeInTheDocument() + }) + + it('should not show children when not active', () => { + render( + +
Body Content
+
, + ) + expect(screen.queryByText('Body Content')).not.toBeInTheDocument() + }) + + it('should apply selected border style when active and not noHighlight', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('border-components-option-card-option-selected-border') + }) + + it('should not apply selected border when noHighlight is true', () => { + const { container } = render() + expect(container.firstChild).not.toHaveClass('border-components-option-card-option-selected-border') + }) + + it('should apply disabled opacity and pointer-events styles', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('pointer-events-none') + expect(container.firstChild).toHaveClass('opacity-50') + }) + + it('should forward custom className', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('should forward custom style', () => { + const { container } = render( + , + ) + expect((container.firstChild as HTMLElement).style.maxWidth).toBe('300px') + }) +}) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/parent-child-options.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/parent-child-options.spec.tsx new file mode 100644 index 0000000000..7f33b04f48 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/__tests__/parent-child-options.spec.tsx @@ -0,0 +1,150 @@ +import type { ParentChildConfig } from '../../hooks' +import type { PreProcessingRule } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode } from '@/models/datasets' +import { ParentChildOptions } from '../parent-child-options' + +vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({ + default: ({ onSummaryIndexSettingChange }: { onSummaryIndexSettingChange?: (val: Record) => void }) => ( +
+ +
+ ), +})) + +vi.mock('@/config', () => ({ + IS_CE_EDITION: true, +})) + +const ns = 'datasetCreation' + +const createRules = (): PreProcessingRule[] => [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, +] + +const createParentChildConfig = (overrides?: Partial): ParentChildConfig => ({ + chunkForContext: 'paragraph', + parent: { delimiter: '\\n\\n', maxLength: 2000 }, + child: { delimiter: '\\n', maxLength: 500 }, + ...overrides, +}) + +const defaultProps = { + parentChildConfig: createParentChildConfig(), + rules: createRules(), + currentDocForm: ChunkingMode.parentChild, + isActive: true, + isInUpload: false, + isNotUploadInEmptyDataset: false, + onDocFormChange: vi.fn(), + onChunkForContextChange: vi.fn(), + onParentDelimiterChange: vi.fn(), + onParentMaxLengthChange: vi.fn(), + onChildDelimiterChange: vi.fn(), + onChildMaxLengthChange: vi.fn(), + onRuleToggle: vi.fn(), + onPreview: vi.fn(), + onReset: vi.fn(), +} + +describe('ParentChildOptions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render parent-child title', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.parentChild`)).toBeInTheDocument() + }) + + it('should render parent chunk context section when active', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.parentChunkForContext`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.paragraph`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.fullDoc`)).toBeInTheDocument() + }) + + it('should render child chunk retrieval section when active', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.childChunkForRetrieval`)).toBeInTheDocument() + }) + + it('should render rules section when active', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.removeExtraSpaces`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.removeUrlEmails`)).toBeInTheDocument() + }) + + it('should render preview and reset buttons when active', () => { + render() + expect(screen.getByText(`${ns}.stepTwo.previewChunk`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.stepTwo.reset`)).toBeInTheDocument() + }) + + it('should not render body when not active', () => { + render() + expect(screen.queryByText(`${ns}.stepTwo.parentChunkForContext`)).not.toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onPreview when preview button clicked', () => { + const onPreview = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.previewChunk`)) + expect(onPreview).toHaveBeenCalledOnce() + }) + + it('should call onReset when reset button clicked', () => { + const onReset = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.reset`)) + expect(onReset).toHaveBeenCalledOnce() + }) + + it('should call onRuleToggle when rule clicked', () => { + const onRuleToggle = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.removeUrlEmails`)) + expect(onRuleToggle).toHaveBeenCalledWith('remove_urls_emails') + }) + + it('should call onDocFormChange with parentChild when card switched', () => { + const onDocFormChange = vi.fn() + render() + const titleEl = screen.getByText(`${ns}.stepTwo.parentChild`) + fireEvent.click(titleEl.closest('[class*="rounded-xl"]')!) + expect(onDocFormChange).toHaveBeenCalledWith(ChunkingMode.parentChild) + }) + + it('should call onChunkForContextChange when full-doc chosen', () => { + const onChunkForContextChange = vi.fn() + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.fullDoc`)) + expect(onChunkForContextChange).toHaveBeenCalledWith('full-doc') + }) + + it('should call onChunkForContextChange when paragraph chosen', () => { + const onChunkForContextChange = vi.fn() + const config = createParentChildConfig({ chunkForContext: 'full-doc' }) + render() + fireEvent.click(screen.getByText(`${ns}.stepTwo.paragraph`)) + expect(onChunkForContextChange).toHaveBeenCalledWith('paragraph') + }) + }) + + describe('Summary Index Setting', () => { + it('should render SummaryIndexSetting when showSummaryIndexSetting is true', () => { + render() + expect(screen.getByTestId('summary-index-setting')).toBeInTheDocument() + }) + + it('should not render SummaryIndexSetting when showSummaryIndexSetting is false', () => { + render() + expect(screen.queryByTestId('summary-index-setting')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/preview-panel.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/preview-panel.spec.tsx new file mode 100644 index 0000000000..5e61b53ad7 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/__tests__/preview-panel.spec.tsx @@ -0,0 +1,166 @@ +import type { ParentChildConfig } from '../../hooks' +import type { FileIndexingEstimateResponse } from '@/models/datasets' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode, DataSourceType } from '@/models/datasets' +import { PreviewPanel } from '../preview-panel' + +vi.mock('@/app/components/base/float-right-container', () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/components/base/badge', () => ({ + default: ({ text }: { text: string }) => {text}, +})) + +vi.mock('@/app/components/base/skeleton', () => ({ + SkeletonContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, + SkeletonPoint: () => , + SkeletonRectangle: () => , + SkeletonRow: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('../../../../chunk', () => ({ + ChunkContainer: ({ children, label }: { children: React.ReactNode, label: string }) => ( +
+ {label} + : + {' '} + {children} +
+ ), + QAPreview: ({ qa }: { qa: { question: string } }) =>
{qa.question}
, +})) + +vi.mock('../../../../common/document-picker/preview-document-picker', () => ({ + default: () =>
, +})) + +vi.mock('../../../../documents/detail/completed/common/summary-label', () => ({ + default: ({ summary }: { summary: string }) => {summary}, +})) + +vi.mock('../../../../formatted-text/flavours/preview-slice', () => ({ + PreviewSlice: ({ label, text }: { label: string, text: string }) => ( + + {label} + : + {' '} + {text} + + ), +})) + +vi.mock('../../../../formatted-text/formatted', () => ({ + FormattedText: ({ children }: { children: React.ReactNode }) =>

{children}

, +})) + +vi.mock('../../../../preview/container', () => ({ + default: ({ children, header }: { children: React.ReactNode, header: React.ReactNode }) => ( +
+ {header} + {children} +
+ ), +})) + +vi.mock('../../../../preview/header', () => ({ + PreviewHeader: ({ children, title }: { children: React.ReactNode, title: string }) => ( +
+ {title} + {children} +
+ ), +})) + +vi.mock('@/config', () => ({ + FULL_DOC_PREVIEW_LENGTH: 3, +})) + +describe('PreviewPanel', () => { + const defaultProps = { + isMobile: false, + dataSourceType: DataSourceType.FILE, + currentDocForm: ChunkingMode.text, + parentChildConfig: { chunkForContext: 'paragraph' } as ParentChildConfig, + pickerFiles: [{ id: '1', name: 'file.pdf', extension: 'pdf' }], + pickerValue: { id: '1', name: 'file.pdf', extension: 'pdf' }, + isIdle: false, + isPending: false, + onPickerChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render preview header with title', () => { + render() + expect(screen.getByTestId('preview-header')).toHaveTextContent('datasetCreation.stepTwo.preview') + }) + + it('should render document picker', () => { + render() + expect(screen.getByTestId('doc-picker')).toBeInTheDocument() + }) + + it('should show idle state when isIdle is true', () => { + render() + expect(screen.getByText('datasetCreation.stepTwo.previewChunkTip')).toBeInTheDocument() + }) + + it('should show loading skeletons when isPending', () => { + render() + expect(screen.getAllByTestId('skeleton')).toHaveLength(10) + }) + + it('should render text preview chunks', () => { + const estimate: Partial = { + total_segments: 2, + preview: [ + { content: 'chunk 1 text', child_chunks: [], summary: '' }, + { content: 'chunk 2 text', child_chunks: [], summary: 'summary text' }, + ], + } + render() + expect(screen.getAllByTestId('chunk-container')).toHaveLength(2) + }) + + it('should render QA preview', () => { + const estimate: Partial = { + qa_preview: [ + { question: 'Q1', answer: 'A1' }, + ], + } + render( + , + ) + expect(screen.getByTestId('qa-preview')).toHaveTextContent('Q1') + }) + + it('should render parent-child preview', () => { + const estimate: Partial = { + preview: [ + { content: 'parent chunk', child_chunks: ['child1', 'child2'], summary: '' }, + ], + } + render( + , + ) + expect(screen.getAllByTestId('preview-slice')).toHaveLength(2) + }) + + it('should show badge with chunk count for non-QA mode', () => { + const estimate: Partial = { total_segments: 5, preview: [] } + render() + expect(screen.getByTestId('badge')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/create/step-two/components/__tests__/step-two-footer.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/step-two-footer.spec.tsx new file mode 100644 index 0000000000..ace92d3f64 --- /dev/null +++ b/web/app/components/datasets/create/step-two/components/__tests__/step-two-footer.spec.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { StepTwoFooter } from '../step-two-footer' + +describe('StepTwoFooter', () => { + const defaultProps = { + isCreating: false, + onPrevious: vi.fn(), + onCreate: vi.fn(), + onCancel: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render previous and next buttons when not isSetting', () => { + render() + expect(screen.getByText('datasetCreation.stepTwo.previousStep')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepTwo.nextStep')).toBeInTheDocument() + }) + + it('should render save and cancel buttons when isSetting', () => { + render() + expect(screen.getByText('datasetCreation.stepTwo.save')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepTwo.cancel')).toBeInTheDocument() + }) + + it('should call onPrevious on previous button click', () => { + render() + fireEvent.click(screen.getByText('datasetCreation.stepTwo.previousStep')) + expect(defaultProps.onPrevious).toHaveBeenCalledOnce() + }) + + it('should call onCreate on next button click', () => { + render() + fireEvent.click(screen.getByText('datasetCreation.stepTwo.nextStep')) + expect(defaultProps.onCreate).toHaveBeenCalledOnce() + }) + + it('should call onCancel on cancel button click in settings mode', () => { + render() + fireEvent.click(screen.getByText('datasetCreation.stepTwo.cancel')) + expect(defaultProps.onCancel).toHaveBeenCalledOnce() + }) +}) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/escape.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/escape.spec.ts new file mode 100644 index 0000000000..0f0b167822 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/escape.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import escape from '../escape' + +describe('escape', () => { + // Basic special character escaping + it('should escape null character', () => { + expect(escape('\0')).toBe('\\0') + }) + + it('should escape backspace', () => { + expect(escape('\b')).toBe('\\b') + }) + + it('should escape form feed', () => { + expect(escape('\f')).toBe('\\f') + }) + + it('should escape newline', () => { + expect(escape('\n')).toBe('\\n') + }) + + it('should escape carriage return', () => { + expect(escape('\r')).toBe('\\r') + }) + + it('should escape tab', () => { + expect(escape('\t')).toBe('\\t') + }) + + it('should escape vertical tab', () => { + expect(escape('\v')).toBe('\\v') + }) + + it('should escape single quote', () => { + expect(escape('\'')).toBe('\\\'') + }) + + // Multiple special characters in one string + it('should escape multiple special characters', () => { + expect(escape('line1\nline2\ttab')).toBe('line1\\nline2\\ttab') + }) + + it('should escape mixed special characters', () => { + expect(escape('\n\r\t')).toBe('\\n\\r\\t') + }) + + it('should return empty string for null input', () => { + expect(escape(null as unknown as string)).toBe('') + }) + + it('should return empty string for undefined input', () => { + expect(escape(undefined as unknown as string)).toBe('') + }) + + it('should return empty string for empty string input', () => { + expect(escape('')).toBe('') + }) + + it('should return empty string for non-string input', () => { + expect(escape(123 as unknown as string)).toBe('') + }) + + // Pass-through for normal strings + it('should leave normal text unchanged', () => { + expect(escape('hello world')).toBe('hello world') + }) + + it('should leave special regex characters unchanged', () => { + expect(escape('a.b*c+d')).toBe('a.b*c+d') + }) + + it('should handle strings with no special characters', () => { + expect(escape('abc123')).toBe('abc123') + }) +}) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/unescape.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/unescape.spec.ts new file mode 100644 index 0000000000..b0261e6250 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/unescape.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import unescape from '../unescape' + +describe('unescape', () => { + // Basic escape sequences + it('should unescape \\n to newline', () => { + expect(unescape('\\n')).toBe('\n') + }) + + it('should unescape \\t to tab', () => { + expect(unescape('\\t')).toBe('\t') + }) + + it('should unescape \\r to carriage return', () => { + expect(unescape('\\r')).toBe('\r') + }) + + it('should unescape \\b to backspace', () => { + expect(unescape('\\b')).toBe('\b') + }) + + it('should unescape \\f to form feed', () => { + expect(unescape('\\f')).toBe('\f') + }) + + it('should unescape \\v to vertical tab', () => { + expect(unescape('\\v')).toBe('\v') + }) + + it('should unescape \\0 to null character', () => { + expect(unescape('\\0')).toBe('\0') + }) + + it('should unescape \\\\ to backslash', () => { + expect(unescape('\\\\')).toBe('\\') + }) + + it('should unescape \\\' to single quote', () => { + expect(unescape('\\\'')).toBe('\'') + }) + + it('should unescape \\" to double quote', () => { + expect(unescape('\\"')).toBe('"') + }) + + // Hex escape sequences (\\xNN) + it('should unescape 2-digit hex sequences', () => { + expect(unescape('\\x41')).toBe('A') + expect(unescape('\\x61')).toBe('a') + }) + + // Unicode escape sequences (\\uNNNN) + it('should unescape 4-digit unicode sequences', () => { + expect(unescape('\\u0041')).toBe('A') + expect(unescape('\\u4e2d')).toBe('中') + }) + + // Variable-length unicode (\\u{NNNN}) + it('should unescape variable-length unicode sequences', () => { + expect(unescape('\\u{41}')).toBe('A') + expect(unescape('\\u{1F600}')).toBe('😀') + }) + + // Octal escape sequences + it('should unescape octal sequences', () => { + expect(unescape('\\101')).toBe('A') // 0o101 = 65 = 'A' + expect(unescape('\\12')).toBe('\n') // 0o12 = 10 = '\n' + }) + + // Python-style 8-digit unicode (\\UNNNNNNNN) + it('should unescape Python-style 8-digit unicode', () => { + expect(unescape('\\U0001F3B5')).toBe('🎵') + }) + + // Multiple escape sequences + it('should unescape multiple sequences in one string', () => { + expect(unescape('line1\\nline2\\ttab')).toBe('line1\nline2\ttab') + }) + + // Mixed content + it('should leave non-escape content unchanged', () => { + expect(unescape('hello world')).toBe('hello world') + }) + + it('should handle mixed escaped and non-escaped content', () => { + expect(unescape('before\\nafter')).toBe('before\nafter') + }) + + it('should handle empty string', () => { + expect(unescape('')).toBe('') + }) + + it('should handle string with no escape sequences', () => { + expect(unescape('abc123')).toBe('abc123') + }) +}) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts new file mode 100644 index 0000000000..74c37c876b --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-document-creation.spec.ts @@ -0,0 +1,186 @@ +import type { CustomFile, FullDocumentDetail, ProcessRule } from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode, DataSourceType } from '@/models/datasets' +import { RETRIEVE_METHOD } from '@/types/app' + +// Hoisted mocks +const mocks = vi.hoisted(() => ({ + toastNotify: vi.fn(), + mutateAsync: vi.fn(), + isReRankModelSelected: vi.fn(() => true), + trackEvent: vi.fn(), + invalidDatasetList: vi.fn(), +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: mocks.toastNotify }, +})) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: mocks.trackEvent, +})) + +vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({ + isReRankModelSelected: mocks.isReRankModelSelected, +})) + +vi.mock('@/service/knowledge/use-create-dataset', () => ({ + useCreateFirstDocument: () => ({ mutateAsync: mocks.mutateAsync, isPending: false }), + useCreateDocument: () => ({ mutateAsync: mocks.mutateAsync, isPending: false }), + getNotionInfo: vi.fn(() => []), + getWebsiteInfo: vi.fn(() => ({})), +})) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useInvalidDatasetList: () => mocks.invalidDatasetList, +})) + +const { useDocumentCreation } = await import('../use-document-creation') +const { IndexingType } = await import('../use-indexing-config') + +describe('useDocumentCreation', () => { + const defaultOptions = { + dataSourceType: DataSourceType.FILE, + files: [{ id: 'f-1', name: 'test.txt' }] as CustomFile[], + notionPages: [], + notionCredentialId: '', + websitePages: [], + } + + const defaultValidationParams = { + segmentationType: 'general', + maxChunkLength: 1024, + limitMaxChunkLength: 4000, + overlap: 50, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: 'openai', model: 'text-embedding-3-small' }, + rerankModelList: [], + retrievalConfig: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 3, + score_threshold_enabled: false, + score_threshold: 0.5, + } as RetrievalConfig, + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.isReRankModelSelected.mockReturnValue(true) + }) + + describe('validateParams', () => { + it('should return true for valid params', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + expect(result.current.validateParams(defaultValidationParams)).toBe(true) + }) + + it('should return false when overlap > maxChunkLength', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const invalid = { ...defaultValidationParams, overlap: 2000, maxChunkLength: 1000 } + expect(result.current.validateParams(invalid)).toBe(false) + expect(mocks.toastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('should return false when maxChunkLength > limitMaxChunkLength', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const invalid = { ...defaultValidationParams, maxChunkLength: 5000, limitMaxChunkLength: 4000 } + expect(result.current.validateParams(invalid)).toBe(false) + }) + + it('should return false when qualified but no embedding model', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const invalid = { + ...defaultValidationParams, + indexType: IndexingType.QUALIFIED, + embeddingModel: { provider: '', model: '' }, + } + expect(result.current.validateParams(invalid)).toBe(false) + }) + + it('should return false when rerank model not selected', () => { + mocks.isReRankModelSelected.mockReturnValue(false) + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + expect(result.current.validateParams(defaultValidationParams)).toBe(false) + }) + + it('should skip embedding/rerank checks when isSetting is true', () => { + mocks.isReRankModelSelected.mockReturnValue(false) + const { result } = renderHook(() => + useDocumentCreation({ ...defaultOptions, isSetting: true }), + ) + const params = { + ...defaultValidationParams, + embeddingModel: { provider: '', model: '' }, + } + expect(result.current.validateParams(params)).toBe(true) + }) + }) + + describe('buildCreationParams', () => { + it('should build params for FILE data source', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + const processRule = { mode: 'custom', rules: {} } as unknown as ProcessRule + const retrievalConfig = defaultValidationParams.retrievalConfig + const embeddingModel = { provider: 'openai', model: 'text-embedding-3-small' } + + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + processRule, + retrievalConfig, + embeddingModel, + 'high_quality', + ) + + expect(params).not.toBeNull() + expect(params!.data_source!.type).toBe(DataSourceType.FILE) + expect(params!.data_source!.info_list.file_info_list?.file_ids).toContain('f-1') + expect(params!.embedding_model).toBe('text-embedding-3-small') + expect(params!.embedding_model_provider).toBe('openai') + }) + + it('should build params for isSetting mode', () => { + const detail = { id: 'doc-1' } as FullDocumentDetail + const { result } = renderHook(() => + useDocumentCreation({ ...defaultOptions, isSetting: true, documentDetail: detail }), + ) + const params = result.current.buildCreationParams( + ChunkingMode.text, + 'English', + { mode: 'custom', rules: {} } as unknown as ProcessRule, + defaultValidationParams.retrievalConfig, + { provider: 'openai', model: 'text-embedding-3-small' }, + 'high_quality', + ) + + expect(params!.original_document_id).toBe('doc-1') + expect(params!.data_source).toBeUndefined() + }) + }) + + describe('validatePreviewParams', () => { + it('should return true when maxChunkLength is within limit', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + expect(result.current.validatePreviewParams(1024)).toBe(true) + }) + + it('should return false when maxChunkLength exceeds limit', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + expect(result.current.validatePreviewParams(999999)).toBe(false) + expect(mocks.toastNotify).toHaveBeenCalled() + }) + }) + + describe('isCreating', () => { + it('should reflect mutation pending state', () => { + const { result } = renderHook(() => useDocumentCreation(defaultOptions)) + expect(result.current.isCreating).toBe(false) + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts new file mode 100644 index 0000000000..1ac13aee76 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-config.spec.ts @@ -0,0 +1,161 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RETRIEVE_METHOD } from '@/types/app' + +// Hoisted mock state +const mocks = vi.hoisted(() => ({ + rerankModelList: [] as Array<{ provider: { provider: string }, model: string }>, + rerankDefaultModel: null as { provider: { provider: string }, model: string } | null, + isRerankDefaultModelValid: null as { provider: { provider: string }, model: string } | null, + embeddingModelList: [] as Array<{ provider: { provider: string }, model: string }>, + defaultEmbeddingModel: null as { provider: { provider: string }, model: string } | null, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({ + modelList: mocks.rerankModelList, + defaultModel: mocks.rerankDefaultModel, + currentModel: mocks.isRerankDefaultModelValid, + }), + useModelList: () => ({ data: mocks.embeddingModelList }), + useDefaultModel: () => ({ data: mocks.defaultEmbeddingModel }), +})) + +vi.mock('@/app/components/datasets/settings/utils', () => ({ + checkShowMultiModalTip: vi.fn(() => false), +})) + +const { IndexingType, useIndexingConfig } = await import('../use-indexing-config') + +describe('useIndexingConfig', () => { + const defaultOptions = { + isAPIKeySet: true, + hasSetIndexType: false, + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.rerankModelList = [] + mocks.rerankDefaultModel = null + mocks.isRerankDefaultModelValid = null + mocks.embeddingModelList = [] + mocks.defaultEmbeddingModel = null + }) + + describe('initial state', () => { + it('should default to QUALIFIED when API key is set', () => { + const { result } = renderHook(() => useIndexingConfig(defaultOptions)) + expect(result.current.indexType).toBe(IndexingType.QUALIFIED) + }) + + it('should default to ECONOMICAL when API key is not set', () => { + const { result } = renderHook(() => + useIndexingConfig({ ...defaultOptions, isAPIKeySet: false }), + ) + expect(result.current.indexType).toBe(IndexingType.ECONOMICAL) + }) + + it('should use initial index type when provided', () => { + const { result } = renderHook(() => + useIndexingConfig({ + ...defaultOptions, + initialIndexType: IndexingType.ECONOMICAL, + }), + ) + expect(result.current.indexType).toBe(IndexingType.ECONOMICAL) + }) + + it('should use initial embedding model when provided', () => { + const { result } = renderHook(() => + useIndexingConfig({ + ...defaultOptions, + initialEmbeddingModel: { provider: 'openai', model: 'text-embedding-3-small' }, + }), + ) + expect(result.current.embeddingModel).toEqual({ + provider: 'openai', + model: 'text-embedding-3-small', + }) + }) + + it('should use initial retrieval config when provided', () => { + const config = { + search_method: RETRIEVE_METHOD.fullText, + reranking_enable: false, + reranking_model: { reranking_provider_name: '', reranking_model_name: '' }, + top_k: 5, + score_threshold_enabled: true, + score_threshold: 0.8, + } + const { result } = renderHook(() => + useIndexingConfig({ ...defaultOptions, initialRetrievalConfig: config }), + ) + expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.fullText) + expect(result.current.retrievalConfig.top_k).toBe(5) + }) + }) + + describe('setters', () => { + it('should update index type', () => { + const { result } = renderHook(() => useIndexingConfig(defaultOptions)) + + act(() => { + result.current.setIndexType(IndexingType.ECONOMICAL) + }) + expect(result.current.indexType).toBe(IndexingType.ECONOMICAL) + }) + + it('should update embedding model', () => { + const { result } = renderHook(() => useIndexingConfig(defaultOptions)) + + act(() => { + result.current.setEmbeddingModel({ provider: 'cohere', model: 'embed-v3' }) + }) + expect(result.current.embeddingModel).toEqual({ provider: 'cohere', model: 'embed-v3' }) + }) + + it('should update retrieval config', () => { + const { result } = renderHook(() => useIndexingConfig(defaultOptions)) + const newConfig = { + ...result.current.retrievalConfig, + top_k: 10, + } + + act(() => { + result.current.setRetrievalConfig(newConfig) + }) + expect(result.current.retrievalConfig.top_k).toBe(10) + }) + }) + + describe('getIndexingTechnique', () => { + it('should return initialIndexType when provided', () => { + const { result } = renderHook(() => + useIndexingConfig({ + ...defaultOptions, + initialIndexType: IndexingType.ECONOMICAL, + }), + ) + expect(result.current.getIndexingTechnique()).toBe(IndexingType.ECONOMICAL) + }) + + it('should return current indexType when no initialIndexType', () => { + const { result } = renderHook(() => useIndexingConfig(defaultOptions)) + expect(result.current.getIndexingTechnique()).toBe(IndexingType.QUALIFIED) + }) + }) + + describe('computed properties', () => { + it('should expose hasSetIndexType from options', () => { + const { result } = renderHook(() => + useIndexingConfig({ ...defaultOptions, hasSetIndexType: true }), + ) + expect(result.current.hasSetIndexType).toBe(true) + }) + + it('should expose showMultiModalTip as boolean', () => { + const { result } = renderHook(() => useIndexingConfig(defaultOptions)) + expect(typeof result.current.showMultiModalTip).toBe('boolean') + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts new file mode 100644 index 0000000000..59676e68a8 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-indexing-estimate.spec.ts @@ -0,0 +1,127 @@ +import type { IndexingType } from '../use-indexing-config' +import type { NotionPage } from '@/models/common' +import type { ChunkingMode, CrawlResultItem, CustomFile, ProcessRule } from '@/models/datasets' +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DataSourceType } from '@/models/datasets' + +// Hoisted mocks +const mocks = vi.hoisted(() => ({ + fileMutate: vi.fn(), + fileReset: vi.fn(), + notionMutate: vi.fn(), + notionReset: vi.fn(), + webMutate: vi.fn(), + webReset: vi.fn(), +})) + +vi.mock('@/service/knowledge/use-create-dataset', () => ({ + useFetchFileIndexingEstimateForFile: () => ({ + mutate: mocks.fileMutate, + reset: mocks.fileReset, + data: { tokens: 100, total_segments: 5 }, + isIdle: true, + isPending: false, + }), + useFetchFileIndexingEstimateForNotion: () => ({ + mutate: mocks.notionMutate, + reset: mocks.notionReset, + data: null, + isIdle: true, + isPending: false, + }), + useFetchFileIndexingEstimateForWeb: () => ({ + mutate: mocks.webMutate, + reset: mocks.webReset, + data: null, + isIdle: true, + isPending: false, + }), +})) + +const { useIndexingEstimate } = await import('../use-indexing-estimate') + +describe('useIndexingEstimate', () => { + const defaultOptions = { + dataSourceType: DataSourceType.FILE, + currentDocForm: 'text_model' as ChunkingMode, + docLanguage: 'English', + files: [{ id: 'f-1', name: 'test.txt' }] as unknown as CustomFile[], + previewNotionPage: {} as unknown as NotionPage, + notionCredentialId: '', + previewWebsitePage: {} as unknown as CrawlResultItem, + indexingTechnique: 'high_quality' as unknown as IndexingType, + processRule: { mode: 'custom', rules: {} } as unknown as ProcessRule, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('currentMutation selection', () => { + it('should select file mutation for FILE type', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + expect(result.current.estimate).toEqual({ tokens: 100, total_segments: 5 }) + }) + + it('should select notion mutation for NOTION type', () => { + const { result } = renderHook(() => useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + })) + expect(result.current.estimate).toBeNull() + }) + + it('should select web mutation for WEB type', () => { + const { result } = renderHook(() => useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + })) + expect(result.current.estimate).toBeNull() + }) + }) + + describe('fetchEstimate', () => { + it('should call file mutate for FILE type', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + result.current.fetchEstimate() + expect(mocks.fileMutate).toHaveBeenCalledOnce() + }) + + it('should call notion mutate for NOTION type', () => { + const { result } = renderHook(() => useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.NOTION, + })) + result.current.fetchEstimate() + expect(mocks.notionMutate).toHaveBeenCalledOnce() + }) + + it('should call web mutate for WEB type', () => { + const { result } = renderHook(() => useIndexingEstimate({ + ...defaultOptions, + dataSourceType: DataSourceType.WEB, + })) + result.current.fetchEstimate() + expect(mocks.webMutate).toHaveBeenCalledOnce() + }) + }) + + describe('state properties', () => { + it('should expose isIdle', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + expect(result.current.isIdle).toBe(true) + }) + + it('should expose isPending', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + expect(result.current.isPending).toBe(false) + }) + + it('should expose reset function', () => { + const { result } = renderHook(() => useIndexingEstimate(defaultOptions)) + result.current.reset() + expect(mocks.fileReset).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts new file mode 100644 index 0000000000..b13dcb5327 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-preview-state.spec.ts @@ -0,0 +1,198 @@ +import type { NotionPage } from '@/models/common' +import type { CrawlResultItem, CustomFile } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DataSourceType } from '@/models/datasets' +import { usePreviewState } from '../use-preview-state' + +// Factory functions +const createFile = (id: string, name: string): CustomFile => ({ + id, + name, + size: 1024, + type: 'text/plain', + extension: 'txt', + created_by: 'user', + created_at: Date.now(), +} as unknown as CustomFile) + +const createNotionPage = (pageId: string, pageName: string): NotionPage => ({ + page_id: pageId, + page_name: pageName, + page_icon: null, + parent_id: '', + type: 'page', + is_bound: true, +} as unknown as NotionPage) + +const createWebsitePage = (url: string, title: string): CrawlResultItem => ({ + source_url: url, + title, + markdown: '', + description: '', +} as unknown as CrawlResultItem) + +describe('usePreviewState', () => { + const files = [createFile('f-1', 'file1.txt'), createFile('f-2', 'file2.txt')] + const notionPages = [createNotionPage('np-1', 'Page 1'), createNotionPage('np-2', 'Page 2')] + const websitePages = [createWebsitePage('https://a.com', 'Site A'), createWebsitePage('https://b.com', 'Site B')] + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('initial state for FILE', () => { + it('should set first file as preview', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + websitePages: [], + })) + expect(result.current.previewFile).toBe(files[0]) + }) + }) + + describe('initial state for NOTION', () => { + it('should set first notion page as preview', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages, + websitePages: [], + })) + expect(result.current.previewNotionPage).toBe(notionPages[0]) + }) + }) + + describe('initial state for WEB', () => { + it('should set first website page as preview', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.WEB, + files: [], + notionPages: [], + websitePages, + })) + expect(result.current.previewWebsitePage).toBe(websitePages[0]) + }) + }) + + describe('getPreviewPickerItems', () => { + it('should return files for FILE type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + websitePages: [], + })) + const items = result.current.getPreviewPickerItems() + expect(items).toHaveLength(2) + }) + + it('should return mapped notion pages for NOTION type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages, + websitePages: [], + })) + const items = result.current.getPreviewPickerItems() + expect(items).toHaveLength(2) + expect(items[0]).toEqual({ id: 'np-1', name: 'Page 1', extension: 'md' }) + }) + + it('should return mapped website pages for WEB type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.WEB, + files: [], + notionPages: [], + websitePages, + })) + const items = result.current.getPreviewPickerItems() + expect(items).toHaveLength(2) + expect(items[0]).toEqual({ id: 'https://a.com', name: 'Site A', extension: 'md' }) + }) + }) + + describe('getPreviewPickerValue', () => { + it('should return current preview file for FILE type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + websitePages: [], + })) + const value = result.current.getPreviewPickerValue() + expect(value).toBe(files[0]) + }) + + it('should return mapped notion page value for NOTION type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages, + websitePages: [], + })) + const value = result.current.getPreviewPickerValue() + expect(value).toEqual({ id: 'np-1', name: 'Page 1', extension: 'md' }) + }) + }) + + describe('handlePreviewChange', () => { + it('should change preview file for FILE type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.FILE, + files, + notionPages: [], + websitePages: [], + })) + + act(() => { + result.current.handlePreviewChange({ id: 'f-2', name: 'file2.txt' }) + }) + expect(result.current.previewFile).toEqual({ id: 'f-2', name: 'file2.txt' }) + }) + + it('should change preview notion page for NOTION type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages, + websitePages: [], + })) + + act(() => { + result.current.handlePreviewChange({ id: 'np-2', name: 'Page 2' }) + }) + expect(result.current.previewNotionPage).toBe(notionPages[1]) + }) + + it('should change preview website page for WEB type', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.WEB, + files: [], + notionPages: [], + websitePages, + })) + + act(() => { + result.current.handlePreviewChange({ id: 'https://b.com', name: 'Site B' }) + }) + expect(result.current.previewWebsitePage).toBe(websitePages[1]) + }) + + it('should not change if selected page not found (NOTION)', () => { + const { result } = renderHook(() => usePreviewState({ + dataSourceType: DataSourceType.NOTION, + files: [], + notionPages, + websitePages: [], + })) + + act(() => { + result.current.handlePreviewChange({ id: 'non-existent', name: 'x' }) + }) + expect(result.current.previewNotionPage).toBe(notionPages[0]) + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/hooks/__tests__/use-segmentation-state.spec.ts b/web/app/components/datasets/create/step-two/hooks/__tests__/use-segmentation-state.spec.ts new file mode 100644 index 0000000000..bdf0de31e4 --- /dev/null +++ b/web/app/components/datasets/create/step-two/hooks/__tests__/use-segmentation-state.spec.ts @@ -0,0 +1,372 @@ +import type { PreProcessingRule, Rules } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode, ProcessMode } from '@/models/datasets' +import { + DEFAULT_MAXIMUM_CHUNK_LENGTH, + DEFAULT_OVERLAP, + DEFAULT_SEGMENT_IDENTIFIER, + defaultParentChildConfig, + useSegmentationState, +} from '../use-segmentation-state' + +describe('useSegmentationState', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // --- Default state --- + describe('default state', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => useSegmentationState()) + + expect(result.current.segmentationType).toBe(ProcessMode.general) + expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER) + expect(result.current.maxChunkLength).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH) + expect(result.current.overlap).toBe(DEFAULT_OVERLAP) + expect(result.current.rules).toEqual([]) + expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig) + }) + + it('should accept initial segmentation type', () => { + const { result } = renderHook(() => + useSegmentationState({ initialSegmentationType: ProcessMode.parentChild }), + ) + expect(result.current.segmentationType).toBe(ProcessMode.parentChild) + }) + + it('should accept initial summary index setting', () => { + const setting = { enable: true } + const { result } = renderHook(() => + useSegmentationState({ initialSummaryIndexSetting: setting }), + ) + expect(result.current.summaryIndexSetting).toEqual(setting) + }) + }) + + // --- Setters --- + describe('setters', () => { + it('should update segmentation type', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentationType(ProcessMode.parentChild) + }) + expect(result.current.segmentationType).toBe(ProcessMode.parentChild) + }) + + it('should update max chunk length', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setMaxChunkLength(2048) + }) + expect(result.current.maxChunkLength).toBe(2048) + }) + + it('should update overlap', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setOverlap(100) + }) + expect(result.current.overlap).toBe(100) + }) + + it('should update rules', () => { + const newRules: PreProcessingRule[] = [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ] + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setRules(newRules) + }) + expect(result.current.rules).toEqual(newRules) + }) + }) + + // --- Segment identifier with escaping --- + describe('setSegmentIdentifier', () => { + it('should escape the value when setting', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentIdentifier('\n\n') + }) + expect(result.current.segmentIdentifier).toBe('\\n\\n') + }) + + it('should reset to default when empty and canEmpty is false', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentIdentifier('') + }) + expect(result.current.segmentIdentifier).toBe(DEFAULT_SEGMENT_IDENTIFIER) + }) + + it('should allow empty value when canEmpty is true', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setSegmentIdentifier('', true) + }) + expect(result.current.segmentIdentifier).toBe('') + }) + }) + + // --- Toggle rule --- + describe('toggleRule', () => { + it('should toggle a rule enabled state', () => { + const { result } = renderHook(() => useSegmentationState()) + const rules: PreProcessingRule[] = [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ] + + act(() => { + result.current.setRules(rules) + }) + act(() => { + result.current.toggleRule('remove_extra_spaces') + }) + + expect(result.current.rules[0].enabled).toBe(false) + expect(result.current.rules[1].enabled).toBe(false) + }) + + it('should toggle second rule without affecting first', () => { + const { result } = renderHook(() => useSegmentationState()) + const rules: PreProcessingRule[] = [ + { id: 'remove_extra_spaces', enabled: true }, + { id: 'remove_urls_emails', enabled: false }, + ] + + act(() => { + result.current.setRules(rules) + }) + act(() => { + result.current.toggleRule('remove_urls_emails') + }) + + expect(result.current.rules[0].enabled).toBe(true) + expect(result.current.rules[1].enabled).toBe(true) + }) + }) + + // --- Parent-child config --- + describe('parent-child config', () => { + it('should update parent delimiter with escaping', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateParentConfig('delimiter', '\n') + }) + expect(result.current.parentChildConfig.parent.delimiter).toBe('\\n') + }) + + it('should update parent maxLength', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateParentConfig('maxLength', 2048) + }) + expect(result.current.parentChildConfig.parent.maxLength).toBe(2048) + }) + + it('should update child delimiter with escaping', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateChildConfig('delimiter', '\t') + }) + expect(result.current.parentChildConfig.child.delimiter).toBe('\\t') + }) + + it('should update child maxLength', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateChildConfig('maxLength', 256) + }) + expect(result.current.parentChildConfig.child.maxLength).toBe(256) + }) + + it('should set empty delimiter when value is empty', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateParentConfig('delimiter', '') + }) + expect(result.current.parentChildConfig.parent.delimiter).toBe('') + }) + + it('should set chunk for context mode', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.setChunkForContext('full-doc') + }) + expect(result.current.parentChildConfig.chunkForContext).toBe('full-doc') + }) + }) + + // --- Reset to defaults --- + describe('resetToDefaults', () => { + it('should reset to default config when defaults are set', () => { + const { result } = renderHook(() => useSegmentationState()) + const defaultRules: Rules = { + pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }], + segmentation: { + separator: '---', + max_tokens: 500, + chunk_overlap: 25, + }, + parent_mode: 'paragraph', + subchunk_segmentation: { + separator: '\n', + max_tokens: 200, + }, + } + + act(() => { + result.current.setDefaultConfig(defaultRules) + }) + // Change values + act(() => { + result.current.setMaxChunkLength(2048) + result.current.setOverlap(200) + }) + act(() => { + result.current.resetToDefaults() + }) + + expect(result.current.maxChunkLength).toBe(500) + expect(result.current.overlap).toBe(25) + expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig) + }) + + it('should reset parent-child config even without default config', () => { + const { result } = renderHook(() => useSegmentationState()) + + act(() => { + result.current.updateParentConfig('maxLength', 9999) + }) + act(() => { + result.current.resetToDefaults() + }) + + expect(result.current.parentChildConfig).toEqual(defaultParentChildConfig) + }) + }) + + // --- applyConfigFromRules --- + describe('applyConfigFromRules', () => { + it('should apply general config from rules', () => { + const { result } = renderHook(() => useSegmentationState()) + const rulesConfig: Rules = { + pre_processing_rules: [{ id: 'remove_extra_spaces', enabled: true }], + segmentation: { + separator: '|||', + max_tokens: 800, + chunk_overlap: 30, + }, + parent_mode: 'paragraph', + subchunk_segmentation: { + separator: '\n', + max_tokens: 200, + }, + } + + act(() => { + result.current.applyConfigFromRules(rulesConfig, false) + }) + + expect(result.current.maxChunkLength).toBe(800) + expect(result.current.overlap).toBe(30) + expect(result.current.rules).toEqual(rulesConfig.pre_processing_rules) + }) + + it('should apply hierarchical config from rules', () => { + const { result } = renderHook(() => useSegmentationState()) + const rulesConfig: Rules = { + pre_processing_rules: [], + segmentation: { + separator: '\n\n', + max_tokens: 1024, + chunk_overlap: 50, + }, + parent_mode: 'full-doc', + subchunk_segmentation: { + separator: '\n', + max_tokens: 256, + }, + } + + act(() => { + result.current.applyConfigFromRules(rulesConfig, true) + }) + + expect(result.current.parentChildConfig.chunkForContext).toBe('full-doc') + expect(result.current.parentChildConfig.child.maxLength).toBe(256) + }) + }) + + // --- getProcessRule --- + describe('getProcessRule', () => { + it('should build general process rule', () => { + const { result } = renderHook(() => useSegmentationState()) + + const rule = result.current.getProcessRule(ChunkingMode.text) + expect(rule.mode).toBe(ProcessMode.general) + expect(rule.rules!.segmentation.max_tokens).toBe(DEFAULT_MAXIMUM_CHUNK_LENGTH) + expect(rule.rules!.segmentation.chunk_overlap).toBe(DEFAULT_OVERLAP) + }) + + it('should build parent-child process rule', () => { + const { result } = renderHook(() => useSegmentationState()) + + const rule = result.current.getProcessRule(ChunkingMode.parentChild) + expect(rule.mode).toBe('hierarchical') + expect(rule.rules!.parent_mode).toBe('paragraph') + expect(rule.rules!.subchunk_segmentation).toBeDefined() + }) + + it('should include summary index setting in process rule', () => { + const setting = { enable: true } + const { result } = renderHook(() => + useSegmentationState({ initialSummaryIndexSetting: setting }), + ) + + const rule = result.current.getProcessRule(ChunkingMode.text) + expect(rule.summary_index_setting).toEqual(setting) + }) + }) + + // --- Summary index setting --- + describe('handleSummaryIndexSettingChange', () => { + it('should update summary index setting', () => { + const { result } = renderHook(() => + useSegmentationState({ initialSummaryIndexSetting: { enable: false } }), + ) + + act(() => { + result.current.handleSummaryIndexSettingChange({ enable: true }) + }) + expect(result.current.summaryIndexSetting).toEqual({ enable: true }) + }) + + it('should merge with existing setting', () => { + const { result } = renderHook(() => + useSegmentationState({ initialSummaryIndexSetting: { enable: true } }), + ) + + act(() => { + result.current.handleSummaryIndexSettingChange({ enable: false }) + }) + expect(result.current.summaryIndexSetting?.enable).toBe(false) + }) + }) +}) diff --git a/web/app/components/datasets/create/step-two/language-select/index.spec.tsx b/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx similarity index 89% rename from web/app/components/datasets/create/step-two/language-select/index.spec.tsx rename to web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx index a2f0d96d80..759bf69f4c 100644 --- a/web/app/components/datasets/create/step-two/language-select/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/language-select/__tests__/index.spec.tsx @@ -1,8 +1,8 @@ -import type { ILanguageSelectProps } from './index' +import type { ILanguageSelectProps } from '../index' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { languages } from '@/i18n-config/language' -import LanguageSelect from './index' +import LanguageSelect from '../index' // Get supported languages for test assertions const supportedLanguages = languages.filter(lang => lang.supported) @@ -20,37 +20,27 @@ describe('LanguageSelect', () => { vi.clearAllMocks() }) - // ========================================== // Rendering Tests - Verify component renders correctly - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('English')).toBeInTheDocument() }) it('should render current language text', () => { - // Arrange const props = createDefaultProps({ currentLanguage: 'Chinese Simplified' }) - // Act render() - // Assert expect(screen.getByText('Chinese Simplified')).toBeInTheDocument() }) it('should render dropdown arrow icon', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() // Assert - RiArrowDownSLine renders as SVG @@ -59,7 +49,6 @@ describe('LanguageSelect', () => { }) it('should render all supported languages in dropdown when opened', () => { - // Arrange const props = createDefaultProps() render() @@ -75,12 +64,10 @@ describe('LanguageSelect', () => { }) it('should render check icon for selected language', () => { - // Arrange const selectedLanguage = 'Japanese' const props = createDefaultProps({ currentLanguage: selectedLanguage }) render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -91,9 +78,7 @@ describe('LanguageSelect', () => { }) }) - // ========================================== // Props Testing - Verify all prop variations work correctly - // ========================================== describe('Props', () => { describe('currentLanguage prop', () => { it('should display English when currentLanguage is English', () => { @@ -126,47 +111,36 @@ describe('LanguageSelect', () => { describe('disabled prop', () => { it('should have disabled button when disabled is true', () => { - // Arrange const props = createDefaultProps({ disabled: true }) - // Act render() - // Assert const button = screen.getByRole('button') expect(button).toBeDisabled() }) it('should have enabled button when disabled is false', () => { - // Arrange const props = createDefaultProps({ disabled: false }) - // Act render() - // Assert const button = screen.getByRole('button') expect(button).not.toBeDisabled() }) it('should have enabled button when disabled is undefined', () => { - // Arrange const props = createDefaultProps() delete (props as Partial).disabled - // Act render() - // Assert const button = screen.getByRole('button') expect(button).not.toBeDisabled() }) it('should apply disabled styling when disabled is true', () => { - // Arrange const props = createDefaultProps({ disabled: true }) - // Act const { container } = render() // Assert - Check for disabled class on text elements @@ -175,13 +149,10 @@ describe('LanguageSelect', () => { }) it('should apply cursor-not-allowed styling when disabled', () => { - // Arrange const props = createDefaultProps({ disabled: true }) - // Act const { container } = render() - // Assert const elementWithCursor = container.querySelector('.cursor-not-allowed') expect(elementWithCursor).toBeInTheDocument() }) @@ -205,16 +176,12 @@ describe('LanguageSelect', () => { }) }) - // ========================================== // User Interactions - Test event handlers - // ========================================== describe('User Interactions', () => { it('should open dropdown when button is clicked', () => { - // Arrange const props = createDefaultProps() render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -223,24 +190,20 @@ describe('LanguageSelect', () => { }) it('should call onSelect when a language option is clicked', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect }) render() - // Act const button = screen.getByRole('button') fireEvent.click(button) const frenchOption = screen.getByText('French') fireEvent.click(frenchOption) - // Assert expect(mockOnSelect).toHaveBeenCalledTimes(1) expect(mockOnSelect).toHaveBeenCalledWith('French') }) it('should call onSelect with correct language when selecting different languages', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect }) render() @@ -259,11 +222,9 @@ describe('LanguageSelect', () => { }) it('should not open dropdown when disabled', () => { - // Arrange const props = createDefaultProps({ disabled: true }) render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -273,21 +234,17 @@ describe('LanguageSelect', () => { }) it('should not call onSelect when component is disabled', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect, disabled: true }) render() - // Act const button = screen.getByRole('button') fireEvent.click(button) - // Assert expect(mockOnSelect).not.toHaveBeenCalled() }) it('should handle rapid consecutive clicks', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect }) render() @@ -303,9 +260,7 @@ describe('LanguageSelect', () => { }) }) - // ========================================== // Component Memoization - Test React.memo behavior - // ========================================== describe('Memoization', () => { it('should be wrapped with React.memo', () => { // Assert - Check component has memo wrapper @@ -313,7 +268,6 @@ describe('LanguageSelect', () => { }) it('should not re-render when props remain the same', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect }) const renderSpy = vi.fn() @@ -325,7 +279,6 @@ describe('LanguageSelect', () => { } const MemoizedTracked = React.memo(TrackedLanguageSelect) - // Act const { rerender } = render() rerender() @@ -334,43 +287,33 @@ describe('LanguageSelect', () => { }) it('should re-render when currentLanguage changes', () => { - // Arrange const props = createDefaultProps({ currentLanguage: 'English' }) - // Act const { rerender } = render() expect(screen.getByText('English')).toBeInTheDocument() rerender() - // Assert expect(screen.getByText('French')).toBeInTheDocument() }) it('should re-render when disabled changes', () => { - // Arrange const props = createDefaultProps({ disabled: false }) - // Act const { rerender } = render() expect(screen.getByRole('button')).not.toBeDisabled() rerender() - // Assert expect(screen.getByRole('button')).toBeDisabled() }) }) - // ========================================== // Edge Cases - Test boundary conditions and error handling - // ========================================== describe('Edge Cases', () => { it('should handle empty string as currentLanguage', () => { - // Arrange const props = createDefaultProps({ currentLanguage: '' }) - // Act render() // Assert - Component should still render @@ -379,10 +322,8 @@ describe('LanguageSelect', () => { }) it('should handle non-existent language as currentLanguage', () => { - // Arrange const props = createDefaultProps({ currentLanguage: 'NonExistentLanguage' }) - // Act render() // Assert - Should display the value even if not in list @@ -393,19 +334,15 @@ describe('LanguageSelect', () => { // Arrange - Turkish has special character in prompt_name const props = createDefaultProps({ currentLanguage: 'Türkçe' }) - // Act render() - // Assert expect(screen.getByText('Türkçe')).toBeInTheDocument() }) it('should handle very long language names', () => { - // Arrange const longLanguageName = 'A'.repeat(100) const props = createDefaultProps({ currentLanguage: longLanguageName }) - // Act render() // Assert - Should not crash and should display the text @@ -413,11 +350,9 @@ describe('LanguageSelect', () => { }) it('should render correct number of language options', () => { - // Arrange const props = createDefaultProps() render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -431,11 +366,9 @@ describe('LanguageSelect', () => { }) it('should only show supported languages in dropdown', () => { - // Arrange const props = createDefaultProps() render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -452,7 +385,6 @@ describe('LanguageSelect', () => { // Arrange - This tests TypeScript boundary, but runtime should not crash const props = createDefaultProps() - // Act render() const button = screen.getByRole('button') fireEvent.click(button) @@ -463,11 +395,9 @@ describe('LanguageSelect', () => { }) it('should maintain selection state visually with check icon', () => { - // Arrange const props = createDefaultProps({ currentLanguage: 'Russian' }) const { container } = render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -478,28 +408,21 @@ describe('LanguageSelect', () => { }) }) - // ========================================== // Accessibility - Basic accessibility checks - // ========================================== describe('Accessibility', () => { it('should have accessible button element', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert const button = screen.getByRole('button') expect(button).toBeInTheDocument() }) it('should have clickable language options', () => { - // Arrange const props = createDefaultProps() render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -509,16 +432,12 @@ describe('LanguageSelect', () => { }) }) - // ========================================== // Integration with Popover - Test Popover behavior - // ========================================== describe('Popover Integration', () => { it('should use manualClose prop on Popover', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect }) - // Act render() const button = screen.getByRole('button') fireEvent.click(button) @@ -528,11 +447,9 @@ describe('LanguageSelect', () => { }) it('should have correct popup z-index class', () => { - // Arrange const props = createDefaultProps() const { container } = render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -542,12 +459,9 @@ describe('LanguageSelect', () => { }) }) - // ========================================== // Styling Tests - Verify correct CSS classes applied - // ========================================== describe('Styling', () => { it('should apply tertiary button styling', () => { - // Arrange const props = createDefaultProps() const { container } = render() @@ -556,11 +470,9 @@ describe('LanguageSelect', () => { }) it('should apply hover styling class to options', () => { - // Arrange const props = createDefaultProps() const { container } = render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -570,11 +482,9 @@ describe('LanguageSelect', () => { }) it('should apply correct text styling to language options', () => { - // Arrange const props = createDefaultProps() const { container } = render() - // Act const button = screen.getByRole('button') fireEvent.click(button) @@ -584,7 +494,6 @@ describe('LanguageSelect', () => { }) it('should apply disabled styling to icon when disabled', () => { - // Arrange const props = createDefaultProps({ disabled: true }) const { container } = render() diff --git a/web/app/components/datasets/create/step-two/preview-item/index.spec.tsx b/web/app/components/datasets/create/step-two/preview-item/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/datasets/create/step-two/preview-item/index.spec.tsx rename to web/app/components/datasets/create/step-two/preview-item/__tests__/index.spec.tsx index c4cdf75480..a246293cbe 100644 --- a/web/app/components/datasets/create/step-two/preview-item/index.spec.tsx +++ b/web/app/components/datasets/create/step-two/preview-item/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ -import type { IPreviewItemProps } from './index' +import type { IPreviewItemProps } from '../index' import { render, screen } from '@testing-library/react' import * as React from 'react' -import PreviewItem, { PreviewType } from './index' +import PreviewItem, { PreviewType } from '../index' // Test data builder for props const createDefaultProps = (overrides?: Partial): IPreviewItemProps => ({ @@ -26,40 +26,29 @@ describe('PreviewItem', () => { vi.clearAllMocks() }) - // ========================================== // Rendering Tests - Verify component renders correctly - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('Test content')).toBeInTheDocument() }) it('should render with TEXT type', () => { - // Arrange const props = createDefaultProps({ content: 'Sample text content' }) - // Act render() - // Assert expect(screen.getByText('Sample text content')).toBeInTheDocument() }) it('should render with QA type', () => { - // Arrange const props = createQAProps() - // Act render() - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() expect(screen.getByText('Test question')).toBeInTheDocument() @@ -67,10 +56,8 @@ describe('PreviewItem', () => { }) it('should render sharp icon (#) with formatted index', () => { - // Arrange const props = createDefaultProps({ index: 5 }) - // Act const { container } = render() // Assert - Index should be padded to 3 digits @@ -81,11 +68,9 @@ describe('PreviewItem', () => { }) it('should render character count for TEXT type', () => { - // Arrange const content = 'Hello World' // 11 characters const props = createDefaultProps({ content }) - // Act render() // Assert - Shows character count with translation key @@ -94,7 +79,6 @@ describe('PreviewItem', () => { }) it('should render character count for QA type', () => { - // Arrange const props = createQAProps({ qa: { question: 'Hello', // 5 characters @@ -102,7 +86,6 @@ describe('PreviewItem', () => { }, }) - // Act render() // Assert - Shows combined character count @@ -110,10 +93,8 @@ describe('PreviewItem', () => { }) it('should render text icon SVG', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() // Assert - Should have SVG icons @@ -122,35 +103,27 @@ describe('PreviewItem', () => { }) }) - // ========================================== // Props Testing - Verify all prop variations work correctly - // ========================================== describe('Props', () => { describe('type prop', () => { it('should render TEXT content when type is TEXT', () => { - // Arrange const props = createDefaultProps({ type: PreviewType.TEXT, content: 'Text mode content' }) - // Act render() - // Assert expect(screen.getByText('Text mode content')).toBeInTheDocument() expect(screen.queryByText('Q')).not.toBeInTheDocument() expect(screen.queryByText('A')).not.toBeInTheDocument() }) it('should render QA content when type is QA', () => { - // Arrange const props = createQAProps({ type: PreviewType.QA, qa: { question: 'My question', answer: 'My answer' }, }) - // Act render() - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() expect(screen.getByText('My question')).toBeInTheDocument() @@ -158,24 +131,18 @@ describe('PreviewItem', () => { }) it('should use TEXT as default type when type is "text"', () => { - // Arrange const props = createDefaultProps({ type: 'text' as PreviewType, content: 'Default type content' }) - // Act render() - // Assert expect(screen.getByText('Default type content')).toBeInTheDocument() }) it('should use QA type when type is "QA"', () => { - // Arrange const props = createQAProps({ type: 'QA' as PreviewType }) - // Act render() - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() }) @@ -191,57 +158,43 @@ describe('PreviewItem', () => { [999, '999'], [1000, '1000'], ])('should format index %i as %s', (index, expected) => { - // Arrange const props = createDefaultProps({ index }) - // Act render() - // Assert expect(screen.getByText(expected)).toBeInTheDocument() }) it('should handle index 0', () => { - // Arrange const props = createDefaultProps({ index: 0 }) - // Act render() - // Assert expect(screen.getByText('000')).toBeInTheDocument() }) it('should handle large index numbers', () => { - // Arrange const props = createDefaultProps({ index: 12345 }) - // Act render() - // Assert expect(screen.getByText('12345')).toBeInTheDocument() }) }) describe('content prop', () => { it('should render content when provided', () => { - // Arrange const props = createDefaultProps({ content: 'Custom content here' }) - // Act render() - // Assert expect(screen.getByText('Custom content here')).toBeInTheDocument() }) it('should handle multiline content', () => { - // Arrange const multilineContent = 'Line 1\nLine 2\nLine 3' const props = createDefaultProps({ content: multilineContent }) - // Act const { container } = render() // Assert - Check content is rendered (multiline text is in pre-line div) @@ -252,10 +205,8 @@ describe('PreviewItem', () => { }) it('should preserve whitespace with pre-line style', () => { - // Arrange const props = createDefaultProps({ content: 'Text with spaces' }) - // Act const { container } = render() // Assert - Check for whiteSpace: pre-line style @@ -266,7 +217,6 @@ describe('PreviewItem', () => { describe('qa prop', () => { it('should render question and answer when qa is provided', () => { - // Arrange const props = createQAProps({ qa: { question: 'What is testing?', @@ -274,28 +224,22 @@ describe('PreviewItem', () => { }, }) - // Act render() - // Assert expect(screen.getByText('What is testing?')).toBeInTheDocument() expect(screen.getByText('Testing is verification.')).toBeInTheDocument() }) it('should render Q and A labels', () => { - // Arrange const props = createQAProps() - // Act render() - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() }) it('should handle multiline question', () => { - // Arrange const props = createQAProps({ qa: { question: 'Question line 1\nQuestion line 2', @@ -303,7 +247,6 @@ describe('PreviewItem', () => { }, }) - // Act const { container } = render() // Assert - Check content is in pre-line div @@ -314,7 +257,6 @@ describe('PreviewItem', () => { }) it('should handle multiline answer', () => { - // Arrange const props = createQAProps({ qa: { question: 'Question', @@ -322,7 +264,6 @@ describe('PreviewItem', () => { }, }) - // Act const { container } = render() // Assert - Check content is in pre-line div @@ -334,9 +275,7 @@ describe('PreviewItem', () => { }) }) - // ========================================== // Component Memoization - Test React.memo behavior - // ========================================== describe('Memoization', () => { it('should be wrapped with React.memo', () => { // Assert - Check component has memo wrapper @@ -344,7 +283,6 @@ describe('PreviewItem', () => { }) it('should not re-render when props remain the same', () => { - // Arrange const props = createDefaultProps() const renderSpy = vi.fn() @@ -355,7 +293,6 @@ describe('PreviewItem', () => { } const MemoizedTracked = React.memo(TrackedPreviewItem) - // Act const { rerender } = render() rerender() @@ -364,77 +301,61 @@ describe('PreviewItem', () => { }) it('should re-render when content changes', () => { - // Arrange const props = createDefaultProps({ content: 'Initial content' }) - // Act const { rerender } = render() expect(screen.getByText('Initial content')).toBeInTheDocument() rerender() - // Assert expect(screen.getByText('Updated content')).toBeInTheDocument() }) it('should re-render when index changes', () => { - // Arrange const props = createDefaultProps({ index: 1 }) - // Act const { rerender } = render() expect(screen.getByText('001')).toBeInTheDocument() rerender() - // Assert expect(screen.getByText('099')).toBeInTheDocument() }) it('should re-render when type changes', () => { - // Arrange const props = createDefaultProps({ type: PreviewType.TEXT, content: 'Text content' }) - // Act const { rerender } = render() expect(screen.getByText('Text content')).toBeInTheDocument() expect(screen.queryByText('Q')).not.toBeInTheDocument() rerender() - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() }) it('should re-render when qa prop changes', () => { - // Arrange const props = createQAProps({ qa: { question: 'Original question', answer: 'Original answer' }, }) - // Act const { rerender } = render() expect(screen.getByText('Original question')).toBeInTheDocument() rerender() - // Assert expect(screen.getByText('New question')).toBeInTheDocument() expect(screen.getByText('New answer')).toBeInTheDocument() }) }) - // ========================================== // Edge Cases - Test boundary conditions and error handling - // ========================================== describe('Edge Cases', () => { describe('Empty/Undefined values', () => { it('should handle undefined content gracefully', () => { - // Arrange const props = createDefaultProps({ content: undefined }) - // Act render() // Assert - Should show 0 characters (use more specific text match) @@ -442,10 +363,8 @@ describe('PreviewItem', () => { }) it('should handle empty string content', () => { - // Arrange const props = createDefaultProps({ content: '' }) - // Act render() // Assert - Should show 0 characters (use more specific text match) @@ -453,14 +372,12 @@ describe('PreviewItem', () => { }) it('should handle undefined qa gracefully', () => { - // Arrange const props: IPreviewItemProps = { type: PreviewType.QA, index: 1, qa: undefined, } - // Act render() // Assert - Should render Q and A labels but with empty content @@ -471,7 +388,6 @@ describe('PreviewItem', () => { }) it('should handle undefined question in qa', () => { - // Arrange const props: IPreviewItemProps = { type: PreviewType.QA, index: 1, @@ -481,15 +397,12 @@ describe('PreviewItem', () => { }, } - // Act render() - // Assert expect(screen.getByText('Only answer')).toBeInTheDocument() }) it('should handle undefined answer in qa', () => { - // Arrange const props: IPreviewItemProps = { type: PreviewType.QA, index: 1, @@ -499,20 +412,16 @@ describe('PreviewItem', () => { }, } - // Act render() - // Assert expect(screen.getByText('Only question')).toBeInTheDocument() }) it('should handle empty question and answer strings', () => { - // Arrange const props = createQAProps({ qa: { question: '', answer: '' }, }) - // Act render() // Assert - Should show 0 characters (use more specific text match) @@ -527,10 +436,8 @@ describe('PreviewItem', () => { // Arrange - 'Test' has 4 characters const props = createDefaultProps({ content: 'Test' }) - // Act render() - // Assert expect(screen.getByText(/4/)).toBeInTheDocument() }) @@ -540,10 +447,8 @@ describe('PreviewItem', () => { qa: { question: 'ABC', answer: 'DEFGH' }, }) - // Act render() - // Assert expect(screen.getByText(/8/)).toBeInTheDocument() }) @@ -551,10 +456,8 @@ describe('PreviewItem', () => { // Arrange - Content with special characters const props = createDefaultProps({ content: '你好世界' }) // 4 Chinese characters - // Act render() - // Assert expect(screen.getByText(/4/)).toBeInTheDocument() }) @@ -562,10 +465,8 @@ describe('PreviewItem', () => { // Arrange - 'a\nb' has 3 characters const props = createDefaultProps({ content: 'a\nb' }) - // Act render() - // Assert expect(screen.getByText(/3/)).toBeInTheDocument() }) @@ -573,21 +474,17 @@ describe('PreviewItem', () => { // Arrange - 'a b' has 3 characters const props = createDefaultProps({ content: 'a b' }) - // Act render() - // Assert expect(screen.getByText(/3/)).toBeInTheDocument() }) }) describe('Boundary conditions', () => { it('should handle very long content', () => { - // Arrange const longContent = 'A'.repeat(10000) const props = createDefaultProps({ content: longContent }) - // Act render() // Assert - Should show correct character count @@ -595,21 +492,16 @@ describe('PreviewItem', () => { }) it('should handle very long index', () => { - // Arrange const props = createDefaultProps({ index: 999999999 }) - // Act render() - // Assert expect(screen.getByText('999999999')).toBeInTheDocument() }) it('should handle negative index', () => { - // Arrange const props = createDefaultProps({ index: -1 }) - // Act render() // Assert - padStart pads from the start, so -1 becomes 0-1 @@ -617,21 +509,16 @@ describe('PreviewItem', () => { }) it('should handle content with only whitespace', () => { - // Arrange const props = createDefaultProps({ content: ' ' }) // 3 spaces - // Act render() - // Assert expect(screen.getByText(/3/)).toBeInTheDocument() }) it('should handle content with HTML-like characters', () => { - // Arrange const props = createDefaultProps({ content: '
Test
' }) - // Act render() // Assert - Should render as text, not HTML @@ -642,7 +529,6 @@ describe('PreviewItem', () => { // Arrange - Emojis can have complex character lengths const props = createDefaultProps({ content: '😀👍' }) - // Act render() // Assert - Emoji length depends on JS string length @@ -660,17 +546,14 @@ describe('PreviewItem', () => { qa: { question: 'Should not show', answer: 'Also should not show' }, } - // Act render() - // Assert expect(screen.getByText('Text content')).toBeInTheDocument() expect(screen.queryByText('Should not show')).not.toBeInTheDocument() expect(screen.queryByText('Also should not show')).not.toBeInTheDocument() }) it('should use content length for TEXT type even when qa is provided', () => { - // Arrange const props: IPreviewItemProps = { type: PreviewType.TEXT, index: 1, @@ -678,7 +561,6 @@ describe('PreviewItem', () => { qa: { question: 'Question', answer: 'Answer' }, // Would be 14 characters if used } - // Act render() // Assert - Should show 2, not 14 @@ -686,7 +568,6 @@ describe('PreviewItem', () => { }) it('should ignore content prop when type is QA', () => { - // Arrange const props: IPreviewItemProps = { type: PreviewType.QA, index: 1, @@ -694,10 +575,8 @@ describe('PreviewItem', () => { qa: { question: 'Q text', answer: 'A text' }, } - // Act render() - // Assert expect(screen.queryByText('Should not display')).not.toBeInTheDocument() expect(screen.getByText('Q text')).toBeInTheDocument() expect(screen.getByText('A text')).toBeInTheDocument() @@ -705,9 +584,7 @@ describe('PreviewItem', () => { }) }) - // ========================================== // PreviewType Enum - Test exported enum values - // ========================================== describe('PreviewType Enum', () => { it('should have TEXT value as "text"', () => { expect(PreviewType.TEXT).toBe('text') @@ -718,27 +595,20 @@ describe('PreviewItem', () => { }) }) - // ========================================== // Styling Tests - Verify correct CSS classes applied - // ========================================== describe('Styling', () => { it('should have rounded container with gray background', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() - // Assert const rootDiv = container.firstChild as HTMLElement expect(rootDiv).toHaveClass('rounded-xl', 'bg-gray-50', 'p-4') }) it('should have proper header styling', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() // Assert - Check header div styling @@ -747,53 +617,40 @@ describe('PreviewItem', () => { }) it('should have index badge styling', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() - // Assert const indexBadge = container.querySelector('.border.border-gray-200') expect(indexBadge).toBeInTheDocument() expect(indexBadge).toHaveClass('rounded-md', 'italic', 'font-medium') }) it('should have content area with line-clamp', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() - // Assert const contentArea = container.querySelector('.line-clamp-6') expect(contentArea).toBeInTheDocument() expect(contentArea).toHaveClass('max-h-[120px]', 'overflow-hidden') }) it('should have Q/A labels with gray color', () => { - // Arrange const props = createQAProps() - // Act const { container } = render() - // Assert const labels = container.querySelectorAll('.text-gray-400') expect(labels.length).toBeGreaterThanOrEqual(2) // Q and A labels }) }) - // ========================================== // i18n Translation - Test translation integration - // ========================================== describe('i18n Translation', () => { it('should use translation key for characters label', () => { - // Arrange const props = createDefaultProps({ content: 'Test' }) - // Act render() // Assert - The mock returns the key as-is diff --git a/web/app/components/datasets/create/stepper/index.spec.tsx b/web/app/components/datasets/create/stepper/__tests__/index.spec.tsx similarity index 82% rename from web/app/components/datasets/create/stepper/index.spec.tsx rename to web/app/components/datasets/create/stepper/__tests__/index.spec.tsx index 3a66a5f8f4..a3cf5742b8 100644 --- a/web/app/components/datasets/create/stepper/index.spec.tsx +++ b/web/app/components/datasets/create/stepper/__tests__/index.spec.tsx @@ -1,8 +1,8 @@ -import type { StepperProps } from './index' -import type { Step, StepperStepProps } from './step' +import type { StepperProps } from '../index' +import type { Step, StepperStepProps } from '../step' import { render, screen } from '@testing-library/react' -import { Stepper } from './index' -import { StepperStep } from './step' +import { Stepper } from '../index' +import { StepperStep } from '../step' // Test data factory for creating steps const createStep = (overrides: Partial = {}): Step => ({ @@ -34,44 +34,33 @@ const renderStepperStep = (props: Partial = {}) => { return render() } -// ============================================================================ // Stepper Component Tests -// ============================================================================ describe('Stepper', () => { beforeEach(() => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- // Rendering Tests - Verify component renders properly with various inputs - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act renderStepper() - // Assert expect(screen.getByText('Step 1')).toBeInTheDocument() }) it('should render all step names', () => { - // Arrange const steps = createSteps(3, 'Custom Step') - // Act renderStepper({ steps }) - // Assert expect(screen.getByText('Custom Step 1')).toBeInTheDocument() expect(screen.getByText('Custom Step 2')).toBeInTheDocument() expect(screen.getByText('Custom Step 3')).toBeInTheDocument() }) it('should render dividers between steps', () => { - // Arrange const steps = createSteps(3) - // Act const { container } = renderStepper({ steps }) // Assert - Should have 2 dividers for 3 steps @@ -80,10 +69,8 @@ describe('Stepper', () => { }) it('should not render divider after last step', () => { - // Arrange const steps = createSteps(2) - // Act const { container } = renderStepper({ steps }) // Assert - Should have 1 divider for 2 steps @@ -92,28 +79,21 @@ describe('Stepper', () => { }) it('should render with flex container layout', () => { - // Arrange & Act const { container } = renderStepper() - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex', 'items-center', 'gap-3') }) }) - // -------------------------------------------------------------------------- // Props Testing - Test all prop variations and combinations - // -------------------------------------------------------------------------- describe('Props', () => { describe('steps prop', () => { it('should render correct number of steps', () => { - // Arrange const steps = createSteps(5) - // Act renderStepper({ steps }) - // Assert expect(screen.getByText('Step 1')).toBeInTheDocument() expect(screen.getByText('Step 2')).toBeInTheDocument() expect(screen.getByText('Step 3')).toBeInTheDocument() @@ -122,13 +102,10 @@ describe('Stepper', () => { }) it('should handle single step correctly', () => { - // Arrange const steps = [createStep({ name: 'Only Step' })] - // Act const { container } = renderStepper({ steps, activeIndex: 0 }) - // Assert expect(screen.getByText('Only Step')).toBeInTheDocument() // No dividers for single step const dividers = container.querySelectorAll('.bg-divider-deep') @@ -136,29 +113,23 @@ describe('Stepper', () => { }) it('should handle steps with long names', () => { - // Arrange const longName = 'This is a very long step name that might overflow' const steps = [createStep({ name: longName })] - // Act renderStepper({ steps, activeIndex: 0 }) - // Assert expect(screen.getByText(longName)).toBeInTheDocument() }) it('should handle steps with special characters', () => { - // Arrange const steps = [ createStep({ name: 'Step & Configuration' }), createStep({ name: 'Step ' }), createStep({ name: 'Step "Complete"' }), ] - // Act renderStepper({ steps, activeIndex: 0 }) - // Assert expect(screen.getByText('Step & Configuration')).toBeInTheDocument() expect(screen.getByText('Step ')).toBeInTheDocument() expect(screen.getByText('Step "Complete"')).toBeInTheDocument() @@ -167,7 +138,6 @@ describe('Stepper', () => { describe('activeIndex prop', () => { it('should highlight first step when activeIndex is 0', () => { - // Arrange & Act renderStepper({ activeIndex: 0 }) // Assert - First step should show "STEP 1" label @@ -175,7 +145,6 @@ describe('Stepper', () => { }) it('should highlight second step when activeIndex is 1', () => { - // Arrange & Act renderStepper({ activeIndex: 1 }) // Assert - Second step should show "STEP 2" label @@ -183,10 +152,8 @@ describe('Stepper', () => { }) it('should highlight last step when activeIndex equals steps length - 1', () => { - // Arrange const steps = createSteps(3) - // Act renderStepper({ steps, activeIndex: 2 }) // Assert - Third step should show "STEP 3" label @@ -194,10 +161,8 @@ describe('Stepper', () => { }) it('should show completed steps with number only (no STEP prefix)', () => { - // Arrange const steps = createSteps(3) - // Act renderStepper({ steps, activeIndex: 2 }) // Assert - Completed steps show just the number @@ -207,10 +172,8 @@ describe('Stepper', () => { }) it('should show disabled steps with number only (no STEP prefix)', () => { - // Arrange const steps = createSteps(3) - // Act renderStepper({ steps, activeIndex: 0 }) // Assert - Disabled steps show just the number @@ -221,12 +184,9 @@ describe('Stepper', () => { }) }) - // -------------------------------------------------------------------------- // Edge Cases - Test boundary conditions and unexpected inputs - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle empty steps array', () => { - // Arrange & Act const { container } = renderStepper({ steps: [] }) // Assert - Container should render but be empty @@ -235,7 +195,6 @@ describe('Stepper', () => { }) it('should handle activeIndex greater than steps length', () => { - // Arrange const steps = createSteps(2) // Act - activeIndex 5 is beyond array bounds @@ -247,7 +206,6 @@ describe('Stepper', () => { }) it('should handle negative activeIndex', () => { - // Arrange const steps = createSteps(2) // Act - negative activeIndex @@ -259,13 +217,10 @@ describe('Stepper', () => { }) it('should handle large number of steps', () => { - // Arrange const steps = createSteps(10) - // Act const { container } = renderStepper({ steps, activeIndex: 5 }) - // Assert expect(screen.getByText('STEP 6')).toBeInTheDocument() // Should have 9 dividers for 10 steps const dividers = container.querySelectorAll('.bg-divider-deep') @@ -273,10 +228,8 @@ describe('Stepper', () => { }) it('should handle steps with empty name', () => { - // Arrange const steps = [createStep({ name: '' })] - // Act const { container } = renderStepper({ steps, activeIndex: 0 }) // Assert - Should still render the step structure @@ -285,18 +238,13 @@ describe('Stepper', () => { }) }) - // -------------------------------------------------------------------------- // Integration - Test step state combinations - // -------------------------------------------------------------------------- describe('Step States', () => { it('should render mixed states: completed, active, disabled', () => { - // Arrange const steps = createSteps(5) - // Act renderStepper({ steps, activeIndex: 2 }) - // Assert // Steps 1-2 are completed (show number only) expect(screen.getByText('1')).toBeInTheDocument() expect(screen.getByText('2')).toBeInTheDocument() @@ -308,7 +256,6 @@ describe('Stepper', () => { }) it('should transition through all states correctly', () => { - // Arrange const steps = createSteps(3) // Act & Assert - Step 1 active @@ -329,80 +276,59 @@ describe('Stepper', () => { }) }) -// ============================================================================ // StepperStep Component Tests -// ============================================================================ describe('StepperStep', () => { beforeEach(() => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act renderStepperStep() - // Assert expect(screen.getByText('Test Step')).toBeInTheDocument() }) it('should render step name', () => { - // Arrange & Act renderStepperStep({ name: 'Configure Dataset' }) - // Assert expect(screen.getByText('Configure Dataset')).toBeInTheDocument() }) it('should render with flex container layout', () => { - // Arrange & Act const { container } = renderStepperStep() - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex', 'items-center', 'gap-2') }) }) - // -------------------------------------------------------------------------- // Active State Tests - // -------------------------------------------------------------------------- describe('Active State', () => { it('should show STEP prefix when active', () => { - // Arrange & Act renderStepperStep({ index: 0, activeIndex: 0 }) - // Assert expect(screen.getByText('STEP 1')).toBeInTheDocument() }) it('should apply active styles to label container', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 0, activeIndex: 0 }) - // Assert const labelContainer = container.querySelector('.bg-state-accent-solid') expect(labelContainer).toBeInTheDocument() expect(labelContainer).toHaveClass('px-2') }) it('should apply active text color to label', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 0, activeIndex: 0 }) - // Assert const label = container.querySelector('.text-text-primary-on-surface') expect(label).toBeInTheDocument() }) it('should apply accent text color to name when active', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 0, activeIndex: 0 }) - // Assert const nameElement = container.querySelector('.text-text-accent') expect(nameElement).toBeInTheDocument() expect(nameElement).toHaveClass('system-xs-semibold-uppercase') @@ -421,105 +347,79 @@ describe('StepperStep', () => { }) }) - // -------------------------------------------------------------------------- // Completed State Tests (index < activeIndex) - // -------------------------------------------------------------------------- describe('Completed State', () => { it('should show number only when completed (not active)', () => { - // Arrange & Act renderStepperStep({ index: 0, activeIndex: 1 }) - // Assert expect(screen.getByText('1')).toBeInTheDocument() expect(screen.queryByText('STEP 1')).not.toBeInTheDocument() }) it('should apply completed styles to label container', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 0, activeIndex: 1 }) - // Assert const labelContainer = container.querySelector('.border-text-quaternary') expect(labelContainer).toBeInTheDocument() expect(labelContainer).toHaveClass('w-5') }) it('should apply tertiary text color to label when completed', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 0, activeIndex: 1 }) - // Assert const label = container.querySelector('.text-text-tertiary') expect(label).toBeInTheDocument() }) it('should apply tertiary text color to name when completed', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 0, activeIndex: 2 }) - // Assert const nameElements = container.querySelectorAll('.text-text-tertiary') expect(nameElements.length).toBeGreaterThan(0) }) }) - // -------------------------------------------------------------------------- // Disabled State Tests (index > activeIndex) - // -------------------------------------------------------------------------- describe('Disabled State', () => { it('should show number only when disabled', () => { - // Arrange & Act renderStepperStep({ index: 2, activeIndex: 0 }) - // Assert expect(screen.getByText('3')).toBeInTheDocument() expect(screen.queryByText('STEP 3')).not.toBeInTheDocument() }) it('should apply disabled styles to label container', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 2, activeIndex: 0 }) - // Assert const labelContainer = container.querySelector('.border-divider-deep') expect(labelContainer).toBeInTheDocument() expect(labelContainer).toHaveClass('w-5') }) it('should apply quaternary text color to label when disabled', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 2, activeIndex: 0 }) - // Assert const label = container.querySelector('.text-text-quaternary') expect(label).toBeInTheDocument() }) it('should apply quaternary text color to name when disabled', () => { - // Arrange & Act const { container } = renderStepperStep({ index: 2, activeIndex: 0 }) - // Assert const nameElements = container.querySelectorAll('.text-text-quaternary') expect(nameElements.length).toBeGreaterThan(0) }) }) - // -------------------------------------------------------------------------- - // Props Testing - // -------------------------------------------------------------------------- describe('Props', () => { describe('name prop', () => { it('should render provided name', () => { - // Arrange & Act renderStepperStep({ name: 'Custom Name' }) - // Assert expect(screen.getByText('Custom Name')).toBeInTheDocument() }) it('should handle empty name', () => { - // Arrange & Act const { container } = renderStepperStep({ name: '' }) // Assert - Label should still render @@ -528,36 +428,28 @@ describe('StepperStep', () => { }) it('should handle name with whitespace', () => { - // Arrange & Act renderStepperStep({ name: ' Padded Name ' }) - // Assert expect(screen.getByText('Padded Name')).toBeInTheDocument() }) }) describe('index prop', () => { it('should display correct 1-based number for index 0', () => { - // Arrange & Act renderStepperStep({ index: 0, activeIndex: 0 }) - // Assert expect(screen.getByText('STEP 1')).toBeInTheDocument() }) it('should display correct 1-based number for index 9', () => { - // Arrange & Act renderStepperStep({ index: 9, activeIndex: 9 }) - // Assert expect(screen.getByText('STEP 10')).toBeInTheDocument() }) it('should handle large index values', () => { - // Arrange & Act renderStepperStep({ index: 99, activeIndex: 99 }) - // Assert expect(screen.getByText('STEP 100')).toBeInTheDocument() }) }) @@ -581,20 +473,14 @@ describe('StepperStep', () => { }) }) - // -------------------------------------------------------------------------- - // Edge Cases - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle zero index correctly', () => { - // Arrange & Act renderStepperStep({ index: 0, activeIndex: 0 }) - // Assert expect(screen.getByText('STEP 1')).toBeInTheDocument() }) it('should handle negative activeIndex', () => { - // Arrange & Act renderStepperStep({ index: 0, activeIndex: -1 }) // Assert - Step should be disabled (index > activeIndex) @@ -602,7 +488,6 @@ describe('StepperStep', () => { }) it('should handle equal boundary (index equals activeIndex)', () => { - // Arrange & Act renderStepperStep({ index: 5, activeIndex: 5 }) // Assert - Should be active @@ -610,7 +495,6 @@ describe('StepperStep', () => { }) it('should handle name with HTML-like content safely', () => { - // Arrange & Act renderStepperStep({ name: '' }) // Assert - Should render as text, not execute @@ -618,73 +502,57 @@ describe('StepperStep', () => { }) it('should handle name with unicode characters', () => { - // Arrange & Act renderStepperStep({ name: 'Step 数据 🚀' }) - // Assert expect(screen.getByText('Step 数据 🚀')).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- // Style Classes Verification - // -------------------------------------------------------------------------- describe('Style Classes', () => { it('should apply correct typography classes to label', () => { - // Arrange & Act const { container } = renderStepperStep() - // Assert const label = container.querySelector('.system-2xs-semibold-uppercase') expect(label).toBeInTheDocument() }) it('should apply correct typography classes to name', () => { - // Arrange & Act const { container } = renderStepperStep() - // Assert const name = container.querySelector('.system-xs-medium-uppercase') expect(name).toBeInTheDocument() }) it('should have rounded pill shape for label container', () => { - // Arrange & Act const { container } = renderStepperStep() - // Assert const labelContainer = container.querySelector('.rounded-3xl') expect(labelContainer).toBeInTheDocument() }) it('should apply h-5 height to label container', () => { - // Arrange & Act const { container } = renderStepperStep() - // Assert const labelContainer = container.querySelector('.h-5') expect(labelContainer).toBeInTheDocument() }) }) }) -// ============================================================================ // Integration Tests - Stepper and StepperStep working together -// ============================================================================ describe('Stepper Integration', () => { beforeEach(() => { vi.clearAllMocks() }) it('should pass correct props to each StepperStep', () => { - // Arrange const steps = [ createStep({ name: 'First' }), createStep({ name: 'Second' }), createStep({ name: 'Third' }), ] - // Act renderStepper({ steps, activeIndex: 1 }) // Assert - Each step receives correct index and displays correctly @@ -697,10 +565,8 @@ describe('Stepper Integration', () => { }) it('should maintain correct visual hierarchy across steps', () => { - // Arrange const steps = createSteps(4) - // Act const { container } = renderStepper({ steps, activeIndex: 2 }) // Assert - Check visual hierarchy @@ -718,10 +584,8 @@ describe('Stepper Integration', () => { }) it('should render correctly with dynamic step updates', () => { - // Arrange const initialSteps = createSteps(2) - // Act const { rerender } = render() expect(screen.getByText('Step 1')).toBeInTheDocument() expect(screen.getByText('Step 2')).toBeInTheDocument() @@ -730,7 +594,6 @@ describe('Stepper Integration', () => { const updatedSteps = createSteps(4) rerender() - // Assert expect(screen.getByText('STEP 3')).toBeInTheDocument() expect(screen.getByText('Step 4')).toBeInTheDocument() }) diff --git a/web/app/components/datasets/create/stepper/__tests__/step.spec.tsx b/web/app/components/datasets/create/stepper/__tests__/step.spec.tsx new file mode 100644 index 0000000000..6d046bb9c9 --- /dev/null +++ b/web/app/components/datasets/create/stepper/__tests__/step.spec.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { StepperStep } from '../step' + +describe('StepperStep', () => { + it('should render step name', () => { + render() + expect(screen.getByText('Configure')).toBeInTheDocument() + }) + + it('should show "STEP N" label for active step', () => { + render() + expect(screen.getByText('STEP 2')).toBeInTheDocument() + }) + + it('should show just number for non-active step', () => { + render() + expect(screen.getByText('2')).toBeInTheDocument() + }) + + it('should apply accent style for active step', () => { + render() + const nameEl = screen.getByText('Step A') + expect(nameEl.className).toContain('text-text-accent') + }) + + it('should apply disabled style for future step', () => { + render() + const nameEl = screen.getByText('Step C') + expect(nameEl.className).toContain('text-text-quaternary') + }) +}) diff --git a/web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx b/web/app/components/datasets/create/stop-embedding-modal/__tests__/index.spec.tsx similarity index 85% rename from web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx rename to web/app/components/datasets/create/stop-embedding-modal/__tests__/index.spec.tsx index 897c965c96..5dc30be00f 100644 --- a/web/app/components/datasets/create/stop-embedding-modal/index.spec.tsx +++ b/web/app/components/datasets/create/stop-embedding-modal/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import type { MockInstance } from 'vitest' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' -import StopEmbeddingModal from './index' +import StopEmbeddingModal from '../index' // Helper type for component props type StopEmbeddingModalProps = { @@ -23,9 +23,7 @@ const renderStopEmbeddingModal = (props: Partial = {}) } } -// ============================================================================ // StopEmbeddingModal Component Tests -// ============================================================================ describe('StopEmbeddingModal', () => { // Suppress Headless UI warnings in tests // These warnings are from the library's internal behavior, not our code @@ -37,69 +35,54 @@ describe('StopEmbeddingModal', () => { consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(vi.fn()) }) + beforeEach(() => { + vi.clearAllMocks() + }) + afterAll(() => { consoleWarnSpy.mockRestore() consoleErrorSpy.mockRestore() }) - beforeEach(() => { - vi.clearAllMocks() - }) - - // -------------------------------------------------------------------------- // Rendering Tests - Verify component renders properly - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing when show is true', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() }) it('should render modal title', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() }) it('should render modal content', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument() }) it('should render confirm button with correct text', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeInTheDocument() }) it('should render cancel button with correct text', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelButtonCancel')).toBeInTheDocument() }) it('should not render modal content when show is false', () => { - // Arrange & Act renderStopEmbeddingModal({ show: false }) - // Assert expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() }) it('should render buttons in correct order (cancel first, then confirm)', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) // Assert - Due to flex-row-reverse, confirm appears first visually but cancel is first in DOM @@ -108,25 +91,20 @@ describe('StopEmbeddingModal', () => { }) it('should render confirm button with primary variant styling', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') expect(confirmButton).toHaveClass('ml-2', 'w-24') }) it('should render cancel button with default styling', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') expect(cancelButton).toHaveClass('w-24') }) it('should render all modal elements', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) // Assert - Modal should contain title, content, and buttons @@ -137,39 +115,30 @@ describe('StopEmbeddingModal', () => { }) }) - // -------------------------------------------------------------------------- // Props Testing - Test all prop variations - // -------------------------------------------------------------------------- describe('Props', () => { describe('show prop', () => { it('should show modal when show is true', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() }) it('should hide modal when show is false', () => { - // Arrange & Act renderStopEmbeddingModal({ show: false }) - // Assert expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() }) it('should use default value false when show is not provided', () => { - // Arrange & Act const onConfirm = vi.fn() const onHide = vi.fn() render() - // Assert expect(screen.queryByText('datasetCreation.stepThree.modelTitle')).not.toBeInTheDocument() }) it('should toggle visibility when show prop changes to true', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() @@ -193,10 +162,8 @@ describe('StopEmbeddingModal', () => { describe('onConfirm prop', () => { it('should accept onConfirm callback function', () => { - // Arrange const onConfirm = vi.fn() - // Act renderStopEmbeddingModal({ onConfirm }) // Assert - No errors thrown @@ -206,10 +173,8 @@ describe('StopEmbeddingModal', () => { describe('onHide prop', () => { it('should accept onHide callback function', () => { - // Arrange const onHide = vi.fn() - // Act renderStopEmbeddingModal({ onHide }) // Assert - No errors thrown @@ -218,51 +183,41 @@ describe('StopEmbeddingModal', () => { }) }) - // -------------------------------------------------------------------------- // User Interactions Tests - Test click events and event handlers - // -------------------------------------------------------------------------- describe('User Interactions', () => { describe('Confirm Button', () => { it('should call onConfirm when confirm button is clicked', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) }) - // Assert expect(onConfirm).toHaveBeenCalledTimes(1) }) it('should call onHide when confirm button is clicked', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) }) - // Assert expect(onHide).toHaveBeenCalledTimes(1) }) it('should call both onConfirm and onHide in correct order when confirm button is clicked', async () => { - // Arrange const callOrder: string[] = [] const onConfirm = vi.fn(() => callOrder.push('confirm')) const onHide = vi.fn(() => callOrder.push('hide')) renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) @@ -273,12 +228,10 @@ describe('StopEmbeddingModal', () => { }) it('should handle multiple clicks on confirm button', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) @@ -286,7 +239,6 @@ describe('StopEmbeddingModal', () => { fireEvent.click(confirmButton) }) - // Assert expect(onConfirm).toHaveBeenCalledTimes(3) expect(onHide).toHaveBeenCalledTimes(3) }) @@ -294,51 +246,42 @@ describe('StopEmbeddingModal', () => { describe('Cancel Button', () => { it('should call onHide when cancel button is clicked', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') await act(async () => { fireEvent.click(cancelButton) }) - // Assert expect(onHide).toHaveBeenCalledTimes(1) }) it('should not call onConfirm when cancel button is clicked', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') await act(async () => { fireEvent.click(cancelButton) }) - // Assert expect(onConfirm).not.toHaveBeenCalled() }) it('should handle multiple clicks on cancel button', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const cancelButton = screen.getByText('datasetCreation.stepThree.modelButtonCancel') await act(async () => { fireEvent.click(cancelButton) fireEvent.click(cancelButton) }) - // Assert expect(onHide).toHaveBeenCalledTimes(2) expect(onConfirm).not.toHaveBeenCalled() }) @@ -346,7 +289,6 @@ describe('StopEmbeddingModal', () => { describe('Close Icon', () => { it('should call onHide when close span is clicked', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() const { container } = renderStopEmbeddingModal({ onConfirm, onHide }) @@ -362,7 +304,6 @@ describe('StopEmbeddingModal', () => { fireEvent.click(closeSpan) }) - // Assert expect(onHide).toHaveBeenCalledTimes(1) } else { @@ -372,12 +313,10 @@ describe('StopEmbeddingModal', () => { }) it('should not call onConfirm when close span is clicked', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() const { container } = renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const spans = container.querySelectorAll('span') const closeSpan = Array.from(spans).find(span => span.className && span.getAttribute('class')?.includes('close'), @@ -388,7 +327,6 @@ describe('StopEmbeddingModal', () => { fireEvent.click(closeSpan) }) - // Assert expect(onConfirm).not.toHaveBeenCalled() } }) @@ -396,7 +334,6 @@ describe('StopEmbeddingModal', () => { describe('Different Close Methods', () => { it('should distinguish between confirm and cancel actions', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) @@ -407,11 +344,9 @@ describe('StopEmbeddingModal', () => { fireEvent.click(cancelButton) }) - // Assert expect(onConfirm).not.toHaveBeenCalled() expect(onHide).toHaveBeenCalledTimes(1) - // Reset vi.clearAllMocks() // Act - Click confirm @@ -420,19 +355,15 @@ describe('StopEmbeddingModal', () => { fireEvent.click(confirmButton) }) - // Assert expect(onConfirm).toHaveBeenCalledTimes(1) expect(onHide).toHaveBeenCalledTimes(1) }) }) }) - // -------------------------------------------------------------------------- // Edge Cases Tests - Test null, undefined, empty values and boundaries - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle rapid confirm button clicks', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) @@ -444,13 +375,11 @@ describe('StopEmbeddingModal', () => { fireEvent.click(confirmButton) }) - // Assert expect(onConfirm).toHaveBeenCalledTimes(10) expect(onHide).toHaveBeenCalledTimes(10) }) it('should handle rapid cancel button clicks', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) @@ -462,19 +391,16 @@ describe('StopEmbeddingModal', () => { fireEvent.click(cancelButton) }) - // Assert expect(onHide).toHaveBeenCalledTimes(10) expect(onConfirm).not.toHaveBeenCalled() }) it('should handle callbacks being replaced', async () => { - // Arrange const onConfirm1 = vi.fn() const onHide1 = vi.fn() const onConfirm2 = vi.fn() const onHide2 = vi.fn() - // Act const { rerender } = render( , ) @@ -484,7 +410,6 @@ describe('StopEmbeddingModal', () => { rerender() }) - // Click confirm with new callbacks const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) @@ -498,7 +423,6 @@ describe('StopEmbeddingModal', () => { }) it('should render with all required props', () => { - // Arrange & Act render( { />, ) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- // Layout and Styling Tests - Verify correct structure - // -------------------------------------------------------------------------- describe('Layout and Styling', () => { it('should have buttons container with flex-row-reverse', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert const buttons = screen.getAllByRole('button') expect(buttons[0].closest('div')).toHaveClass('flex', 'flex-row-reverse') }) it('should render title and content elements', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeInTheDocument() expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeInTheDocument() }) it('should render two buttons', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert const buttons = screen.getAllByRole('button') expect(buttons).toHaveLength(2) }) }) - // -------------------------------------------------------------------------- // submit Function Tests - Test the internal submit function behavior - // -------------------------------------------------------------------------- describe('submit Function', () => { it('should execute onConfirm first then onHide', async () => { - // Arrange let confirmTime = 0 let hideTime = 0 let counter = 0 @@ -562,73 +474,59 @@ describe('StopEmbeddingModal', () => { }) renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) }) - // Assert expect(confirmTime).toBe(1) expect(hideTime).toBe(2) }) it('should call both callbacks exactly once per click', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) }) - // Assert expect(onConfirm).toHaveBeenCalledTimes(1) expect(onHide).toHaveBeenCalledTimes(1) }) it('should pass no arguments to onConfirm', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) }) - // Assert expect(onConfirm).toHaveBeenCalledWith() }) it('should pass no arguments to onHide when called from submit', async () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() renderStopEmbeddingModal({ onConfirm, onHide }) - // Act const confirmButton = screen.getByText('datasetCreation.stepThree.modelButtonConfirm') await act(async () => { fireEvent.click(confirmButton) }) - // Assert expect(onHide).toHaveBeenCalledWith() }) }) - // -------------------------------------------------------------------------- // Modal Integration Tests - Verify Modal component integration - // -------------------------------------------------------------------------- describe('Modal Integration', () => { it('should pass show prop to Modal as isShow', async () => { - // Arrange & Act const { rerender } = render( , ) @@ -648,15 +546,10 @@ describe('StopEmbeddingModal', () => { }) }) - // -------------------------------------------------------------------------- - // Accessibility Tests - // -------------------------------------------------------------------------- describe('Accessibility', () => { it('should have buttons that are focusable', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert const buttons = screen.getAllByRole('button') buttons.forEach((button) => { expect(button).not.toHaveAttribute('tabindex', '-1') @@ -664,19 +557,15 @@ describe('StopEmbeddingModal', () => { }) it('should have semantic button elements', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert const buttons = screen.getAllByRole('button') expect(buttons).toHaveLength(2) }) it('should have accessible text content', () => { - // Arrange & Act renderStopEmbeddingModal({ show: true }) - // Assert expect(screen.getByText('datasetCreation.stepThree.modelTitle')).toBeVisible() expect(screen.getByText('datasetCreation.stepThree.modelContent')).toBeVisible() expect(screen.getByText('datasetCreation.stepThree.modelButtonConfirm')).toBeVisible() @@ -684,12 +573,9 @@ describe('StopEmbeddingModal', () => { }) }) - // -------------------------------------------------------------------------- // Component Lifecycle Tests - // -------------------------------------------------------------------------- describe('Component Lifecycle', () => { it('should unmount cleanly', () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() const { unmount } = renderStopEmbeddingModal({ onConfirm, onHide }) @@ -699,12 +585,10 @@ describe('StopEmbeddingModal', () => { }) it('should not call callbacks after unmount', () => { - // Arrange const onConfirm = vi.fn() const onHide = vi.fn() const { unmount } = renderStopEmbeddingModal({ onConfirm, onHide }) - // Act unmount() // Assert - No callbacks should be called after unmount @@ -713,7 +597,6 @@ describe('StopEmbeddingModal', () => { }) it('should re-render correctly when props update', async () => { - // Arrange const onConfirm1 = vi.fn() const onHide1 = vi.fn() const onConfirm2 = vi.fn() diff --git a/web/app/components/datasets/create/top-bar/index.spec.tsx b/web/app/components/datasets/create/top-bar/__tests__/index.spec.tsx similarity index 83% rename from web/app/components/datasets/create/top-bar/index.spec.tsx rename to web/app/components/datasets/create/top-bar/__tests__/index.spec.tsx index ec16d7b892..4fc8d1852b 100644 --- a/web/app/components/datasets/create/top-bar/index.spec.tsx +++ b/web/app/components/datasets/create/top-bar/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ -import type { TopBarProps } from './index' +import type { TopBarProps } from '../index' import { render, screen } from '@testing-library/react' -import { TopBar } from './index' +import { TopBar } from '../index' // Mock next/link to capture href values vi.mock('next/link', () => ({ @@ -23,31 +23,23 @@ const renderTopBar = (props: Partial = {}) => { } } -// ============================================================================ // TopBar Component Tests -// ============================================================================ describe('TopBar', () => { beforeEach(() => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- // Rendering Tests - Verify component renders properly - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act renderTopBar() - // Assert expect(screen.getByTestId('back-link')).toBeInTheDocument() }) it('should render back link with arrow icon', () => { - // Arrange & Act const { container } = renderTopBar() - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toBeInTheDocument() // Check for the arrow icon (svg element) @@ -56,15 +48,12 @@ describe('TopBar', () => { }) it('should render fallback route text', () => { - // Arrange & Act renderTopBar() - // Assert expect(screen.getByText('datasetCreation.steps.header.fallbackRoute')).toBeInTheDocument() }) it('should render Stepper component with 3 steps', () => { - // Arrange & Act renderTopBar({ activeIndex: 0 }) // Assert - Check for step translations @@ -74,10 +63,8 @@ describe('TopBar', () => { }) it('should apply default container classes', () => { - // Arrange & Act const { container } = renderTopBar() - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('relative') expect(wrapper).toHaveClass('flex') @@ -90,25 +77,19 @@ describe('TopBar', () => { }) }) - // -------------------------------------------------------------------------- // Props Testing - Test all prop variations - // -------------------------------------------------------------------------- describe('Props', () => { describe('className prop', () => { it('should apply custom className when provided', () => { - // Arrange & Act const { container } = renderTopBar({ className: 'custom-class' }) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-class') }) it('should merge custom className with default classes', () => { - // Arrange & Act const { container } = renderTopBar({ className: 'my-custom-class another-class' }) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('relative') expect(wrapper).toHaveClass('flex') @@ -117,20 +98,16 @@ describe('TopBar', () => { }) it('should render correctly without className', () => { - // Arrange & Act const { container } = renderTopBar({ className: undefined }) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('relative') expect(wrapper).toHaveClass('flex') }) it('should handle empty string className', () => { - // Arrange & Act const { container } = renderTopBar({ className: '' }) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('relative') }) @@ -138,34 +115,27 @@ describe('TopBar', () => { describe('datasetId prop', () => { it('should set fallback route to /datasets when datasetId is undefined', () => { - // Arrange & Act renderTopBar({ datasetId: undefined }) - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveAttribute('href', '/datasets') }) it('should set fallback route to /datasets/:id/documents when datasetId is provided', () => { - // Arrange & Act renderTopBar({ datasetId: 'dataset-123' }) - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveAttribute('href', '/datasets/dataset-123/documents') }) it('should handle various datasetId formats', () => { - // Arrange & Act renderTopBar({ datasetId: 'abc-def-ghi-123' }) - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveAttribute('href', '/datasets/abc-def-ghi-123/documents') }) it('should handle empty string datasetId', () => { - // Arrange & Act renderTopBar({ datasetId: '' }) // Assert - Empty string is falsy, so fallback to /datasets @@ -176,7 +146,6 @@ describe('TopBar', () => { describe('activeIndex prop', () => { it('should pass activeIndex to Stepper component (index 0)', () => { - // Arrange & Act const { container } = renderTopBar({ activeIndex: 0 }) // Assert - First step should be active (has specific styling) @@ -185,7 +154,6 @@ describe('TopBar', () => { }) it('should pass activeIndex to Stepper component (index 1)', () => { - // Arrange & Act renderTopBar({ activeIndex: 1 }) // Assert - Stepper is rendered with correct props @@ -194,15 +162,12 @@ describe('TopBar', () => { }) it('should pass activeIndex to Stepper component (index 2)', () => { - // Arrange & Act renderTopBar({ activeIndex: 2 }) - // Assert expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument() }) it('should handle edge case activeIndex of -1', () => { - // Arrange & Act const { container } = renderTopBar({ activeIndex: -1 }) // Assert - Component should render without crashing @@ -210,7 +175,6 @@ describe('TopBar', () => { }) it('should handle edge case activeIndex beyond steps length', () => { - // Arrange & Act const { container } = renderTopBar({ activeIndex: 10 }) // Assert - Component should render without crashing @@ -219,15 +183,12 @@ describe('TopBar', () => { }) }) - // -------------------------------------------------------------------------- // Memoization Tests - Test useMemo logic and dependencies - // -------------------------------------------------------------------------- describe('Memoization Logic', () => { it('should compute fallbackRoute based on datasetId', () => { // Arrange & Act - With datasetId const { rerender } = render() - // Assert expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/test-id/documents') // Act - Rerender with different datasetId @@ -238,35 +199,27 @@ describe('TopBar', () => { }) it('should update fallbackRoute when datasetId changes from undefined to defined', () => { - // Arrange const { rerender } = render() expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets') - // Act rerender() - // Assert expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/new-dataset/documents') }) it('should update fallbackRoute when datasetId changes from defined to undefined', () => { - // Arrange const { rerender } = render() expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/existing-id/documents') - // Act rerender() - // Assert expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets') }) it('should not change fallbackRoute when activeIndex changes but datasetId stays same', () => { - // Arrange const { rerender } = render() const initialHref = screen.getByTestId('back-link').getAttribute('href') - // Act rerender() // Assert - href should remain the same @@ -274,11 +227,9 @@ describe('TopBar', () => { }) it('should not change fallbackRoute when className changes but datasetId stays same', () => { - // Arrange const { rerender } = render() const initialHref = screen.getByTestId('back-link').getAttribute('href') - // Act rerender() // Assert - href should remain the same @@ -286,24 +237,18 @@ describe('TopBar', () => { }) }) - // -------------------------------------------------------------------------- // Link Component Tests - // -------------------------------------------------------------------------- describe('Link Component', () => { it('should render Link with replace prop', () => { - // Arrange & Act renderTopBar() - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveAttribute('data-replace', 'true') }) it('should render Link with correct classes', () => { - // Arrange & Act renderTopBar() - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveClass('inline-flex') expect(backLink).toHaveClass('h-12') @@ -316,84 +261,63 @@ describe('TopBar', () => { }) }) - // -------------------------------------------------------------------------- // STEP_T_MAP Tests - Verify step translations - // -------------------------------------------------------------------------- describe('STEP_T_MAP Translations', () => { it('should render step one translation', () => { - // Arrange & Act renderTopBar({ activeIndex: 0 }) - // Assert expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() }) it('should render step two translation', () => { - // Arrange & Act renderTopBar({ activeIndex: 1 }) - // Assert expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument() }) it('should render step three translation', () => { - // Arrange & Act renderTopBar({ activeIndex: 2 }) - // Assert expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument() }) it('should render all three step translations', () => { - // Arrange & Act renderTopBar({ activeIndex: 0 }) - // Assert expect(screen.getByText('datasetCreation.steps.one')).toBeInTheDocument() expect(screen.getByText('datasetCreation.steps.two')).toBeInTheDocument() expect(screen.getByText('datasetCreation.steps.three')).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- // Edge Cases and Error Handling Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle special characters in datasetId', () => { - // Arrange & Act renderTopBar({ datasetId: 'dataset-with-special_chars.123' }) - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveAttribute('href', '/datasets/dataset-with-special_chars.123/documents') }) it('should handle very long datasetId', () => { - // Arrange const longId = 'a'.repeat(100) - // Act renderTopBar({ datasetId: longId }) - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveAttribute('href', `/datasets/${longId}/documents`) }) it('should handle UUID format datasetId', () => { - // Arrange const uuid = '550e8400-e29b-41d4-a716-446655440000' - // Act renderTopBar({ datasetId: uuid }) - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toHaveAttribute('href', `/datasets/${uuid}/documents`) }) it('should handle whitespace in className', () => { - // Arrange & Act const { container } = renderTopBar({ className: ' spaced-class ' }) // Assert - classNames utility handles whitespace @@ -402,35 +326,28 @@ describe('TopBar', () => { }) it('should render correctly with all props provided', () => { - // Arrange & Act const { container } = renderTopBar({ className: 'custom-class', datasetId: 'full-props-id', activeIndex: 2, }) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-class') expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets/full-props-id/documents') }) it('should render correctly with minimal props (only activeIndex)', () => { - // Arrange & Act const { container } = renderTopBar({ activeIndex: 0 }) - // Assert expect(container.firstChild).toBeInTheDocument() expect(screen.getByTestId('back-link')).toHaveAttribute('href', '/datasets') }) }) - // -------------------------------------------------------------------------- // Stepper Integration Tests - // -------------------------------------------------------------------------- describe('Stepper Integration', () => { it('should pass steps array with correct structure to Stepper', () => { - // Arrange & Act renderTopBar({ activeIndex: 0 }) // Assert - All step names should be rendered @@ -444,7 +361,6 @@ describe('TopBar', () => { }) it('should render Stepper in centered position', () => { - // Arrange & Act const { container } = renderTopBar({ activeIndex: 0 }) // Assert - Check for centered positioning classes @@ -453,7 +369,6 @@ describe('TopBar', () => { }) it('should render step dividers between steps', () => { - // Arrange & Act const { container } = renderTopBar({ activeIndex: 0 }) // Assert - Check for dividers (h-px w-4 bg-divider-deep) @@ -462,15 +377,10 @@ describe('TopBar', () => { }) }) - // -------------------------------------------------------------------------- - // Accessibility Tests - // -------------------------------------------------------------------------- describe('Accessibility', () => { it('should have accessible back link', () => { - // Arrange & Act renderTopBar() - // Assert const backLink = screen.getByTestId('back-link') expect(backLink).toBeInTheDocument() // Link should have visible text @@ -478,7 +388,6 @@ describe('TopBar', () => { }) it('should have visible arrow icon in back link', () => { - // Arrange & Act const { container } = renderTopBar() // Assert - Arrow icon should be visible @@ -488,12 +397,9 @@ describe('TopBar', () => { }) }) - // -------------------------------------------------------------------------- // Re-render Tests - // -------------------------------------------------------------------------- describe('Re-render Behavior', () => { it('should update activeIndex on re-render', () => { - // Arrange const { rerender, container } = render() // Initial check @@ -507,21 +413,17 @@ describe('TopBar', () => { }) it('should update className on re-render', () => { - // Arrange const { rerender, container } = render() const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('initial-class') - // Act rerender() - // Assert expect(wrapper).toHaveClass('updated-class') expect(wrapper).not.toHaveClass('initial-class') }) it('should handle multiple rapid re-renders', () => { - // Arrange const { rerender, container } = render() // Act - Multiple rapid re-renders diff --git a/web/app/components/datasets/create/website/base.spec.tsx b/web/app/components/datasets/create/website/__tests__/base.spec.tsx similarity index 94% rename from web/app/components/datasets/create/website/base.spec.tsx rename to web/app/components/datasets/create/website/__tests__/base.spec.tsx index 3843aa780c..980d1b8382 100644 --- a/web/app/components/datasets/create/website/base.spec.tsx +++ b/web/app/components/datasets/create/website/__tests__/base.spec.tsx @@ -1,14 +1,10 @@ import type { CrawlResultItem } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import CrawledResult from './base/crawled-result' -import CrawledResultItem from './base/crawled-result-item' -import Header from './base/header' -import Input from './base/input' - -// ============================================================================ -// Test Data Factories -// ============================================================================ +import CrawledResult from '../base/crawled-result' +import CrawledResultItem from '../base/crawled-result-item' +import Header from '../base/header' +import Input from '../base/input' const createCrawlResultItem = (overrides: Partial = {}): CrawlResultItem => ({ title: 'Test Page Title', @@ -18,9 +14,7 @@ const createCrawlResultItem = (overrides: Partial = {}): CrawlR ...overrides, }) -// ============================================================================ // Input Component Tests -// ============================================================================ describe('Input', () => { beforeEach(() => { @@ -155,9 +149,7 @@ describe('Input', () => { }) }) -// ============================================================================ // Header Component Tests -// ============================================================================ describe('Header', () => { const createHeaderProps = (overrides: Partial[0]> = {}) => ({ @@ -254,9 +246,7 @@ describe('Header', () => { }) }) -// ============================================================================ // CrawledResultItem Component Tests -// ============================================================================ describe('CrawledResultItem', () => { const createItemProps = (overrides: Partial[0]> = {}) => ({ @@ -359,9 +349,7 @@ describe('CrawledResultItem', () => { }) }) -// ============================================================================ // CrawledResult Component Tests -// ============================================================================ describe('CrawledResult', () => { const createResultProps = (overrides: Partial[0]> = {}) => ({ @@ -487,7 +475,6 @@ describe('CrawledResult', () => { const props = createResultProps({ list, checkedList: [list[0], list[1]], onSelectedChange }) render() - // Click the first item's checkbox to uncheck it await userEvent.click(getItemCheckbox(0)) expect(onSelectedChange).toHaveBeenCalledWith([list[1]]) @@ -505,7 +492,6 @@ describe('CrawledResult', () => { render() - // Click preview on second item const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview') await userEvent.click(previewButtons[1]) @@ -522,7 +508,6 @@ describe('CrawledResult', () => { render() - // Click preview on first item const previewButtons = screen.getAllByText('datasetCreation.stepOne.website.preview') await userEvent.click(previewButtons[0]) diff --git a/web/app/components/datasets/create/website/__tests__/index.spec.tsx b/web/app/components/datasets/create/website/__tests__/index.spec.tsx new file mode 100644 index 0000000000..f9f6bf6d57 --- /dev/null +++ b/web/app/components/datasets/create/website/__tests__/index.spec.tsx @@ -0,0 +1,286 @@ +import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types' +import type { CrawlOptions, CrawlResultItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types' +import Website from '../index' + +const mockSetShowAccountSettingModal = vi.fn() + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + }), +})) + +vi.mock('../index.module.css', () => ({ + default: { + jinaLogo: 'jina-logo', + watercrawlLogo: 'watercrawl-logo', + }, +})) + +vi.mock('../firecrawl', () => ({ + default: (props: Record) =>
, +})) + +vi.mock('../jina-reader', () => ({ + default: (props: Record) =>
, +})) + +vi.mock('../watercrawl', () => ({ + default: (props: Record) =>
, +})) + +vi.mock('../no-data', () => ({ + default: ({ onConfig, provider }: { onConfig: () => void, provider: string }) => ( +
+ +
+ ), +})) + +let mockEnableJinaReader = true +let mockEnableFirecrawl = true +let mockEnableWatercrawl = true + +vi.mock('@/config', () => ({ + get ENABLE_WEBSITE_JINAREADER() { return mockEnableJinaReader }, + get ENABLE_WEBSITE_FIRECRAWL() { return mockEnableFirecrawl }, + get ENABLE_WEBSITE_WATERCRAWL() { return mockEnableWatercrawl }, +})) + +const createMockCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({ + crawl_sub_pages: true, + limit: 10, + max_depth: 2, + excludes: '', + includes: '', + only_main_content: false, + use_sitemap: false, + ...overrides, +}) + +const createMockDataSourceAuth = ( + provider: string, + credentialsCount = 1, +): DataSourceAuth => ({ + author: 'test', + provider, + plugin_id: `${provider}-plugin`, + plugin_unique_identifier: `${provider}-unique`, + icon: 'icon.png', + name: provider, + label: { en_US: provider, zh_Hans: provider }, + description: { en_US: `${provider} description`, zh_Hans: `${provider} description` }, + credentials_list: Array.from({ length: credentialsCount }, (_, i) => ({ + credential: {}, + type: CredentialTypeEnum.API_KEY, + name: `cred-${i}`, + id: `cred-${i}`, + is_default: i === 0, + avatar_url: '', + })), +}) + +type RenderProps = { + authedDataSourceList?: DataSourceAuth[] + enableJina?: boolean + enableFirecrawl?: boolean + enableWatercrawl?: boolean +} + +const renderWebsite = ({ + authedDataSourceList = [], + enableJina = true, + enableFirecrawl = true, + enableWatercrawl = true, +}: RenderProps = {}) => { + mockEnableJinaReader = enableJina + mockEnableFirecrawl = enableFirecrawl + mockEnableWatercrawl = enableWatercrawl + + const props = { + onPreview: vi.fn() as (payload: CrawlResultItem) => void, + checkedCrawlResult: [] as CrawlResultItem[], + onCheckedCrawlResultChange: vi.fn() as (payload: CrawlResultItem[]) => void, + onCrawlProviderChange: vi.fn(), + onJobIdChange: vi.fn(), + crawlOptions: createMockCrawlOptions(), + onCrawlOptionsChange: vi.fn() as (payload: CrawlOptions) => void, + authedDataSourceList, + } + + const result = render() + return { ...result, props } +} + +describe('Website', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnableJinaReader = true + mockEnableFirecrawl = true + mockEnableWatercrawl = true + }) + + describe('Rendering', () => { + it('should render provider selection section', () => { + renderWebsite() + expect(screen.getByText(/chooseProvider/i)).toBeInTheDocument() + }) + + it('should show Jina Reader button when ENABLE_WEBSITE_JINAREADER is true', () => { + renderWebsite({ enableJina: true }) + expect(screen.getByText('Jina Reader')).toBeInTheDocument() + }) + + it('should not show Jina Reader button when ENABLE_WEBSITE_JINAREADER is false', () => { + renderWebsite({ enableJina: false }) + expect(screen.queryByText('Jina Reader')).not.toBeInTheDocument() + }) + + it('should show Firecrawl button when ENABLE_WEBSITE_FIRECRAWL is true', () => { + renderWebsite({ enableFirecrawl: true }) + expect(screen.getByText(/Firecrawl/)).toBeInTheDocument() + }) + + it('should not show Firecrawl button when ENABLE_WEBSITE_FIRECRAWL is false', () => { + renderWebsite({ enableFirecrawl: false }) + expect(screen.queryByText(/Firecrawl/)).not.toBeInTheDocument() + }) + + it('should show WaterCrawl button when ENABLE_WEBSITE_WATERCRAWL is true', () => { + renderWebsite({ enableWatercrawl: true }) + expect(screen.getByText('WaterCrawl')).toBeInTheDocument() + }) + + it('should not show WaterCrawl button when ENABLE_WEBSITE_WATERCRAWL is false', () => { + renderWebsite({ enableWatercrawl: false }) + expect(screen.queryByText('WaterCrawl')).not.toBeInTheDocument() + }) + }) + + describe('Provider Selection', () => { + it('should select Jina Reader by default', () => { + const authedDataSourceList = [createMockDataSourceAuth('jinareader')] + renderWebsite({ authedDataSourceList }) + + expect(screen.getByTestId('jina-reader-component')).toBeInTheDocument() + }) + + it('should switch to Firecrawl when Firecrawl button clicked', () => { + const authedDataSourceList = [ + createMockDataSourceAuth('jinareader'), + createMockDataSourceAuth('firecrawl'), + ] + renderWebsite({ authedDataSourceList }) + + const firecrawlButton = screen.getByText(/Firecrawl/) + fireEvent.click(firecrawlButton) + + expect(screen.getByTestId('firecrawl-component')).toBeInTheDocument() + expect(screen.queryByTestId('jina-reader-component')).not.toBeInTheDocument() + }) + + it('should switch to WaterCrawl when WaterCrawl button clicked', () => { + const authedDataSourceList = [ + createMockDataSourceAuth('jinareader'), + createMockDataSourceAuth('watercrawl'), + ] + renderWebsite({ authedDataSourceList }) + + const watercrawlButton = screen.getByText('WaterCrawl') + fireEvent.click(watercrawlButton) + + expect(screen.getByTestId('watercrawl-component')).toBeInTheDocument() + expect(screen.queryByTestId('jina-reader-component')).not.toBeInTheDocument() + }) + + it('should call onCrawlProviderChange when provider switched', () => { + const authedDataSourceList = [ + createMockDataSourceAuth('jinareader'), + createMockDataSourceAuth('firecrawl'), + ] + const { props } = renderWebsite({ authedDataSourceList }) + + const firecrawlButton = screen.getByText(/Firecrawl/) + fireEvent.click(firecrawlButton) + + expect(props.onCrawlProviderChange).toHaveBeenCalledWith('firecrawl') + }) + }) + + describe('Provider Content', () => { + it('should show JinaReader component when selected and available', () => { + const authedDataSourceList = [createMockDataSourceAuth('jinareader')] + renderWebsite({ authedDataSourceList }) + + expect(screen.getByTestId('jina-reader-component')).toBeInTheDocument() + }) + + it('should show Firecrawl component when selected and available', () => { + const authedDataSourceList = [ + createMockDataSourceAuth('jinareader'), + createMockDataSourceAuth('firecrawl'), + ] + renderWebsite({ authedDataSourceList }) + + const firecrawlButton = screen.getByText(/Firecrawl/) + fireEvent.click(firecrawlButton) + + expect(screen.getByTestId('firecrawl-component')).toBeInTheDocument() + }) + + it('should show NoData when selected provider has no credentials', () => { + const authedDataSourceList = [createMockDataSourceAuth('jinareader', 0)] + renderWebsite({ authedDataSourceList }) + + expect(screen.getByTestId('no-data-component')).toBeInTheDocument() + }) + + it('should show NoData when no data source available for selected provider', () => { + renderWebsite({ authedDataSourceList: [] }) + + expect(screen.getByTestId('no-data-component')).toBeInTheDocument() + }) + }) + + describe('NoData Config', () => { + it('should call setShowAccountSettingModal when NoData onConfig is triggered', () => { + renderWebsite({ authedDataSourceList: [] }) + + const configButton = screen.getByTestId('no-data-config-button') + fireEvent.click(configButton) + + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: 'data-source', + }) + }) + }) + + describe('Edge Cases', () => { + it('should handle no providers enabled', () => { + renderWebsite({ + enableJina: false, + enableFirecrawl: false, + enableWatercrawl: false, + }) + + expect(screen.queryByText('Jina Reader')).not.toBeInTheDocument() + expect(screen.queryByText(/Firecrawl/)).not.toBeInTheDocument() + expect(screen.queryByText('WaterCrawl')).not.toBeInTheDocument() + }) + + it('should handle only one provider enabled', () => { + renderWebsite({ + enableJina: true, + enableFirecrawl: false, + enableWatercrawl: false, + }) + + expect(screen.getByText('Jina Reader')).toBeInTheDocument() + expect(screen.queryByText(/Firecrawl/)).not.toBeInTheDocument() + expect(screen.queryByText('WaterCrawl')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/website/__tests__/no-data.spec.tsx b/web/app/components/datasets/create/website/__tests__/no-data.spec.tsx new file mode 100644 index 0000000000..b19e117d69 --- /dev/null +++ b/web/app/components/datasets/create/website/__tests__/no-data.spec.tsx @@ -0,0 +1,185 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DataSourceProvider } from '@/models/common' +import NoData from '../no-data' + +// Mock Setup + +// Mock CSS module +vi.mock('../index.module.css', () => ({ + default: { + jinaLogo: 'jinaLogo', + watercrawlLogo: 'watercrawlLogo', + }, +})) + +// Feature flags - default all enabled +let mockEnableFirecrawl = true +let mockEnableJinaReader = true +let mockEnableWaterCrawl = true + +vi.mock('@/config', () => ({ + get ENABLE_WEBSITE_FIRECRAWL() { return mockEnableFirecrawl }, + get ENABLE_WEBSITE_JINAREADER() { return mockEnableJinaReader }, + get ENABLE_WEBSITE_WATERCRAWL() { return mockEnableWaterCrawl }, +})) + +// NoData Component Tests + +describe('NoData', () => { + const mockOnConfig = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockEnableFirecrawl = true + mockEnableJinaReader = true + mockEnableWaterCrawl = true + }) + + // Rendering Tests - Per Provider + describe('Rendering per provider', () => { + it('should render fireCrawl provider with emoji and not-configured message', () => { + render() + + expect(screen.getByText('🔥')).toBeInTheDocument() + const titleAndDesc = screen.getAllByText(/fireCrawlNotConfigured/i) + expect(titleAndDesc).toHaveLength(2) + }) + + it('should render jinaReader provider with jina logo and not-configured message', () => { + render() + + const titleAndDesc = screen.getAllByText(/jinaReaderNotConfigured/i) + expect(titleAndDesc).toHaveLength(2) + }) + + it('should render waterCrawl provider with emoji and not-configured message', () => { + render() + + expect(screen.getByText('💧')).toBeInTheDocument() + const titleAndDesc = screen.getAllByText(/waterCrawlNotConfigured/i) + expect(titleAndDesc).toHaveLength(2) + }) + + it('should render configure button for each provider', () => { + render() + + expect(screen.getByRole('button', { name: /configure/i })).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onConfig when configure button is clicked', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /configure/i })) + + expect(mockOnConfig).toHaveBeenCalledTimes(1) + }) + + it('should call onConfig for jinaReader provider', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /configure/i })) + + expect(mockOnConfig).toHaveBeenCalledTimes(1) + }) + + it('should call onConfig for waterCrawl provider', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /configure/i })) + + expect(mockOnConfig).toHaveBeenCalledTimes(1) + }) + }) + + // Feature Flag Disabled - Returns null + describe('Disabled providers (feature flag off)', () => { + it('should fall back to jinaReader when fireCrawl is disabled but jinaReader enabled', () => { + // Arrange — fireCrawl config is null, falls back to providerConfig.jinareader + mockEnableFirecrawl = false + + const { container } = render( + , + ) + + // Assert — renders the jinaReader fallback (not null) + expect(container.innerHTML).not.toBe('') + expect(screen.getAllByText(/jinaReaderNotConfigured/).length).toBeGreaterThan(0) + }) + + it('should return null when jinaReader is disabled', () => { + // Arrange — jinaReader is the only provider without a fallback + mockEnableJinaReader = false + + const { container } = render( + , + ) + + expect(container.innerHTML).toBe('') + }) + + it('should fall back to jinaReader when waterCrawl is disabled but jinaReader enabled', () => { + // Arrange — waterCrawl config is null, falls back to providerConfig.jinareader + mockEnableWaterCrawl = false + + const { container } = render( + , + ) + + // Assert — renders the jinaReader fallback (not null) + expect(container.innerHTML).not.toBe('') + expect(screen.getAllByText(/jinaReaderNotConfigured/).length).toBeGreaterThan(0) + }) + }) + + // Fallback behavior + describe('Fallback behavior', () => { + it('should fall back to jinaReader config for unknown provider value', () => { + // Arrange - the || fallback goes to providerConfig.jinareader + // Since DataSourceProvider only has 3 values, we test the fallback + // by checking that jinaReader is the fallback when provider doesn't match + mockEnableJinaReader = true + + render() + + expect(screen.getAllByText(/jinaReaderNotConfigured/i).length).toBeGreaterThan(0) + }) + }) + + describe('Edge Cases', () => { + it('should not call onConfig without user interaction', () => { + render() + + expect(mockOnConfig).not.toHaveBeenCalled() + }) + + it('should render correctly when all providers are enabled', () => { + // Arrange - all flags are true by default + + const { rerender } = render( + , + ) + expect(screen.getByText('🔥')).toBeInTheDocument() + + rerender() + expect(screen.getAllByText(/jinaReaderNotConfigured/i).length).toBeGreaterThan(0) + + rerender() + expect(screen.getByText('💧')).toBeInTheDocument() + }) + + it('should return null when all providers are disabled and fireCrawl is selected', () => { + mockEnableFirecrawl = false + mockEnableJinaReader = false + mockEnableWaterCrawl = false + + const { container } = render( + , + ) + + expect(container.innerHTML).toBe('') + }) + }) +}) diff --git a/web/app/components/datasets/create/website/__tests__/preview.spec.tsx b/web/app/components/datasets/create/website/__tests__/preview.spec.tsx new file mode 100644 index 0000000000..9fe447c95c --- /dev/null +++ b/web/app/components/datasets/create/website/__tests__/preview.spec.tsx @@ -0,0 +1,197 @@ +import type { CrawlResultItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import WebsitePreview from '../preview' + +// Mock Setup + +// Mock the CSS module import - returns class names as-is +vi.mock('../../file-preview/index.module.css', () => ({ + default: { + filePreview: 'filePreview', + previewHeader: 'previewHeader', + title: 'title', + previewContent: 'previewContent', + fileContent: 'fileContent', + }, +})) + +// Test Data Factory + +const createPayload = (overrides: Partial = {}): CrawlResultItem => ({ + title: 'Test Page Title', + markdown: 'This is **markdown** content', + description: 'A test description', + source_url: 'https://example.com/page', + ...overrides, +}) + +// WebsitePreview Component Tests + +describe('WebsitePreview', () => { + const mockHidePreview = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const payload = createPayload() + + render() + + expect(screen.getByText('Test Page Title')).toBeInTheDocument() + }) + + it('should render the page preview header text', () => { + const payload = createPayload() + + render() + + // Assert - i18n returns the key path + expect(screen.getByText(/pagePreview/i)).toBeInTheDocument() + }) + + it('should render the payload title', () => { + const payload = createPayload({ title: 'My Custom Page' }) + + render() + + expect(screen.getByText('My Custom Page')).toBeInTheDocument() + }) + + it('should render the payload source_url', () => { + const payload = createPayload({ source_url: 'https://docs.dify.ai/intro' }) + + render() + + const urlElement = screen.getByText('https://docs.dify.ai/intro') + expect(urlElement).toBeInTheDocument() + expect(urlElement).toHaveAttribute('title', 'https://docs.dify.ai/intro') + }) + + it('should render the payload markdown content', () => { + const payload = createPayload({ markdown: 'Hello world markdown' }) + + render() + + expect(screen.getByText('Hello world markdown')).toBeInTheDocument() + }) + + it('should render the close button (XMarkIcon)', () => { + const payload = createPayload() + + render() + + // Assert - the close button container is a div with cursor-pointer + const closeButton = screen.getByText(/pagePreview/i).parentElement?.querySelector('.cursor-pointer') + expect(closeButton).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call hidePreview when close button is clicked', () => { + const payload = createPayload() + render() + + // Act - find the close button div with cursor-pointer class + const closeButton = screen.getByText(/pagePreview/i) + .closest('[class*="title"]')! + .querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(closeButton) + + expect(mockHidePreview).toHaveBeenCalledTimes(1) + }) + + it('should call hidePreview exactly once per click', () => { + const payload = createPayload() + render() + + const closeButton = screen.getByText(/pagePreview/i) + .closest('[class*="title"]')! + .querySelector('.cursor-pointer') as HTMLElement + fireEvent.click(closeButton) + fireEvent.click(closeButton) + + expect(mockHidePreview).toHaveBeenCalledTimes(2) + }) + }) + + // Props Display Tests + describe('Props Display', () => { + it('should display all payload fields simultaneously', () => { + const payload = createPayload({ + title: 'Full Title', + source_url: 'https://full.example.com', + markdown: 'Full markdown text', + }) + + render() + + expect(screen.getByText('Full Title')).toBeInTheDocument() + expect(screen.getByText('https://full.example.com')).toBeInTheDocument() + expect(screen.getByText('Full markdown text')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render with empty title', () => { + const payload = createPayload({ title: '' }) + + render() + + // Assert - component still renders, url is visible + expect(screen.getByText('https://example.com/page')).toBeInTheDocument() + }) + + it('should render with empty markdown', () => { + const payload = createPayload({ markdown: '' }) + + render() + + expect(screen.getByText('Test Page Title')).toBeInTheDocument() + }) + + it('should render with empty source_url', () => { + const payload = createPayload({ source_url: '' }) + + render() + + expect(screen.getByText('Test Page Title')).toBeInTheDocument() + }) + + it('should render with very long content', () => { + const longMarkdown = 'A'.repeat(5000) + const payload = createPayload({ markdown: longMarkdown }) + + render() + + expect(screen.getByText(longMarkdown)).toBeInTheDocument() + }) + + it('should render with special characters in title', () => { + const payload = createPayload({ title: '' }) + + render() + + // Assert - React escapes HTML by default + expect(screen.getByText('')).toBeInTheDocument() + }) + }) + + // CSS Module Classes + describe('CSS Module Classes', () => { + it('should apply filePreview class to root container', () => { + const payload = createPayload() + + const { container } = render( + , + ) + + const root = container.firstElementChild + expect(root?.className).toContain('filePreview') + expect(root?.className).toContain('h-full') + }) + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/checkbox-with-label.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/checkbox-with-label.spec.tsx new file mode 100644 index 0000000000..a3c246054d --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/checkbox-with-label.spec.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CheckboxWithLabel from '../checkbox-with-label' + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ popupContent }: { popupContent?: React.ReactNode }) =>
{popupContent}
, +})) + +describe('CheckboxWithLabel', () => { + const onChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render label', () => { + render() + expect(screen.getByText('Accept terms')).toBeInTheDocument() + }) + + it('should render tooltip when provided', () => { + render( + , + ) + expect(screen.getByTestId('tooltip')).toBeInTheDocument() + }) + + it('should not render tooltip when not provided', () => { + render() + expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() + }) + + it('should toggle checked state on checkbox click', () => { + render() + fireEvent.click(screen.getByTestId('checkbox-my-check')) + expect(onChange).toHaveBeenCalledWith(true) + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/crawled-result-item.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/crawled-result-item.spec.tsx new file mode 100644 index 0000000000..5087bfdbda --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/crawled-result-item.spec.tsx @@ -0,0 +1,43 @@ +import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CrawledResultItem from '../crawled-result-item' + +describe('CrawledResultItem', () => { + const defaultProps = { + payload: { title: 'Example Page', source_url: 'https://example.com/page' } as CrawlResultItemType, + isChecked: false, + isPreview: false, + onCheckChange: vi.fn(), + onPreview: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render title and url', () => { + render() + expect(screen.getByText('Example Page')).toBeInTheDocument() + expect(screen.getByText('https://example.com/page')).toBeInTheDocument() + }) + + it('should apply active styling when isPreview', () => { + const { container } = render() + expect((container.firstChild as HTMLElement).className).toContain('bg-state-base-active') + }) + + it('should call onCheckChange with true when unchecked checkbox is clicked', () => { + render() + const checkbox = screen.getByTestId('checkbox-crawl-item') + fireEvent.click(checkbox) + expect(defaultProps.onCheckChange).toHaveBeenCalledWith(true) + }) + + it('should call onCheckChange with false when checked checkbox is clicked', () => { + render() + const checkbox = screen.getByTestId('checkbox-crawl-item') + fireEvent.click(checkbox) + expect(defaultProps.onCheckChange).toHaveBeenCalledWith(false) + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/crawled-result.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/crawled-result.spec.tsx new file mode 100644 index 0000000000..922ae2adc9 --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/crawled-result.spec.tsx @@ -0,0 +1,313 @@ +import type { CrawlResultItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CrawledResult from '../crawled-result' + +vi.mock('../checkbox-with-label', () => ({ + default: ({ isChecked, onChange, label, testId }: { + isChecked: boolean + onChange: (checked: boolean) => void + label: string + testId?: string + }) => ( + + ), +})) + +vi.mock('../crawled-result-item', () => ({ + default: ({ payload, isChecked, isPreview, onCheckChange, onPreview, testId }: { + payload: CrawlResultItem + isChecked: boolean + isPreview: boolean + onCheckChange: (checked: boolean) => void + onPreview: () => void + testId?: string + }) => ( +
+ onCheckChange(!isChecked)} + data-testid={`check-${testId}`} + /> + {payload.title} + {payload.source_url} + +
+ ), +})) + +const createMockItem = (overrides: Partial = {}): CrawlResultItem => ({ + title: 'Test Page', + markdown: '# Test', + description: 'A test page', + source_url: 'https://example.com', + ...overrides, +}) + +const createMockList = (): CrawlResultItem[] => [ + createMockItem({ title: 'Page 1', source_url: 'https://example.com/1' }), + createMockItem({ title: 'Page 2', source_url: 'https://example.com/2' }), + createMockItem({ title: 'Page 3', source_url: 'https://example.com/3' }), +] + +describe('CrawledResult', () => { + const mockOnSelectedChange = vi.fn() + const mockOnPreview = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render select all checkbox', () => { + const list = createMockList() + render( + , + ) + + expect(screen.getByTestId('select-all')).toBeInTheDocument() + }) + + it('should render all items from list', () => { + const list = createMockList() + render( + , + ) + + expect(screen.getByTestId('item-0')).toBeInTheDocument() + expect(screen.getByTestId('item-1')).toBeInTheDocument() + expect(screen.getByTestId('item-2')).toBeInTheDocument() + }) + + it('should render scrap time info', () => { + const list = createMockList() + render( + , + ) + + expect(screen.getByText(/scrapTimeInfo/i)).toBeInTheDocument() + }) + + it('should apply custom className', () => { + const list = createMockList() + const { container } = render( + , + ) + + const rootElement = container.firstChild as HTMLElement + expect(rootElement).toHaveClass('custom-class') + }) + }) + + describe('Select All', () => { + it('should call onSelectedChange with full list when not all checked', () => { + const list = createMockList() + render( + , + ) + + const selectAllCheckbox = screen.getByTestId('checkbox-select-all') + fireEvent.click(selectAllCheckbox) + + expect(mockOnSelectedChange).toHaveBeenCalledWith(list) + }) + + it('should call onSelectedChange with empty array when all checked', () => { + const list = createMockList() + render( + , + ) + + const selectAllCheckbox = screen.getByTestId('checkbox-select-all') + fireEvent.click(selectAllCheckbox) + + expect(mockOnSelectedChange).toHaveBeenCalledWith([]) + }) + + it('should show selectAll label when not all checked', () => { + const list = createMockList() + render( + , + ) + + expect(screen.getByText(/selectAll/i)).toBeInTheDocument() + }) + + it('should show resetAll label when all checked', () => { + const list = createMockList() + render( + , + ) + + expect(screen.getByText(/resetAll/i)).toBeInTheDocument() + }) + }) + + describe('Individual Item Check', () => { + it('should call onSelectedChange with added item when checking', () => { + const list = createMockList() + const checkedList = [list[0]] + render( + , + ) + + const item1Checkbox = screen.getByTestId('check-item-1') + fireEvent.click(item1Checkbox) + + expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0], list[1]]) + }) + + it('should call onSelectedChange with removed item when unchecking', () => { + const list = createMockList() + const checkedList = [list[0], list[1]] + render( + , + ) + + const item0Checkbox = screen.getByTestId('check-item-0') + fireEvent.click(item0Checkbox) + + expect(mockOnSelectedChange).toHaveBeenCalledWith([list[1]]) + }) + }) + + describe('Preview', () => { + it('should call onPreview with correct item when preview clicked', () => { + const list = createMockList() + render( + , + ) + + const previewButton = screen.getByTestId('preview-item-1') + fireEvent.click(previewButton) + + expect(mockOnPreview).toHaveBeenCalledWith(list[1]) + }) + + it('should update preview state when preview button is clicked', () => { + const list = createMockList() + render( + , + ) + + const previewButton = screen.getByTestId('preview-item-0') + fireEvent.click(previewButton) + + const item0 = screen.getByTestId('item-0') + expect(item0).toHaveAttribute('data-preview', 'true') + }) + }) + + describe('Edge Cases', () => { + it('should render empty list without crashing', () => { + render( + , + ) + + expect(screen.getByTestId('select-all')).toBeInTheDocument() + }) + + it('should handle single item list', () => { + const list = [createMockItem()] + render( + , + ) + + expect(screen.getByTestId('item-0')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/crawling.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/crawling.spec.tsx new file mode 100644 index 0000000000..36fbf6fbc5 --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/crawling.spec.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import Crawling from '../crawling' + +vi.mock('@/app/components/base/icons/src/public/other', () => ({ + RowStruct: (props: React.HTMLAttributes) =>
, +})) + +describe('Crawling', () => { + it('should render crawled count and total', () => { + render() + expect(screen.getByText(/3/)).toBeInTheDocument() + expect(screen.getByText(/10/)).toBeInTheDocument() + }) + + it('should render skeleton rows', () => { + render() + expect(screen.getAllByTestId('row-struct')).toHaveLength(4) + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/error-message.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/error-message.spec.tsx new file mode 100644 index 0000000000..c521822982 --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/error-message.spec.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ErrorMessage from '../error-message' + +vi.mock('@/app/components/base/icons/src/vender/solid/alertsAndFeedback', () => ({ + AlertTriangle: (props: React.SVGProps) => , +})) + +describe('ErrorMessage', () => { + it('should render title', () => { + render() + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + + it('should render error message when provided', () => { + render() + expect(screen.getByText('Detailed error info')).toBeInTheDocument() + }) + + it('should not render error message when not provided', () => { + render() + expect(screen.queryByText('Detailed error info')).not.toBeInTheDocument() + }) + + it('should render alert icon', () => { + render() + expect(screen.getByTestId('alert-icon')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/field.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/field.spec.tsx new file mode 100644 index 0000000000..8a2e147d60 --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/field.spec.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Field from '../field' + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ popupContent }: { popupContent?: React.ReactNode }) =>
{popupContent}
, +})) + +describe('WebsiteField', () => { + const onChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render label', () => { + render() + expect(screen.getByText('URL')).toBeInTheDocument() + }) + + it('should render required asterisk when isRequired', () => { + render() + expect(screen.getByText('*')).toBeInTheDocument() + }) + + it('should not render required asterisk by default', () => { + render() + expect(screen.queryByText('*')).not.toBeInTheDocument() + }) + + it('should render tooltip when provided', () => { + render() + expect(screen.getByTestId('tooltip')).toBeInTheDocument() + }) + + it('should pass value and onChange to Input', () => { + render() + expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument() + }) + + it('should call onChange when input changes', () => { + render() + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new' } }) + expect(onChange).toHaveBeenCalledWith('new') + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/header.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/header.spec.tsx new file mode 100644 index 0000000000..8564242439 --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/header.spec.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Header from '../header' + +describe('WebsiteHeader', () => { + const defaultProps = { + title: 'Jina Reader', + docTitle: 'Documentation', + docLink: 'https://docs.example.com', + onClickConfiguration: vi.fn(), + buttonText: 'Config', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render title', () => { + render(
) + expect(screen.getByText('Jina Reader')).toBeInTheDocument() + }) + + it('should render doc link with correct href', () => { + render(
) + const link = screen.getByText('Documentation').closest('a') + expect(link).toHaveAttribute('href', 'https://docs.example.com') + expect(link).toHaveAttribute('target', '_blank') + }) + + it('should render configuration button with text when not in pipeline', () => { + render(
) + expect(screen.getByText('Config')).toBeInTheDocument() + }) + + it('should call onClickConfiguration on button click', () => { + render(
) + fireEvent.click(screen.getByText('Config').closest('button')!) + expect(defaultProps.onClickConfiguration).toHaveBeenCalledOnce() + }) + + it('should hide button text when isInPipeline', () => { + render(
) + expect(screen.queryByText('Config')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/input.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/input.spec.tsx new file mode 100644 index 0000000000..c8d5301156 --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/input.spec.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Input from '../input' + +describe('WebsiteInput', () => { + const onChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render text input by default', () => { + render() + const input = screen.getByDisplayValue('hello') + expect(input).toHaveAttribute('type', 'text') + }) + + it('should render number input when isNumber is true', () => { + render() + const input = screen.getByDisplayValue('42') + expect(input).toHaveAttribute('type', 'number') + }) + + it('should call onChange with string value for text input', () => { + render() + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'new value' } }) + expect(onChange).toHaveBeenCalledWith('new value') + }) + + it('should call onChange with parsed integer for number input', () => { + render() + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '10' } }) + expect(onChange).toHaveBeenCalledWith(10) + }) + + it('should call onChange with empty string for NaN number input', () => { + render() + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } }) + expect(onChange).toHaveBeenCalledWith('') + }) + + it('should clamp negative numbers to 0', () => { + render() + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '-5' } }) + expect(onChange).toHaveBeenCalledWith(0) + }) + + it('should render placeholder', () => { + render() + expect(screen.getByPlaceholderText('Enter URL')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/create/website/base/__tests__/options-wrap.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/options-wrap.spec.tsx new file mode 100644 index 0000000000..06e62d41fb --- /dev/null +++ b/web/app/components/datasets/create/website/base/__tests__/options-wrap.spec.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import OptionsWrap from '../options-wrap' + +vi.mock('@/app/components/base/icons/src/vender/line/arrows', () => ({ + ChevronRight: (props: React.SVGProps) => , +})) + +describe('OptionsWrap', () => { + it('should render children when not folded', () => { + render( + +
Options here
+
, + ) + expect(screen.getByTestId('child-content')).toBeInTheDocument() + }) + + it('should toggle fold on click', () => { + render( + +
Options here
+
, + ) + // Initially visible + expect(screen.getByTestId('child-content')).toBeInTheDocument() + + fireEvent.click(screen.getByText('datasetCreation.stepOne.website.options')) + expect(screen.queryByTestId('child-content')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('datasetCreation.stepOne.website.options')) + expect(screen.getByTestId('child-content')).toBeInTheDocument() + }) + + it('should render options label', () => { + render( + +
Content
+
, + ) + expect(screen.getByText('datasetCreation.stepOne.website.options')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/create/website/base/url-input.spec.tsx b/web/app/components/datasets/create/website/base/__tests__/url-input.spec.tsx similarity index 86% rename from web/app/components/datasets/create/website/base/url-input.spec.tsx rename to web/app/components/datasets/create/website/base/__tests__/url-input.spec.tsx index 30d6ffcb93..5301d55307 100644 --- a/web/app/components/datasets/create/website/base/url-input.spec.tsx +++ b/web/app/components/datasets/create/website/base/__tests__/url-input.spec.tsx @@ -2,24 +2,18 @@ import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -// ============================================================================ // Component Imports (after mocks) -// ============================================================================ -import UrlInput from './url-input' +import UrlInput from '../url-input' -// ============================================================================ // Mock Setup -// ============================================================================ // Mock useDocLink hook vi.mock('@/context/i18n', () => ({ useDocLink: vi.fn(() => () => 'https://docs.example.com'), })) -// ============================================================================ // UrlInput Component Tests -// ============================================================================ describe('UrlInput', () => { const mockOnRun = vi.fn() @@ -28,9 +22,6 @@ describe('UrlInput', () => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { render() @@ -71,9 +62,6 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should update input value when user types', async () => { const user = userEvent.setup() @@ -146,9 +134,7 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- // Props Variations Tests - // -------------------------------------------------------------------------- describe('Props Variations', () => { it('should update button state when isRunning changes from false to true', () => { const { rerender } = render() @@ -190,9 +176,6 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- - // Edge Cases Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle special characters in url', async () => { const user = userEvent.setup() @@ -272,9 +255,7 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- // handleOnRun Branch Coverage Tests - // -------------------------------------------------------------------------- describe('handleOnRun Branch Coverage', () => { it('should return early when isRunning is true (branch: isRunning = true)', async () => { const user = userEvent.setup() @@ -307,9 +288,7 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- // Button Text Branch Coverage Tests - // -------------------------------------------------------------------------- describe('Button Text Branch Coverage', () => { it('should display run text when isRunning is false (branch: !isRunning = true)', () => { render() @@ -328,9 +307,6 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be memoized with React.memo', () => { const { rerender } = render() @@ -368,9 +344,6 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- - // Integration Tests - // -------------------------------------------------------------------------- describe('Integration', () => { it('should complete full workflow: type url -> click run -> verify callback', async () => { const user = userEvent.setup() @@ -381,7 +354,6 @@ describe('UrlInput', () => { const input = screen.getByRole('textbox') await user.type(input, 'https://mywebsite.com') - // Click run const button = screen.getByRole('button') await user.click(button) diff --git a/web/app/components/datasets/create/website/firecrawl/index.spec.tsx b/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx similarity index 90% rename from web/app/components/datasets/create/website/firecrawl/index.spec.tsx rename to web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx index b39fb2aab1..7df3881824 100644 --- a/web/app/components/datasets/create/website/firecrawl/index.spec.tsx +++ b/web/app/components/datasets/create/website/firecrawl/__tests__/index.spec.tsx @@ -3,15 +3,11 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -// ============================================================================ // Component Import (after mocks) -// ============================================================================ -import FireCrawl from './index' +import FireCrawl from '../index' -// ============================================================================ // Mock Setup - Only mock API calls and context -// ============================================================================ // Mock API service const mockCreateFirecrawlTask = vi.fn() @@ -38,9 +34,7 @@ vi.mock('@/context/i18n', () => ({ useDocLink: vi.fn(() => () => 'https://docs.example.com'), })) -// ============================================================================ // Test Data Factory -// ============================================================================ const createMockCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({ crawl_sub_pages: true, @@ -61,9 +55,7 @@ const createMockCrawlResultItem = (overrides: Partial = {}): Cr ...overrides, }) -// ============================================================================ // FireCrawl Component Tests -// ============================================================================ describe('FireCrawl', () => { const mockOnPreview = vi.fn() @@ -91,9 +83,6 @@ describe('FireCrawl', () => { return screen.getByPlaceholderText('https://docs.example.com') } - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { render() @@ -131,9 +120,7 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- // Configuration Button Tests - // -------------------------------------------------------------------------- describe('Configuration Button', () => { it('should call setShowAccountSettingModal when configure button is clicked', async () => { const user = userEvent.setup() @@ -148,9 +135,7 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- // URL Validation Tests - // -------------------------------------------------------------------------- describe('URL Validation', () => { it('should show error toast when URL is empty', async () => { const user = userEvent.setup() @@ -261,9 +246,7 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- // Crawl Execution Tests - // -------------------------------------------------------------------------- describe('Crawl Execution', () => { it('should call createFirecrawlTask with correct parameters', async () => { const user = userEvent.setup() @@ -372,9 +355,7 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- // Crawl Status Polling Tests - // -------------------------------------------------------------------------- describe('Crawl Status Polling', () => { it('should handle completed status', async () => { const user = userEvent.setup() @@ -508,9 +489,7 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- // Error Handling Tests - // -------------------------------------------------------------------------- describe('Error Handling', () => { it('should handle API exception during task creation', async () => { const user = userEvent.setup() @@ -594,9 +573,7 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- // Options Change Tests - // -------------------------------------------------------------------------- describe('Options Change', () => { it('should call onCrawlOptionsChange when options change', () => { render() @@ -623,9 +600,7 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- // Crawled Result Display Tests - // -------------------------------------------------------------------------- describe('Crawled Result Display', () => { it('should display CrawledResult when crawl is finished successfully', async () => { const user = userEvent.setup() @@ -686,9 +661,6 @@ describe('FireCrawl', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be memoized with React.memo', () => { const { rerender } = render() diff --git a/web/app/components/datasets/create/website/firecrawl/options.spec.tsx b/web/app/components/datasets/create/website/firecrawl/__tests__/options.spec.tsx similarity index 90% rename from web/app/components/datasets/create/website/firecrawl/options.spec.tsx rename to web/app/components/datasets/create/website/firecrawl/__tests__/options.spec.tsx index bd050ce34a..ee5b5d43e6 100644 --- a/web/app/components/datasets/create/website/firecrawl/options.spec.tsx +++ b/web/app/components/datasets/create/website/firecrawl/__tests__/options.spec.tsx @@ -1,11 +1,9 @@ import type { CrawlOptions } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Options from './options' +import Options from '../options' -// ============================================================================ // Test Data Factory -// ============================================================================ const createMockCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({ crawl_sub_pages: true, @@ -18,9 +16,7 @@ const createMockCrawlOptions = (overrides: Partial = {}): CrawlOpt ...overrides, }) -// ============================================================================ // Options Component Tests -// ============================================================================ describe('Options', () => { const mockOnChange = vi.fn() @@ -34,9 +30,6 @@ describe('Options', () => { return container.querySelectorAll('[data-testid^="checkbox-"]') } - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { const payload = createMockCrawlOptions() @@ -107,9 +100,7 @@ describe('Options', () => { }) }) - // -------------------------------------------------------------------------- // Props Display Tests - // -------------------------------------------------------------------------- describe('Props Display', () => { it('should display crawl_sub_pages checkbox with check icon when true', () => { const payload = createMockCrawlOptions({ crawl_sub_pages: true }) @@ -180,9 +171,6 @@ describe('Options', () => { }) }) - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => { const payload = createMockCrawlOptions({ crawl_sub_pages: true }) @@ -263,9 +251,6 @@ describe('Options', () => { }) }) - // -------------------------------------------------------------------------- - // Edge Cases Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle empty string values', () => { const payload = createMockCrawlOptions({ @@ -340,9 +325,7 @@ describe('Options', () => { }) }) - // -------------------------------------------------------------------------- // handleChange Callback Tests - // -------------------------------------------------------------------------- describe('handleChange Callback', () => { it('should create a new callback for each key', () => { const payload = createMockCrawlOptions() @@ -378,9 +361,6 @@ describe('Options', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be memoized with React.memo', () => { const payload = createMockCrawlOptions() diff --git a/web/app/components/datasets/create/website/jina-reader/base.spec.tsx b/web/app/components/datasets/create/website/jina-reader/__tests__/base.spec.tsx similarity index 82% rename from web/app/components/datasets/create/website/jina-reader/base.spec.tsx rename to web/app/components/datasets/create/website/jina-reader/__tests__/base.spec.tsx index 7bed7dcf45..bcfcf39060 100644 --- a/web/app/components/datasets/create/website/jina-reader/base.spec.tsx +++ b/web/app/components/datasets/create/website/jina-reader/__tests__/base.spec.tsx @@ -1,15 +1,13 @@ import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import UrlInput from './base/url-input' +import UrlInput from '../base/url-input' // Mock doc link context vi.mock('@/context/i18n', () => ({ useDocLink: () => () => 'https://docs.example.com', })) -// ============================================================================ // UrlInput Component Tests -// ============================================================================ describe('UrlInput', () => { beforeEach(() => { @@ -23,50 +21,36 @@ describe('UrlInput', () => { ...overrides, }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createUrlInputProps() - // Act render() - // Assert expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument() }) it('should render input with placeholder from docLink', () => { - // Arrange const props = createUrlInputProps() - // Act render() - // Assert const input = screen.getByRole('textbox') expect(input).toHaveAttribute('placeholder', 'https://docs.example.com') }) it('should render run button with correct text when not running', () => { - // Arrange const props = createUrlInputProps({ isRunning: false }) - // Act render() - // Assert expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument() }) it('should render button without text when running', () => { - // Arrange const props = createUrlInputProps({ isRunning: true }) - // Act render() // Assert - find button by data-testid when in loading state @@ -77,11 +61,9 @@ describe('UrlInput', () => { }) it('should show loading state on button when running', () => { - // Arrange const onRun = vi.fn() const props = createUrlInputProps({ isRunning: true, onRun }) - // Act render() // Assert - find button by data-testid when in loading state @@ -97,100 +79,77 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- // User Input Tests - // -------------------------------------------------------------------------- describe('User Input', () => { it('should update URL value when user types', async () => { - // Arrange const props = createUrlInputProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://test.com') - // Assert expect(input).toHaveValue('https://test.com') }) it('should handle URL input clearing', async () => { - // Arrange const props = createUrlInputProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://test.com') await userEvent.clear(input) - // Assert expect(input).toHaveValue('') }) it('should handle special characters in URL', async () => { - // Arrange const props = createUrlInputProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com/path?query=value&foo=bar') - // Assert expect(input).toHaveValue('https://example.com/path?query=value&foo=bar') }) }) - // -------------------------------------------------------------------------- // Button Click Tests - // -------------------------------------------------------------------------- describe('Button Click', () => { it('should call onRun with URL when button is clicked', async () => { - // Arrange const onRun = vi.fn() const props = createUrlInputProps({ onRun }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://run-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert expect(onRun).toHaveBeenCalledWith('https://run-test.com') expect(onRun).toHaveBeenCalledTimes(1) }) it('should call onRun with empty string if no URL entered', async () => { - // Arrange const onRun = vi.fn() const props = createUrlInputProps({ onRun }) - // Act render() await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert expect(onRun).toHaveBeenCalledWith('') }) it('should not call onRun when isRunning is true', async () => { - // Arrange const onRun = vi.fn() const props = createUrlInputProps({ onRun, isRunning: true }) - // Act render() const runButton = screen.getByTestId('url-input-run-button') fireEvent.click(runButton) - // Assert expect(onRun).not.toHaveBeenCalled() }) it('should not call onRun when already running', async () => { - // Arrange const onRun = vi.fn() // First render with isRunning=false, type URL, then rerender with isRunning=true @@ -210,31 +169,24 @@ describe('UrlInput', () => { }) it('should prevent multiple clicks when already running', async () => { - // Arrange const onRun = vi.fn() const props = createUrlInputProps({ onRun, isRunning: true }) - // Act render() const runButton = screen.getByTestId('url-input-run-button') fireEvent.click(runButton) fireEvent.click(runButton) fireEvent.click(runButton) - // Assert expect(onRun).not.toHaveBeenCalled() }) }) - // -------------------------------------------------------------------------- // Props Tests - // -------------------------------------------------------------------------- describe('Props', () => { it('should respond to isRunning prop change', () => { - // Arrange const props = createUrlInputProps({ isRunning: false }) - // Act const { rerender } = render() expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument() @@ -249,11 +201,9 @@ describe('UrlInput', () => { }) it('should call updated onRun callback after prop change', async () => { - // Arrange const onRun1 = vi.fn() const onRun2 = vi.fn() - // Act const { rerender } = render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://first.com') @@ -268,15 +218,11 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- // Callback Stability Tests - // -------------------------------------------------------------------------- describe('Callback Stability', () => { it('should use memoized handleUrlChange callback', async () => { - // Arrange const props = createUrlInputProps() - // Act const { rerender } = render() const input = screen.getByRole('textbox') await userEvent.type(input, 'a') @@ -290,10 +236,8 @@ describe('UrlInput', () => { }) it('should maintain URL state across rerenders', async () => { - // Arrange const props = createUrlInputProps() - // Act const { rerender } = render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://stable.com') @@ -306,58 +250,43 @@ describe('UrlInput', () => { }) }) - // -------------------------------------------------------------------------- // Component Memoization Tests - // -------------------------------------------------------------------------- describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Assert expect(UrlInput.$$typeof).toBeDefined() }) }) - // -------------------------------------------------------------------------- - // Edge Cases Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle very long URLs', async () => { - // Arrange const props = createUrlInputProps() const longUrl = `https://example.com/${'a'.repeat(1000)}` - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, longUrl) - // Assert expect(input).toHaveValue(longUrl) }) it('should handle URLs with unicode characters', async () => { - // Arrange const props = createUrlInputProps() const unicodeUrl = 'https://example.com/路径/测试' - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, unicodeUrl) - // Assert expect(input).toHaveValue(unicodeUrl) }) it('should handle rapid typing', async () => { - // Arrange const props = createUrlInputProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://rapid.com', { delay: 1 }) - // Assert expect(input).toHaveValue('https://rapid.com') }) @@ -366,7 +295,6 @@ describe('UrlInput', () => { const onRun = vi.fn() const props = createUrlInputProps({ onRun }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://enter.com') @@ -376,16 +304,13 @@ describe('UrlInput', () => { button.focus() await userEvent.keyboard('{Enter}') - // Assert expect(onRun).toHaveBeenCalledWith('https://enter.com') }) it('should handle empty URL submission', async () => { - // Arrange const onRun = vi.fn() const props = createUrlInputProps({ onRun }) - // Act render() await userEvent.click(screen.getByRole('button', { name: /run/i })) diff --git a/web/app/components/datasets/create/website/jina-reader/index.spec.tsx b/web/app/components/datasets/create/website/jina-reader/__tests__/index.spec.tsx similarity index 91% rename from web/app/components/datasets/create/website/jina-reader/index.spec.tsx rename to web/app/components/datasets/create/website/jina-reader/__tests__/index.spec.tsx index fe0e0ec3af..b8829b1042 100644 --- a/web/app/components/datasets/create/website/jina-reader/index.spec.tsx +++ b/web/app/components/datasets/create/website/jina-reader/__tests__/index.spec.tsx @@ -4,9 +4,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { checkJinaReaderTaskStatus, createJinaReaderTask } from '@/service/datasets' import { sleep } from '@/utils' -import JinaReader from './index' +import JinaReader from '../index' -// Mock external dependencies vi.mock('@/service/datasets', () => ({ createJinaReaderTask: vi.fn(), checkJinaReaderTaskStatus: vi.fn(), @@ -29,10 +28,6 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => () => 'https://docs.example.com', })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - // Note: limit and max_depth are typed as `number | string` in CrawlOptions // Tests may use number, string, or empty string values to cover all valid cases const createDefaultCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({ @@ -64,9 +59,6 @@ const createDefaultProps = (overrides: Partial[0]> ...overrides, }) -// ============================================================================ -// Rendering Tests -// ============================================================================ describe('JinaReader', () => { beforeEach(() => { vi.clearAllMocks() @@ -79,95 +71,69 @@ describe('JinaReader', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('datasetCreation.stepOne.website.jinaReaderTitle')).toBeInTheDocument() }) it('should render header with configuration button', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('datasetCreation.stepOne.website.configureJinaReader')).toBeInTheDocument() }) it('should render URL input field', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should render run button', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument() }) it('should render options section', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('datasetCreation.stepOne.website.options')).toBeInTheDocument() }) it('should render doc link to Jina Reader', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert const docLink = screen.getByRole('link') expect(docLink).toHaveAttribute('href', 'https://jina.ai/reader') }) it('should not render crawling or result components initially', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument() }) }) - // ============================================================================ - // Props Testing - // ============================================================================ describe('Props', () => { it('should call onCrawlOptionsChange when options change', async () => { - // Arrange const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) const onCrawlOptionsChange = vi.fn() const props = createDefaultProps({ onCrawlOptionsChange }) - // Act render() // Find the limit input by its associated label text @@ -181,7 +147,6 @@ describe('JinaReader', () => { await user.clear(limitInput) await user.type(limitInput, '20') - // Assert expect(onCrawlOptionsChange).toHaveBeenCalled() } } @@ -192,7 +157,6 @@ describe('JinaReader', () => { }) it('should execute crawl task when checkedCrawlResult is provided', async () => { - // Arrange const checkedItem = createCrawlResultItem({ source_url: 'https://checked.com' }) const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ @@ -208,7 +172,6 @@ describe('JinaReader', () => { checkedCrawlResult: [checkedItem], }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') @@ -221,12 +184,10 @@ describe('JinaReader', () => { }) it('should use default crawlOptions limit in validation', () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: '' }), }) - // Act render() // Assert - component renders with empty limit @@ -234,12 +195,8 @@ describe('JinaReader', () => { }) }) - // ============================================================================ - // State Management Tests - // ============================================================================ describe('State Management', () => { it('should transition from init to running state when run is clicked', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock let resolvePromise: () => void const taskPromise = new Promise((resolve) => { @@ -249,12 +206,10 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const urlInput = screen.getAllByRole('textbox')[0] await userEvent.type(urlInput, 'https://example.com') - // Click run and immediately check for crawling state const runButton = screen.getByRole('button', { name: /run/i }) fireEvent.click(runButton) @@ -271,7 +226,6 @@ describe('JinaReader', () => { }) it('should transition to finished state after successful crawl', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { @@ -284,20 +238,17 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText(/selectAll|resetAll/i)).toBeInTheDocument() }) }) it('should update crawl result state during polling', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock @@ -324,13 +275,11 @@ describe('JinaReader', () => { const onJobIdChange = vi.fn() const props = createDefaultProps({ onCheckedCrawlResultChange, onJobIdChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onJobIdChange).toHaveBeenCalledWith('test-job-123') }) @@ -341,7 +290,6 @@ describe('JinaReader', () => { }) it('should fold options when step changes from init', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { @@ -354,7 +302,6 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() // Options should be visible initially @@ -371,12 +318,9 @@ describe('JinaReader', () => { }) }) - // ============================================================================ // Side Effects and Cleanup Tests - // ============================================================================ describe('Side Effects and Cleanup', () => { it('should call sleep during polling', async () => { - // Arrange const mockSleep = sleep as Mock const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock @@ -388,20 +332,17 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockSleep).toHaveBeenCalledWith(2500) }) }) it('should update controlFoldOptions when step changes', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock let resolvePromise: () => void const taskPromise = new Promise((resolve) => { @@ -411,7 +352,6 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() // Initially options should be visible @@ -434,20 +374,15 @@ describe('JinaReader', () => { }) }) - // ============================================================================ // Callback Stability and Memoization Tests - // ============================================================================ describe('Callback Stability', () => { it('should maintain stable handleSetting callback', () => { - // Arrange const props = createDefaultProps() - // Act const { rerender } = render() const configButton = screen.getByText('datasetCreation.stepOne.website.configureJinaReader') fireEvent.click(configButton) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(1) // Rerender and click again @@ -458,13 +393,11 @@ describe('JinaReader', () => { }) it('should memoize checkValid callback based on crawlOptions', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValue({ data: { title: 'T', content: 'C', description: 'D', url: 'https://a.com' } }) const props = createDefaultProps() - // Act const { rerender } = render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') @@ -482,27 +415,21 @@ describe('JinaReader', () => { }) }) - // ============================================================================ // User Interactions and Event Handlers Tests - // ============================================================================ describe('User Interactions', () => { it('should open account settings when configuration button is clicked', async () => { - // Arrange const props = createDefaultProps() - // Act render() const configButton = screen.getByText('datasetCreation.stepOne.website.configureJinaReader') await userEvent.click(configButton) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source', }) }) it('should handle URL input and run button click', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { @@ -515,13 +442,11 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith({ url: 'https://test.com', @@ -531,7 +456,6 @@ describe('JinaReader', () => { }) it('should handle preview action on crawled result', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const onPreview = vi.fn() const crawlResultData = { @@ -545,7 +469,6 @@ describe('JinaReader', () => { const props = createDefaultProps({ onPreview }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://preview.com') @@ -556,7 +479,6 @@ describe('JinaReader', () => { expect(screen.getByText('Preview Test')).toBeInTheDocument() }) - // Click on preview button const previewButton = screen.getByText('datasetCreation.stepOne.website.preview') await userEvent.click(previewButton) @@ -564,14 +486,12 @@ describe('JinaReader', () => { }) it('should handle checkbox changes in options', async () => { - // Arrange const onCrawlOptionsChange = vi.fn() const props = createDefaultProps({ onCrawlOptionsChange, crawlOptions: createDefaultCrawlOptions({ crawl_sub_pages: false }), }) - // Act render() // Find and click the checkbox by data-testid @@ -583,23 +503,19 @@ describe('JinaReader', () => { }) it('should toggle options visibility when clicking options header', async () => { - // Arrange const props = createDefaultProps() - // Act render() // Options content should be visible initially expect(screen.getByText('datasetCreation.stepOne.website.crawlSubPage')).toBeInTheDocument() - // Click to collapse const optionsHeader = screen.getByText('datasetCreation.stepOne.website.options') await userEvent.click(optionsHeader) // Assert - options should be hidden expect(screen.queryByText('datasetCreation.stepOne.website.crawlSubPage')).not.toBeInTheDocument() - // Click to expand again await userEvent.click(optionsHeader) // Options should be visible again @@ -607,12 +523,9 @@ describe('JinaReader', () => { }) }) - // ============================================================================ // API Calls Tests - // ============================================================================ describe('API Calls', () => { it('should call createJinaReaderTask with correct parameters', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'https://api-test.com' }, @@ -621,13 +534,11 @@ describe('JinaReader', () => { const crawlOptions = createDefaultCrawlOptions({ limit: 5, max_depth: 3 }) const props = createDefaultProps({ crawlOptions }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://api-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith({ url: 'https://api-test.com', @@ -637,7 +548,6 @@ describe('JinaReader', () => { }) it('should handle direct data response from API', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const onCheckedCrawlResultChange = vi.fn() @@ -652,13 +562,11 @@ describe('JinaReader', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://direct.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([ expect.objectContaining({ @@ -670,7 +578,6 @@ describe('JinaReader', () => { }) it('should handle job_id response and poll for status', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock const onJobIdChange = vi.fn() @@ -688,13 +595,11 @@ describe('JinaReader', () => { const props = createDefaultProps({ onJobIdChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://poll-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onJobIdChange).toHaveBeenCalledWith('poll-job-123') }) @@ -705,7 +610,6 @@ describe('JinaReader', () => { }) it('should handle failed status from polling', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock @@ -717,13 +621,11 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://fail-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) @@ -732,7 +634,6 @@ describe('JinaReader', () => { }) it('should handle API error during status check', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock @@ -743,20 +644,17 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://error-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) }) it('should limit total to crawlOptions.limit', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -775,22 +673,18 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: 5 }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://limit-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalled() }) }) }) - // ============================================================================ // Component Memoization Tests - // ============================================================================ describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { // Assert - React.memo components have $$typeof Symbol(react.memo) @@ -799,15 +693,11 @@ describe('JinaReader', () => { }) }) - // ============================================================================ // Edge Cases and Error Handling Tests - // ============================================================================ describe('Edge Cases and Error Handling', () => { it('should show error for empty URL', async () => { - // Arrange const props = createDefaultProps() - // Act render() await userEvent.click(screen.getByRole('button', { name: /run/i })) @@ -818,39 +708,32 @@ describe('JinaReader', () => { }) it('should show error for invalid URL format', async () => { - // Arrange const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'invalid-url') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createJinaReaderTask).not.toHaveBeenCalled() }) }) it('should show error for URL without protocol', async () => { - // Arrange const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createJinaReaderTask).not.toHaveBeenCalled() }) }) it('should accept URL with http:// protocol', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'http://example.com' }, @@ -858,74 +741,62 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'http://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalled() }) }) it('should show error when limit is empty', async () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: '' }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createJinaReaderTask).not.toHaveBeenCalled() }) }) it('should show error when limit is null', async () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: null as unknown as number }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createJinaReaderTask).not.toHaveBeenCalled() }) }) it('should show error when limit is undefined', async () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: undefined as unknown as number }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createJinaReaderTask).not.toHaveBeenCalled() }) }) it('should handle API throwing an exception', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockRejectedValueOnce(new Error('Network error')) // Suppress console output during test to avoid noisy logs @@ -933,13 +804,11 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://exception-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) @@ -948,7 +817,6 @@ describe('JinaReader', () => { }) it('should handle status response without status field', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock @@ -960,20 +828,17 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://no-status-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) }) it('should show unknown error when error message is empty', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock @@ -985,20 +850,17 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://empty-error-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.unknownError')).toBeInTheDocument() }) }) it('should handle empty data array from API', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1013,20 +875,17 @@ describe('JinaReader', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://empty-data-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([]) }) }) it('should handle null data from running status', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1048,20 +907,17 @@ describe('JinaReader', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://null-data-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([]) }) }) it('should return empty array when completed job has undefined data', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1076,20 +932,17 @@ describe('JinaReader', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://undefined-data-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([]) }) }) it('should show zero current progress when crawlResult is not yet available', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock let resolveCheckStatus: () => void @@ -1104,7 +957,6 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: 10 }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://zero-current-test.com') @@ -1123,7 +975,6 @@ describe('JinaReader', () => { }) it('should show 0/0 progress when limit is zero string', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock let resolveCheckStatus: () => void @@ -1138,7 +989,6 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: '0' }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://zero-total-test.com') @@ -1157,7 +1007,6 @@ describe('JinaReader', () => { }) it('should complete successfully when result data is undefined', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1173,7 +1022,6 @@ describe('JinaReader', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://undefined-result-data-test.com') @@ -1186,7 +1034,6 @@ describe('JinaReader', () => { }) it('should use limit as total when crawlResult total is not available', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock let resolveCheckStatus: () => void @@ -1201,7 +1048,6 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: 15 }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://no-total-test.com') @@ -1220,7 +1066,6 @@ describe('JinaReader', () => { }) it('should fallback to limit when crawlResult has zero total', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock let resolveCheckStatus: () => void @@ -1242,7 +1087,6 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: 5 }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://both-zero-test.com') @@ -1261,7 +1105,6 @@ describe('JinaReader', () => { }) it('should construct result item from direct data response', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1276,7 +1119,6 @@ describe('JinaReader', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://direct-array.com') @@ -1294,12 +1136,8 @@ describe('JinaReader', () => { }) }) - // ============================================================================ - // All Prop Variations Tests - // ============================================================================ describe('Prop Variations', () => { it('should handle different limit values in crawlOptions', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'https://limit.com' }, @@ -1309,13 +1147,11 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: 100 }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://limit.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1326,7 +1162,6 @@ describe('JinaReader', () => { }) it('should handle different max_depth values', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'https://depth.com' }, @@ -1336,13 +1171,11 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ max_depth: 5 }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://depth.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1353,7 +1186,6 @@ describe('JinaReader', () => { }) it('should handle crawl_sub_pages disabled', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'https://nosub.com' }, @@ -1363,13 +1195,11 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ crawl_sub_pages: false }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://nosub.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1380,7 +1210,6 @@ describe('JinaReader', () => { }) it('should handle use_sitemap enabled', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'https://sitemap.com' }, @@ -1390,13 +1219,11 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ use_sitemap: true }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://sitemap.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1407,7 +1234,6 @@ describe('JinaReader', () => { }) it('should handle includes and excludes patterns', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'https://patterns.com' }, @@ -1420,13 +1246,11 @@ describe('JinaReader', () => { }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://patterns.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1440,7 +1264,6 @@ describe('JinaReader', () => { }) it('should handle pre-selected crawl results', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const existingResult = createCrawlResultItem({ source_url: 'https://existing.com' }) @@ -1452,20 +1275,17 @@ describe('JinaReader', () => { checkedCrawlResult: [existingResult], }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://new.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalled() }) }) it('should handle string type limit value', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ data: { title: 'T', content: 'C', description: 'D', url: 'https://string-limit.com' }, @@ -1475,25 +1295,20 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: '25' }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://string-limit.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalled() }) }) }) - // ============================================================================ // Display and UI State Tests - // ============================================================================ describe('Display and UI States', () => { it('should show crawling progress during running state', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock let resolveCheckStatus: () => void @@ -1508,13 +1323,11 @@ describe('JinaReader', () => { crawlOptions: createDefaultCrawlOptions({ limit: 10 }), }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://progress.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument() }) @@ -1527,7 +1340,6 @@ describe('JinaReader', () => { }) it('should display time consumed after crawl completion', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ @@ -1536,20 +1348,17 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://time.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText(/scrapTimeInfo/i)).toBeInTheDocument() }) }) it('should display crawled results list after completion', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockResolvedValueOnce({ @@ -1563,20 +1372,17 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://result.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('Result Page')).toBeInTheDocument() }) }) it('should show error message component when crawl fails', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock mockCreateTask.mockRejectedValueOnce(new Error('Failed')) @@ -1585,25 +1391,19 @@ describe('JinaReader', () => { const props = createDefaultProps() - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://fail.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) }) }) - // ============================================================================ - // Integration Tests - // ============================================================================ describe('Integration', () => { it('should complete full crawl workflow with job polling', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const mockCheckStatus = checkJinaReaderTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1641,7 +1441,6 @@ describe('JinaReader', () => { onPreview, }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://full-workflow.com') @@ -1668,7 +1467,6 @@ describe('JinaReader', () => { }) it('should handle select all and deselect all in results', async () => { - // Arrange const mockCreateTask = createJinaReaderTask as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1678,7 +1476,6 @@ describe('JinaReader', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByRole('textbox') await userEvent.type(input, 'https://single.com') @@ -1689,11 +1486,9 @@ describe('JinaReader', () => { expect(screen.getByText('Single')).toBeInTheDocument() }) - // Click select all/reset all const selectAllCheckbox = screen.getByText(/selectAll|resetAll/i) await userEvent.click(selectAllCheckbox) - // Assert expect(onCheckedCrawlResultChange).toHaveBeenCalled() }) }) diff --git a/web/app/components/datasets/create/website/jina-reader/__tests__/options.spec.tsx b/web/app/components/datasets/create/website/jina-reader/__tests__/options.spec.tsx new file mode 100644 index 0000000000..570332aae3 --- /dev/null +++ b/web/app/components/datasets/create/website/jina-reader/__tests__/options.spec.tsx @@ -0,0 +1,191 @@ +import type { CrawlOptions } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Options from '../options' + +// Test Data Factory + +const createMockCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({ + crawl_sub_pages: true, + limit: 10, + max_depth: 2, + excludes: '', + includes: '', + only_main_content: false, + use_sitemap: false, + ...overrides, +}) + +// Jina Reader Options Component Tests + +describe('Options (jina-reader)', () => { + const mockOnChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + const getCheckboxes = (container: HTMLElement) => { + return container.querySelectorAll('[data-testid^="checkbox-"]') + } + + describe('Rendering', () => { + it('should render crawlSubPage and useSitemap checkboxes and limit field', () => { + const payload = createMockCrawlOptions() + render() + + expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument() + expect(screen.getByText(/useSitemap/i)).toBeInTheDocument() + expect(screen.getByText(/limit/i)).toBeInTheDocument() + }) + + it('should render two checkboxes', () => { + const payload = createMockCrawlOptions() + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes.length).toBe(2) + }) + + it('should render limit field with required indicator', () => { + const payload = createMockCrawlOptions() + render() + + const requiredIndicator = screen.getByText('*') + expect(requiredIndicator).toBeInTheDocument() + }) + + it('should render with custom className', () => { + const payload = createMockCrawlOptions() + const { container } = render( + , + ) + + const rootElement = container.firstChild as HTMLElement + expect(rootElement).toHaveClass('custom-class') + }) + }) + + // Props Display Tests + describe('Props Display', () => { + it('should display crawl_sub_pages checkbox with check icon when true', () => { + const payload = createMockCrawlOptions({ crawl_sub_pages: true }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[0].querySelector('svg')).toBeInTheDocument() + }) + + it('should display crawl_sub_pages checkbox without check icon when false', () => { + const payload = createMockCrawlOptions({ crawl_sub_pages: false }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[0].querySelector('svg')).not.toBeInTheDocument() + }) + + it('should display use_sitemap checkbox with check icon when true', () => { + const payload = createMockCrawlOptions({ use_sitemap: true }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[1].querySelector('svg')).toBeInTheDocument() + }) + + it('should display use_sitemap checkbox without check icon when false', () => { + const payload = createMockCrawlOptions({ use_sitemap: false }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[1].querySelector('svg')).not.toBeInTheDocument() + }) + + it('should display limit value in input', () => { + const payload = createMockCrawlOptions({ limit: 25 }) + render() + + expect(screen.getByDisplayValue('25')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => { + const payload = createMockCrawlOptions({ crawl_sub_pages: true }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + fireEvent.click(checkboxes[0]) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + crawl_sub_pages: false, + }) + }) + + it('should call onChange with updated use_sitemap when checkbox is clicked', () => { + const payload = createMockCrawlOptions({ use_sitemap: false }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + fireEvent.click(checkboxes[1]) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + use_sitemap: true, + }) + }) + + it('should call onChange with updated limit when input changes', () => { + const payload = createMockCrawlOptions({ limit: 10 }) + render() + + const limitInput = screen.getByDisplayValue('10') + fireEvent.change(limitInput, { target: { value: '50' } }) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + limit: 50, + }) + }) + }) + + describe('Edge Cases', () => { + it('should handle zero limit value', () => { + const payload = createMockCrawlOptions({ limit: 0 }) + render() + + const zeroInputs = screen.getAllByDisplayValue('0') + expect(zeroInputs.length).toBeGreaterThanOrEqual(1) + }) + + it('should preserve other payload fields when updating one field', () => { + const payload = createMockCrawlOptions({ + crawl_sub_pages: true, + limit: 10, + use_sitemap: true, + }) + render() + + const limitInput = screen.getByDisplayValue('10') + fireEvent.change(limitInput, { target: { value: '20' } }) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + limit: 20, + }) + }) + }) + + describe('Memoization', () => { + it('should re-render when payload changes', () => { + const payload1 = createMockCrawlOptions({ limit: 10 }) + const payload2 = createMockCrawlOptions({ limit: 20 }) + + const { rerender } = render() + expect(screen.getByDisplayValue('10')).toBeInTheDocument() + + rerender() + expect(screen.getByDisplayValue('20')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/create/website/jina-reader/base/__tests__/url-input.spec.tsx b/web/app/components/datasets/create/website/jina-reader/base/__tests__/url-input.spec.tsx new file mode 100644 index 0000000000..296d5c091b --- /dev/null +++ b/web/app/components/datasets/create/website/jina-reader/base/__tests__/url-input.spec.tsx @@ -0,0 +1,192 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Component Imports (after mocks) + +import UrlInput from '../url-input' + +// Mock Setup + +vi.mock('@/context/i18n', () => ({ + useDocLink: vi.fn(() => () => 'https://docs.example.com'), +})) + +// Jina Reader UrlInput Component Tests + +describe('UrlInput (jina-reader)', () => { + const mockOnRun = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render input and run button', () => { + render() + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should render input with placeholder from docLink', () => { + render() + const input = screen.getByRole('textbox') + expect(input).toHaveAttribute('placeholder', 'https://docs.example.com') + }) + + it('should show run text when not running', () => { + render() + const button = screen.getByRole('button') + expect(button).toHaveTextContent(/run/i) + }) + + it('should hide run text when running', () => { + render() + const button = screen.getByRole('button') + expect(button).not.toHaveTextContent(/run/i) + }) + + it('should show loading state on button when running', () => { + render() + const button = screen.getByRole('button') + expect(button).toHaveTextContent(/loading/i) + }) + + it('should not show loading state on button when not running', () => { + render() + const button = screen.getByRole('button') + expect(button).not.toHaveTextContent(/loading/i) + }) + }) + + describe('User Interactions', () => { + it('should update url when user types in input', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'https://example.com') + + expect(input).toHaveValue('https://example.com') + }) + + it('should call onRun with url when run button clicked and not running', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'https://example.com') + + const button = screen.getByRole('button') + await user.click(button) + + expect(mockOnRun).toHaveBeenCalledWith('https://example.com') + expect(mockOnRun).toHaveBeenCalledTimes(1) + }) + + it('should NOT call onRun when isRunning is true', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'https://example.com' } }) + + const button = screen.getByRole('button') + await user.click(button) + + expect(mockOnRun).not.toHaveBeenCalled() + }) + + it('should call onRun with empty string when button clicked with empty input', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button') + await user.click(button) + + expect(mockOnRun).toHaveBeenCalledWith('') + }) + }) + + // Props Variations Tests + describe('Props Variations', () => { + it('should update button state when isRunning changes from false to true', () => { + const { rerender } = render() + + expect(screen.getByRole('button')).toHaveTextContent(/run/i) + + rerender() + + expect(screen.getByRole('button')).not.toHaveTextContent(/run/i) + }) + + it('should preserve input value when isRunning prop changes', async () => { + const user = userEvent.setup() + const { rerender } = render() + + const input = screen.getByRole('textbox') + await user.type(input, 'https://preserved.com') + expect(input).toHaveValue('https://preserved.com') + + rerender() + expect(input).toHaveValue('https://preserved.com') + }) + }) + + describe('Edge Cases', () => { + it('should handle special characters in url', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + const specialUrl = 'https://example.com/path?query=test¶m=value#anchor' + await user.type(input, specialUrl) + + const button = screen.getByRole('button') + await user.click(button) + + expect(mockOnRun).toHaveBeenCalledWith(specialUrl) + }) + + it('should handle rapid input changes', () => { + render() + + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'a' } }) + fireEvent.change(input, { target: { value: 'ab' } }) + fireEvent.change(input, { target: { value: 'https://final.com' } }) + + expect(input).toHaveValue('https://final.com') + + fireEvent.click(screen.getByRole('button')) + expect(mockOnRun).toHaveBeenCalledWith('https://final.com') + }) + }) + + describe('Integration', () => { + it('should complete full workflow: type url -> click run -> verify callback', async () => { + const user = userEvent.setup() + render() + + const input = screen.getByRole('textbox') + await user.type(input, 'https://mywebsite.com') + + const button = screen.getByRole('button') + await user.click(button) + + expect(mockOnRun).toHaveBeenCalledWith('https://mywebsite.com') + }) + + it('should show correct states during running workflow', () => { + const { rerender } = render() + + expect(screen.getByRole('button')).toHaveTextContent(/run/i) + + rerender() + expect(screen.getByRole('button')).not.toHaveTextContent(/run/i) + + rerender() + expect(screen.getByRole('button')).toHaveTextContent(/run/i) + }) + }) +}) diff --git a/web/app/components/datasets/create/website/watercrawl/index.spec.tsx b/web/app/components/datasets/create/website/watercrawl/__tests__/index.spec.tsx similarity index 91% rename from web/app/components/datasets/create/website/watercrawl/index.spec.tsx rename to web/app/components/datasets/create/website/watercrawl/__tests__/index.spec.tsx index c3caab895a..5ff2d8efb8 100644 --- a/web/app/components/datasets/create/website/watercrawl/index.spec.tsx +++ b/web/app/components/datasets/create/website/watercrawl/__tests__/index.spec.tsx @@ -7,9 +7,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { checkWatercrawlTaskStatus, createWatercrawlTask } from '@/service/datasets' import { sleep } from '@/utils' -import WaterCrawl from './index' +import WaterCrawl from '../index' -// Mock external dependencies vi.mock('@/service/datasets', () => ({ createWatercrawlTask: vi.fn(), checkWatercrawlTaskStatus: vi.fn(), @@ -32,10 +31,6 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => (path?: string) => path ? `https://docs.dify.ai/en${path}` : 'https://docs.dify.ai/en/', })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - // Note: limit and max_depth are typed as `number | string` in CrawlOptions // Tests may use number, string, or empty string values to cover all valid cases const createDefaultCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({ @@ -67,9 +62,6 @@ const createDefaultProps = (overrides: Partial[0]> ...overrides, }) -// ============================================================================ -// Rendering Tests -// ============================================================================ describe('WaterCrawl', () => { beforeEach(() => { vi.clearAllMocks() @@ -84,32 +76,24 @@ describe('WaterCrawl', () => { // Tests for initial component rendering describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('datasetCreation.stepOne.website.watercrawlTitle')).toBeInTheDocument() }) it('should render header with configuration button', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('datasetCreation.stepOne.website.configureWatercrawl')).toBeInTheDocument() }) it('should render URL input field', () => { - // Arrange const props = createDefaultProps() - // Act render() // Assert - URL input has specific placeholder @@ -117,62 +101,45 @@ describe('WaterCrawl', () => { }) it('should render run button', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument() }) it('should render options section', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('datasetCreation.stepOne.website.options')).toBeInTheDocument() }) it('should render doc link to WaterCrawl', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert const docLink = screen.getByRole('link') expect(docLink).toHaveAttribute('href', 'https://docs.watercrawl.dev/') }) it('should not render crawling or result components initially', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument() }) }) - // ============================================================================ - // Props Testing - // ============================================================================ describe('Props', () => { it('should call onCrawlOptionsChange when options change', async () => { - // Arrange const user = userEvent.setup() const onCrawlOptionsChange = vi.fn() const props = createDefaultProps({ onCrawlOptionsChange }) - // Act render() // Find the limit input by its associated label text @@ -186,7 +153,6 @@ describe('WaterCrawl', () => { await user.clear(limitInput) await user.type(limitInput, '20') - // Assert expect(onCrawlOptionsChange).toHaveBeenCalled() } } @@ -197,7 +163,6 @@ describe('WaterCrawl', () => { }) it('should execute crawl task when checkedCrawlResult is provided', async () => { - // Arrange const checkedItem = createCrawlResultItem({ source_url: 'https://checked.com' }) const mockCreateTask = createWatercrawlTask as Mock mockCreateTask.mockResolvedValueOnce({ job_id: 'test-job' }) @@ -214,7 +179,6 @@ describe('WaterCrawl', () => { checkedCrawlResult: [checkedItem], }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') @@ -227,12 +191,10 @@ describe('WaterCrawl', () => { }) it('should use default crawlOptions limit in validation', () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: '' }), }) - // Act render() // Assert - component renders with empty limit @@ -240,12 +202,8 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ - // State Management Tests - // ============================================================================ describe('State Management', () => { it('should transition from init to running state when run is clicked', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock let resolvePromise: () => void mockCreateTask.mockImplementation(() => new Promise((resolve) => { @@ -254,12 +212,10 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const urlInput = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(urlInput, 'https://example.com') - // Click run and immediately check for crawling state const runButton = screen.getByRole('button', { name: /run/i }) fireEvent.click(runButton) @@ -273,7 +229,6 @@ describe('WaterCrawl', () => { }) it('should transition to finished state after successful crawl', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -287,20 +242,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText(/selectAll|resetAll/i)).toBeInTheDocument() }) }) it('should update crawl result state during polling', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -327,13 +279,11 @@ describe('WaterCrawl', () => { const onJobIdChange = vi.fn() const props = createDefaultProps({ onCheckedCrawlResultChange, onJobIdChange }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onJobIdChange).toHaveBeenCalledWith('test-job-123') }) @@ -344,7 +294,6 @@ describe('WaterCrawl', () => { }) it('should fold options when step changes from init', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -358,7 +307,6 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() // Options should be visible initially @@ -375,12 +323,9 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ // Side Effects and Cleanup Tests - // ============================================================================ describe('Side Effects and Cleanup', () => { it('should call sleep during polling', async () => { - // Arrange const mockSleep = sleep as Mock const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -392,26 +337,22 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockSleep).toHaveBeenCalledWith(2500) }) }) it('should update controlFoldOptions when step changes', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock mockCreateTask.mockImplementation(() => new Promise(() => { /* pending */ })) const props = createDefaultProps() - // Act render() // Initially options should be visible @@ -428,20 +369,15 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ // Callback Stability and Memoization Tests - // ============================================================================ describe('Callback Stability', () => { it('should maintain stable handleSetting callback', () => { - // Arrange const props = createDefaultProps() - // Act const { rerender } = render() const configButton = screen.getByText('datasetCreation.stepOne.website.configureWatercrawl') fireEvent.click(configButton) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(1) // Rerender and click again @@ -452,7 +388,6 @@ describe('WaterCrawl', () => { }) it('should memoize checkValid callback based on crawlOptions', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -466,7 +401,6 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act const { rerender } = render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') @@ -484,27 +418,21 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ // User Interactions and Event Handlers Tests - // ============================================================================ describe('User Interactions', () => { it('should open account settings when configuration button is clicked', async () => { - // Arrange const props = createDefaultProps() - // Act render() const configButton = screen.getByText('datasetCreation.stepOne.website.configureWatercrawl') await userEvent.click(configButton) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source', }) }) it('should handle URL input and run button click', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -518,13 +446,11 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith({ url: 'https://test.com', @@ -534,7 +460,6 @@ describe('WaterCrawl', () => { }) it('should handle preview action on crawled result', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onPreview = vi.fn() @@ -549,7 +474,6 @@ describe('WaterCrawl', () => { const props = createDefaultProps({ onPreview }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://preview.com') @@ -560,7 +484,6 @@ describe('WaterCrawl', () => { expect(screen.getByText('Preview Test')).toBeInTheDocument() }) - // Click on preview button const previewButton = screen.getByText('datasetCreation.stepOne.website.preview') await userEvent.click(previewButton) @@ -568,14 +491,12 @@ describe('WaterCrawl', () => { }) it('should handle checkbox changes in options', async () => { - // Arrange const onCrawlOptionsChange = vi.fn() const props = createDefaultProps({ onCrawlOptionsChange, crawlOptions: createDefaultCrawlOptions({ crawl_sub_pages: false }), }) - // Act render() // Find and click the checkbox by data-testid @@ -587,23 +508,19 @@ describe('WaterCrawl', () => { }) it('should toggle options visibility when clicking options header', async () => { - // Arrange const props = createDefaultProps() - // Act render() // Options content should be visible initially expect(screen.getByText('datasetCreation.stepOne.website.crawlSubPage')).toBeInTheDocument() - // Click to collapse const optionsHeader = screen.getByText('datasetCreation.stepOne.website.options') await userEvent.click(optionsHeader) // Assert - options should be hidden expect(screen.queryByText('datasetCreation.stepOne.website.crawlSubPage')).not.toBeInTheDocument() - // Click to expand again await userEvent.click(optionsHeader) // Options should be visible again @@ -611,12 +528,9 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ // API Calls Tests - // ============================================================================ describe('API Calls', () => { it('should call createWatercrawlTask with correct parameters', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -631,13 +545,11 @@ describe('WaterCrawl', () => { const crawlOptions = createDefaultCrawlOptions({ limit: 5, max_depth: 3 }) const props = createDefaultProps({ crawlOptions }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://api-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith({ url: 'https://api-test.com', @@ -647,7 +559,6 @@ describe('WaterCrawl', () => { }) it('should delete max_depth from options when it is empty string', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -662,7 +573,6 @@ describe('WaterCrawl', () => { const crawlOptions = createDefaultCrawlOptions({ max_depth: '' }) const props = createDefaultProps({ crawlOptions }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://test.com') @@ -676,7 +586,6 @@ describe('WaterCrawl', () => { }) it('should poll for status with job_id', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onJobIdChange = vi.fn() @@ -694,13 +603,11 @@ describe('WaterCrawl', () => { const props = createDefaultProps({ onJobIdChange }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://poll-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onJobIdChange).toHaveBeenCalledWith('poll-job-123') }) @@ -711,7 +618,6 @@ describe('WaterCrawl', () => { }) it('should handle error status from polling', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -723,13 +629,11 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://fail-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) @@ -738,7 +642,6 @@ describe('WaterCrawl', () => { }) it('should handle API error during status check', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -749,20 +652,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://error-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) }) it('should limit total to crawlOptions.limit', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -781,20 +681,17 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: 5 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://limit-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalled() }) }) it('should handle response without status field as error', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -806,22 +703,18 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://no-status-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) }) }) - // ============================================================================ // Component Memoization Tests - // ============================================================================ describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { // Assert - React.memo components have $$typeof Symbol(react.memo) @@ -830,15 +723,11 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ // Edge Cases and Error Handling Tests - // ============================================================================ describe('Edge Cases and Error Handling', () => { it('should show error for empty URL', async () => { - // Arrange const props = createDefaultProps() - // Act render() await userEvent.click(screen.getByRole('button', { name: /run/i })) @@ -849,39 +738,32 @@ describe('WaterCrawl', () => { }) it('should show error for invalid URL format', async () => { - // Arrange const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'invalid-url') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createWatercrawlTask).not.toHaveBeenCalled() }) }) it('should show error for URL without protocol', async () => { - // Arrange const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createWatercrawlTask).not.toHaveBeenCalled() }) }) it('should accept URL with http:// protocol', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -895,74 +777,62 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'http://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalled() }) }) it('should show error when limit is empty', async () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: '' }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createWatercrawlTask).not.toHaveBeenCalled() }) }) it('should show error when limit is null', async () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: null as unknown as number }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createWatercrawlTask).not.toHaveBeenCalled() }) }) it('should show error when limit is undefined', async () => { - // Arrange const props = createDefaultProps({ crawlOptions: createDefaultCrawlOptions({ limit: undefined as unknown as number }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://example.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(createWatercrawlTask).not.toHaveBeenCalled() }) }) it('should handle API throwing an exception', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock mockCreateTask.mockRejectedValueOnce(new Error('Network error')) // Suppress console output during test to avoid noisy logs @@ -970,13 +840,11 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://exception-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) @@ -985,7 +853,6 @@ describe('WaterCrawl', () => { }) it('should show unknown error when error message is empty', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -997,20 +864,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://empty-error-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.unknownError')).toBeInTheDocument() }) }) it('should handle empty data array from API', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1025,20 +889,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://empty-data-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([]) }) }) it('should handle null data from running status', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1060,20 +921,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://null-data-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([]) }) }) it('should handle undefined data from completed job polling', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1088,20 +946,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://undefined-data-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(onCheckedCrawlResultChange).toHaveBeenCalledWith([]) }) }) it('should handle crawlResult with zero current value', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1112,7 +967,6 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: 10 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://zero-current-test.com') @@ -1125,7 +979,6 @@ describe('WaterCrawl', () => { }) it('should handle crawlResult with zero total and empty limit', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1136,7 +989,6 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: '0' }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://zero-total-test.com') @@ -1149,7 +1001,6 @@ describe('WaterCrawl', () => { }) it('should handle undefined crawlResult data in finished state', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1165,7 +1016,6 @@ describe('WaterCrawl', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://undefined-result-data-test.com') @@ -1178,7 +1028,6 @@ describe('WaterCrawl', () => { }) it('should use parseFloat fallback when crawlResult.total is undefined', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1189,7 +1038,6 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: 15 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://no-total-test.com') @@ -1202,7 +1050,6 @@ describe('WaterCrawl', () => { }) it('should handle crawlResult with current=0 and total=0 during running', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1220,25 +1067,19 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: 5 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://both-zero-test.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText(/totalPageScraped/)).toBeInTheDocument() }) }) }) - // ============================================================================ - // All Prop Variations Tests - // ============================================================================ describe('Prop Variations', () => { it('should handle different limit values in crawlOptions', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1254,13 +1095,11 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: 100 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://limit.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1271,7 +1110,6 @@ describe('WaterCrawl', () => { }) it('should handle different max_depth values', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1287,13 +1125,11 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ max_depth: 5 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://depth.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1304,7 +1140,6 @@ describe('WaterCrawl', () => { }) it('should handle crawl_sub_pages disabled', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1320,13 +1155,11 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ crawl_sub_pages: false }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://nosub.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1337,7 +1170,6 @@ describe('WaterCrawl', () => { }) it('should handle use_sitemap enabled', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1353,13 +1185,11 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ use_sitemap: true }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://sitemap.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1370,7 +1200,6 @@ describe('WaterCrawl', () => { }) it('should handle includes and excludes patterns', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1389,13 +1218,11 @@ describe('WaterCrawl', () => { }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://patterns.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1409,7 +1236,6 @@ describe('WaterCrawl', () => { }) it('should handle pre-selected crawl results', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const existingResult = createCrawlResultItem({ source_url: 'https://existing.com' }) @@ -1426,20 +1252,17 @@ describe('WaterCrawl', () => { checkedCrawlResult: [existingResult], }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://new.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalled() }) }) it('should handle string type limit value', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1455,20 +1278,17 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: '25' }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://string-limit.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalled() }) }) it('should handle only_main_content option', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1484,13 +1304,11 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ only_main_content: false }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://main-content.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(mockCreateTask).toHaveBeenCalledWith( expect.objectContaining({ @@ -1501,12 +1319,9 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ // Display and UI State Tests - // ============================================================================ describe('Display and UI States', () => { it('should show crawling progress during running state', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1517,20 +1332,17 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: 10 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://progress.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument() }) }) it('should display time consumed after crawl completion', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1545,20 +1357,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://time.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText(/scrapTimeInfo/i)).toBeInTheDocument() }) }) it('should display crawled results list after completion', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock @@ -1572,20 +1381,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://result.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('Result Page')).toBeInTheDocument() }) }) it('should show error message component when crawl fails', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock mockCreateTask.mockRejectedValueOnce(new Error('Failed')) @@ -1594,20 +1400,17 @@ describe('WaterCrawl', () => { const props = createDefaultProps() - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://fail.com') await userEvent.click(screen.getByRole('button', { name: /run/i })) - // Assert await waitFor(() => { expect(screen.getByText('datasetCreation.stepOne.website.exceptionErrorTitle')).toBeInTheDocument() }) }) it('should update progress during multiple polling iterations', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1643,7 +1446,6 @@ describe('WaterCrawl', () => { crawlOptions: createDefaultCrawlOptions({ limit: 10 }), }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://multi-poll.com') @@ -1665,12 +1467,8 @@ describe('WaterCrawl', () => { }) }) - // ============================================================================ - // Integration Tests - // ============================================================================ describe('Integration', () => { it('should complete full crawl workflow with job polling', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1708,7 +1506,6 @@ describe('WaterCrawl', () => { onPreview, }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://full-workflow.com') @@ -1735,7 +1532,6 @@ describe('WaterCrawl', () => { }) it('should handle select all and deselect all in results', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onCheckedCrawlResultChange = vi.fn() @@ -1750,7 +1546,6 @@ describe('WaterCrawl', () => { const props = createDefaultProps({ onCheckedCrawlResultChange }) - // Act render() const input = screen.getByPlaceholderText('https://docs.dify.ai/en/') await userEvent.type(input, 'https://single.com') @@ -1761,16 +1556,13 @@ describe('WaterCrawl', () => { expect(screen.getByText('Single')).toBeInTheDocument() }) - // Click select all/reset all const selectAllCheckbox = screen.getByText(/selectAll|resetAll/i) await userEvent.click(selectAllCheckbox) - // Assert expect(onCheckedCrawlResultChange).toHaveBeenCalled() }) it('should handle complete workflow from input to preview', async () => { - // Arrange const mockCreateTask = createWatercrawlTask as Mock const mockCheckStatus = checkWatercrawlTaskStatus as Mock const onPreview = vi.fn() @@ -1796,7 +1588,6 @@ describe('WaterCrawl', () => { onJobIdChange, }) - // Act render() // Step 1: Enter URL @@ -1815,7 +1606,6 @@ describe('WaterCrawl', () => { const previewButton = screen.getByText('datasetCreation.stepOne.website.preview') await userEvent.click(previewButton) - // Assert expect(onJobIdChange).toHaveBeenCalledWith('preview-workflow-job') expect(onCheckedCrawlResultChange).toHaveBeenCalled() expect(onPreview).toHaveBeenCalled() diff --git a/web/app/components/datasets/create/website/watercrawl/__tests__/options.spec.tsx b/web/app/components/datasets/create/website/watercrawl/__tests__/options.spec.tsx new file mode 100644 index 0000000000..20843db82f --- /dev/null +++ b/web/app/components/datasets/create/website/watercrawl/__tests__/options.spec.tsx @@ -0,0 +1,276 @@ +import type { CrawlOptions } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Options from '../options' + +// Test Data Factory + +const createMockCrawlOptions = (overrides: Partial = {}): CrawlOptions => ({ + crawl_sub_pages: true, + limit: 10, + max_depth: 2, + excludes: '', + includes: '', + only_main_content: false, + use_sitemap: false, + ...overrides, +}) + +// WaterCrawl Options Component Tests + +describe('Options (watercrawl)', () => { + const mockOnChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + const getCheckboxes = (container: HTMLElement) => { + return container.querySelectorAll('[data-testid^="checkbox-"]') + } + + describe('Rendering', () => { + it('should render all form fields', () => { + const payload = createMockCrawlOptions() + render() + + expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument() + expect(screen.getByText(/extractOnlyMainContent/i)).toBeInTheDocument() + expect(screen.getByText(/limit/i)).toBeInTheDocument() + expect(screen.getByText(/maxDepth/i)).toBeInTheDocument() + expect(screen.getByText(/excludePaths/i)).toBeInTheDocument() + expect(screen.getByText(/includeOnlyPaths/i)).toBeInTheDocument() + }) + + it('should render two checkboxes', () => { + const payload = createMockCrawlOptions() + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes.length).toBe(2) + }) + + it('should render limit field with required indicator', () => { + const payload = createMockCrawlOptions() + render() + + const requiredIndicator = screen.getByText('*') + expect(requiredIndicator).toBeInTheDocument() + }) + + it('should render placeholder for excludes field', () => { + const payload = createMockCrawlOptions() + render() + + expect(screen.getByPlaceholderText('blog/*, /about/*')).toBeInTheDocument() + }) + + it('should render placeholder for includes field', () => { + const payload = createMockCrawlOptions() + render() + + expect(screen.getByPlaceholderText('articles/*')).toBeInTheDocument() + }) + + it('should render with custom className', () => { + const payload = createMockCrawlOptions() + const { container } = render( + , + ) + + const rootElement = container.firstChild as HTMLElement + expect(rootElement).toHaveClass('custom-class') + }) + }) + + // Props Display Tests + describe('Props Display', () => { + it('should display crawl_sub_pages checkbox with check icon when true', () => { + const payload = createMockCrawlOptions({ crawl_sub_pages: true }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[0].querySelector('svg')).toBeInTheDocument() + }) + + it('should display crawl_sub_pages checkbox without check icon when false', () => { + const payload = createMockCrawlOptions({ crawl_sub_pages: false }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[0].querySelector('svg')).not.toBeInTheDocument() + }) + + it('should display only_main_content checkbox with check icon when true', () => { + const payload = createMockCrawlOptions({ only_main_content: true }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[1].querySelector('svg')).toBeInTheDocument() + }) + + it('should display only_main_content checkbox without check icon when false', () => { + const payload = createMockCrawlOptions({ only_main_content: false }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + expect(checkboxes[1].querySelector('svg')).not.toBeInTheDocument() + }) + + it('should display limit value in input', () => { + const payload = createMockCrawlOptions({ limit: 25 }) + render() + + expect(screen.getByDisplayValue('25')).toBeInTheDocument() + }) + + it('should display max_depth value in input', () => { + const payload = createMockCrawlOptions({ max_depth: 5 }) + render() + + expect(screen.getByDisplayValue('5')).toBeInTheDocument() + }) + + it('should display excludes value in input', () => { + const payload = createMockCrawlOptions({ excludes: 'test/*' }) + render() + + expect(screen.getByDisplayValue('test/*')).toBeInTheDocument() + }) + + it('should display includes value in input', () => { + const payload = createMockCrawlOptions({ includes: 'docs/*' }) + render() + + expect(screen.getByDisplayValue('docs/*')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => { + const payload = createMockCrawlOptions({ crawl_sub_pages: true }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + fireEvent.click(checkboxes[0]) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + crawl_sub_pages: false, + }) + }) + + it('should call onChange with updated only_main_content when checkbox is clicked', () => { + const payload = createMockCrawlOptions({ only_main_content: false }) + const { container } = render() + + const checkboxes = getCheckboxes(container) + fireEvent.click(checkboxes[1]) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + only_main_content: true, + }) + }) + + it('should call onChange with updated limit when input changes', () => { + const payload = createMockCrawlOptions({ limit: 10 }) + render() + + const limitInput = screen.getByDisplayValue('10') + fireEvent.change(limitInput, { target: { value: '50' } }) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + limit: 50, + }) + }) + + it('should call onChange with updated max_depth when input changes', () => { + const payload = createMockCrawlOptions({ max_depth: 2 }) + render() + + const maxDepthInput = screen.getByDisplayValue('2') + fireEvent.change(maxDepthInput, { target: { value: '10' } }) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + max_depth: 10, + }) + }) + + it('should call onChange with updated excludes when input changes', () => { + const payload = createMockCrawlOptions({ excludes: '' }) + render() + + const excludesInput = screen.getByPlaceholderText('blog/*, /about/*') + fireEvent.change(excludesInput, { target: { value: 'admin/*' } }) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + excludes: 'admin/*', + }) + }) + + it('should call onChange with updated includes when input changes', () => { + const payload = createMockCrawlOptions({ includes: '' }) + render() + + const includesInput = screen.getByPlaceholderText('articles/*') + fireEvent.change(includesInput, { target: { value: 'public/*' } }) + + expect(mockOnChange).toHaveBeenCalledWith({ + ...payload, + includes: 'public/*', + }) + }) + }) + + describe('Edge Cases', () => { + it('should preserve other payload fields when updating one field', () => { + const payload = createMockCrawlOptions({ + crawl_sub_pages: true, + limit: 10, + max_depth: 2, + excludes: 'test/*', + includes: 'docs/*', + only_main_content: true, + }) + render() + + const limitInput = screen.getByDisplayValue('10') + fireEvent.change(limitInput, { target: { value: '20' } }) + + expect(mockOnChange).toHaveBeenCalledWith({ + crawl_sub_pages: true, + limit: 20, + max_depth: 2, + excludes: 'test/*', + includes: 'docs/*', + only_main_content: true, + use_sitemap: false, + }) + }) + + it('should handle zero values', () => { + const payload = createMockCrawlOptions({ limit: 0, max_depth: 0 }) + render() + + const zeroInputs = screen.getAllByDisplayValue('0') + expect(zeroInputs.length).toBeGreaterThanOrEqual(1) + }) + }) + + describe('Memoization', () => { + it('should re-render when payload changes', () => { + const payload1 = createMockCrawlOptions({ limit: 10 }) + const payload2 = createMockCrawlOptions({ limit: 20 }) + + const { rerender } = render() + expect(screen.getByDisplayValue('10')).toBeInTheDocument() + + rerender() + expect(screen.getByDisplayValue('20')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/index.spec.tsx b/web/app/components/datasets/documents/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/index.spec.tsx rename to web/app/components/datasets/documents/__tests__/index.spec.tsx index c2f1538056..1749508ee1 100644 --- a/web/app/components/datasets/documents/index.spec.tsx +++ b/web/app/components/datasets/documents/__tests__/index.spec.tsx @@ -4,8 +4,8 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useProviderContext } from '@/context/provider-context' import { DataSourceType } from '@/models/datasets' import { useDocumentList } from '@/service/knowledge/use-document' -import useDocumentsPageState from './hooks/use-documents-page-state' -import Documents from './index' +import useDocumentsPageState from '../hooks/use-documents-page-state' +import Documents from '../index' // Type for mock selector function - use `as MockState` to bypass strict type checking in tests type MockSelector = Parameters[0] @@ -94,7 +94,7 @@ vi.mock('@/service/use-base', () => ({ })) // Mock metadata hook -vi.mock('../metadata/hooks/use-edit-dataset-metadata', () => ({ +vi.mock('../../metadata/hooks/use-edit-dataset-metadata', () => ({ default: vi.fn(() => ({ isShowEditModal: false, showEditModal: vi.fn(), @@ -120,7 +120,7 @@ const mockHandleLimitChange = vi.fn() const mockUpdatePollingState = vi.fn() const mockAdjustPageForTotal = vi.fn() -vi.mock('./hooks/use-documents-page-state', () => ({ +vi.mock('../hooks/use-documents-page-state', () => ({ default: vi.fn(() => ({ inputValue: '', searchValue: '', @@ -146,7 +146,7 @@ vi.mock('./hooks/use-documents-page-state', () => ({ // Mock child components - these have deep dependency chains (QueryClient, API hooks, contexts) // Mocking them allows us to test the Documents component logic in isolation -vi.mock('./components/documents-header', () => ({ +vi.mock('../components/documents-header', () => ({ default: ({ datasetId, embeddingAvailable, @@ -203,7 +203,7 @@ vi.mock('./components/documents-header', () => ({ ), })) -vi.mock('./components/empty-element', () => ({ +vi.mock('../components/empty-element', () => ({ default: ({ canAdd, onClick, type }: { canAdd: boolean onClick: () => void @@ -219,7 +219,7 @@ vi.mock('./components/empty-element', () => ({ ), })) -vi.mock('./components/list', () => ({ +vi.mock('../components/list', () => ({ default: ({ documents, datasetId, diff --git a/web/app/components/datasets/documents/__tests__/status-filter.spec.ts b/web/app/components/datasets/documents/__tests__/status-filter.spec.ts new file mode 100644 index 0000000000..c18f4ef688 --- /dev/null +++ b/web/app/components/datasets/documents/__tests__/status-filter.spec.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { normalizeStatusForQuery, sanitizeStatusValue } from '../status-filter' + +vi.mock('@/models/datasets', () => ({ + DisplayStatusList: [ + 'queuing', + 'indexing', + 'paused', + 'error', + 'available', + 'enabled', + 'disabled', + 'archived', + ], +})) + +describe('status-filter', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Tests for sanitizeStatusValue + describe('sanitizeStatusValue', () => { + // Falsy inputs should return 'all' + describe('falsy inputs', () => { + it('should return all when value is undefined', () => { + expect(sanitizeStatusValue(undefined)).toBe('all') + }) + + it('should return all when value is null', () => { + expect(sanitizeStatusValue(null)).toBe('all') + }) + + it('should return all when value is empty string', () => { + expect(sanitizeStatusValue('')).toBe('all') + }) + }) + + // Known status values should be returned as-is (lowercased) + describe('known status values', () => { + it('should return all when value is all', () => { + expect(sanitizeStatusValue('all')).toBe('all') + }) + + it.each([ + 'queuing', + 'indexing', + 'paused', + 'error', + 'available', + 'enabled', + 'disabled', + 'archived', + ])('should return %s when value is %s', (status) => { + expect(sanitizeStatusValue(status)).toBe(status) + }) + + it('should handle uppercase known values by normalizing to lowercase', () => { + expect(sanitizeStatusValue('QUEUING')).toBe('queuing') + expect(sanitizeStatusValue('Available')).toBe('available') + expect(sanitizeStatusValue('ALL')).toBe('all') + }) + }) + + // URL alias resolution + describe('URL aliases', () => { + it('should resolve active to available', () => { + expect(sanitizeStatusValue('active')).toBe('available') + }) + + it('should resolve Active (uppercase) to available', () => { + expect(sanitizeStatusValue('Active')).toBe('available') + }) + + it('should resolve ACTIVE to available', () => { + expect(sanitizeStatusValue('ACTIVE')).toBe('available') + }) + }) + + // Unknown values should fall back to 'all' + describe('unknown values', () => { + it('should return all when value is unknown', () => { + expect(sanitizeStatusValue('unknown')).toBe('all') + }) + + it('should return all when value is an arbitrary string', () => { + expect(sanitizeStatusValue('foobar')).toBe('all') + }) + + it('should return all when value is a numeric string', () => { + expect(sanitizeStatusValue('123')).toBe('all') + }) + }) + }) + + // Tests for normalizeStatusForQuery + describe('normalizeStatusForQuery', () => { + // When sanitized value is 'all', should return 'all' + describe('all status', () => { + it('should return all when value is undefined', () => { + expect(normalizeStatusForQuery(undefined)).toBe('all') + }) + + it('should return all when value is null', () => { + expect(normalizeStatusForQuery(null)).toBe('all') + }) + + it('should return all when value is empty string', () => { + expect(normalizeStatusForQuery('')).toBe('all') + }) + + it('should return all when value is all', () => { + expect(normalizeStatusForQuery('all')).toBe('all') + }) + + it('should return all when value is unknown (sanitized to all)', () => { + expect(normalizeStatusForQuery('unknown')).toBe('all') + }) + }) + + // Query alias resolution: enabled -> available + describe('query aliases', () => { + it('should resolve enabled to available', () => { + expect(normalizeStatusForQuery('enabled')).toBe('available') + }) + + it('should resolve Enabled (mixed case) to available', () => { + expect(normalizeStatusForQuery('Enabled')).toBe('available') + }) + }) + + // Non-aliased known values should pass through + describe('non-aliased known values', () => { + it.each([ + 'queuing', + 'indexing', + 'paused', + 'error', + 'available', + 'disabled', + 'archived', + ])('should return %s as-is when not aliased', (status) => { + expect(normalizeStatusForQuery(status)).toBe(status) + }) + }) + + // URL alias flows through sanitize first, then query alias + describe('combined alias resolution', () => { + it('should resolve active through URL alias to available', () => { + // active -> sanitizeStatusValue -> available -> no query alias for available -> available + expect(normalizeStatusForQuery('active')).toBe('available') + }) + }) + }) +}) diff --git a/web/app/components/datasets/documents/components/documents-header.spec.tsx b/web/app/components/datasets/documents/components/__tests__/documents-header.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/components/documents-header.spec.tsx rename to web/app/components/datasets/documents/components/__tests__/documents-header.spec.tsx index 922affa865..0289a79e2a 100644 --- a/web/app/components/datasets/documents/components/documents-header.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/documents-header.spec.tsx @@ -2,7 +2,7 @@ import type { SortType } from '@/service/datasets' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DataSourceType } from '@/models/datasets' -import DocumentsHeader from './documents-header' +import DocumentsHeader from '../documents-header' // Mock the context hooks vi.mock('@/context/i18n', () => ({ diff --git a/web/app/components/datasets/documents/components/empty-element.spec.tsx b/web/app/components/datasets/documents/components/__tests__/empty-element.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/components/empty-element.spec.tsx rename to web/app/components/datasets/documents/components/__tests__/empty-element.spec.tsx index c79ed3d50c..533d7b625c 100644 --- a/web/app/components/datasets/documents/components/empty-element.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/empty-element.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import EmptyElement from './empty-element' +import EmptyElement from '../empty-element' describe('EmptyElement', () => { const defaultProps = { diff --git a/web/app/components/datasets/documents/components/icons.spec.tsx b/web/app/components/datasets/documents/components/__tests__/icons.spec.tsx similarity index 97% rename from web/app/components/datasets/documents/components/icons.spec.tsx rename to web/app/components/datasets/documents/components/__tests__/icons.spec.tsx index 4ef9b0e68f..25852b6d8c 100644 --- a/web/app/components/datasets/documents/components/icons.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/icons.spec.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import { FolderPlusIcon, NotionIcon, ThreeDotsIcon } from './icons' +import { FolderPlusIcon, NotionIcon, ThreeDotsIcon } from '../icons' describe('Icons', () => { describe('FolderPlusIcon', () => { diff --git a/web/app/components/datasets/documents/components/operations.spec.tsx b/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx similarity index 87% rename from web/app/components/datasets/documents/components/operations.spec.tsx rename to web/app/components/datasets/documents/components/__tests__/operations.spec.tsx index 22c094a4a9..5aae8dda73 100644 --- a/web/app/components/datasets/documents/components/operations.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx @@ -1,15 +1,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import Operations from './operations' +import Operations from '../operations' -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - -// Mock next/navigation const mockPush = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ @@ -17,7 +9,6 @@ vi.mock('next/navigation', () => ({ }), })) -// Mock ToastContext const mockNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ ToastContext: { @@ -120,7 +111,7 @@ describe('Operations', () => { it('should not render settings when embeddingAvailable is false', () => { render() - expect(screen.queryByText('list.action.settings')).not.toBeInTheDocument() + expect(screen.queryByText('datasetDocuments.list.action.settings')).not.toBeInTheDocument() }) it('should render disabled switch when embeddingAvailable is false in list scene', () => { @@ -262,13 +253,13 @@ describe('Operations', () => { render() await openPopover() // Check if popover content is visible - expect(screen.getByText('list.table.rename')).toBeInTheDocument() + expect(screen.getByText('datasetDocuments.list.table.rename')).toBeInTheDocument() }) it('should call archive when archive action is clicked', async () => { render() await openPopover() - const archiveButton = screen.getByText('list.action.archive') + const archiveButton = screen.getByText('datasetDocuments.list.action.archive') await act(async () => { fireEvent.click(archiveButton) }) @@ -285,7 +276,7 @@ describe('Operations', () => { />, ) await openPopover() - const unarchiveButton = screen.getByText('list.action.unarchive') + const unarchiveButton = screen.getByText('datasetDocuments.list.action.unarchive') await act(async () => { fireEvent.click(unarchiveButton) }) @@ -297,23 +288,22 @@ describe('Operations', () => { it('should show delete confirmation modal when delete is clicked', async () => { render() await openPopover() - const deleteButton = screen.getByText('list.action.delete') + const deleteButton = screen.getByText('datasetDocuments.list.action.delete') await act(async () => { fireEvent.click(deleteButton) }) // Check if confirmation modal is shown - expect(screen.getByText('list.delete.title')).toBeInTheDocument() + expect(screen.getByText('datasetDocuments.list.delete.title')).toBeInTheDocument() }) it('should call delete when confirm is clicked in delete modal', async () => { render() await openPopover() - const deleteButton = screen.getByText('list.action.delete') + const deleteButton = screen.getByText('datasetDocuments.list.action.delete') await act(async () => { fireEvent.click(deleteButton) }) - // Click confirm button - const confirmButton = screen.getByText('operation.sure') + const confirmButton = screen.getByText('common.operation.sure') await act(async () => { fireEvent.click(confirmButton) }) @@ -325,20 +315,20 @@ describe('Operations', () => { it('should close delete modal when cancel is clicked', async () => { render() await openPopover() - const deleteButton = screen.getByText('list.action.delete') + const deleteButton = screen.getByText('datasetDocuments.list.action.delete') await act(async () => { fireEvent.click(deleteButton) }) // Verify modal is shown - expect(screen.getByText('list.delete.title')).toBeInTheDocument() - // Find and click the cancel button (text: operation.cancel) - const cancelButton = screen.getByText('operation.cancel') + expect(screen.getByText('datasetDocuments.list.delete.title')).toBeInTheDocument() + // Find and click the cancel button + const cancelButton = screen.getByText('common.operation.cancel') await act(async () => { fireEvent.click(cancelButton) }) // Modal should be closed - title shouldn't be visible await waitFor(() => { - expect(screen.queryByText('list.delete.title')).not.toBeInTheDocument() + expect(screen.queryByText('datasetDocuments.list.delete.title')).not.toBeInTheDocument() }) }) @@ -351,11 +341,11 @@ describe('Operations', () => { />, ) await openPopover() - const deleteButton = screen.getByText('list.action.delete') + const deleteButton = screen.getByText('datasetDocuments.list.action.delete') await act(async () => { fireEvent.click(deleteButton) }) - const confirmButton = screen.getByText('operation.sure') + const confirmButton = screen.getByText('common.operation.sure') await act(async () => { fireEvent.click(confirmButton) }) @@ -367,7 +357,7 @@ describe('Operations', () => { it('should show rename modal when rename is clicked', async () => { render() await openPopover() - const renameButton = screen.getByText('list.table.rename') + const renameButton = screen.getByText('datasetDocuments.list.table.rename') await act(async () => { fireEvent.click(renameButton) }) @@ -385,7 +375,7 @@ describe('Operations', () => { />, ) await openPopover() - const syncButton = screen.getByText('list.action.sync') + const syncButton = screen.getByText('datasetDocuments.list.action.sync') await act(async () => { fireEvent.click(syncButton) }) @@ -402,7 +392,7 @@ describe('Operations', () => { />, ) await openPopover() - const syncButton = screen.getByText('list.action.sync') + const syncButton = screen.getByText('datasetDocuments.list.action.sync') await act(async () => { fireEvent.click(syncButton) }) @@ -419,7 +409,7 @@ describe('Operations', () => { />, ) await openPopover() - const pauseButton = screen.getByText('list.action.pause') + const pauseButton = screen.getByText('datasetDocuments.list.action.pause') await act(async () => { fireEvent.click(pauseButton) }) @@ -436,7 +426,7 @@ describe('Operations', () => { />, ) await openPopover() - const resumeButton = screen.getByText('list.action.resume') + const resumeButton = screen.getByText('datasetDocuments.list.action.resume') await act(async () => { fireEvent.click(resumeButton) }) @@ -448,7 +438,7 @@ describe('Operations', () => { it('should download file when download action is clicked', async () => { render() await openPopover() - const downloadButton = screen.getByText('list.action.download') + const downloadButton = screen.getByText('datasetDocuments.list.action.download') await act(async () => { fireEvent.click(downloadButton) }) @@ -466,7 +456,7 @@ describe('Operations', () => { />, ) await openPopover() - expect(screen.getByText('list.action.download')).toBeInTheDocument() + expect(screen.getByText('datasetDocuments.list.action.download')).toBeInTheDocument() }) it('should download archived file when download is clicked', async () => { @@ -477,7 +467,7 @@ describe('Operations', () => { />, ) await openPopover() - const downloadButton = screen.getByText('list.action.download') + const downloadButton = screen.getByText('datasetDocuments.list.action.download') await act(async () => { fireEvent.click(downloadButton) }) @@ -497,14 +487,14 @@ describe('Operations', () => { fireEvent.click(moreButton) }) } - const archiveButton = screen.getByText('list.action.archive') + const archiveButton = screen.getByText('datasetDocuments.list.action.archive') await act(async () => { fireEvent.click(archiveButton) }) await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - message: 'actionMsg.modifiedUnsuccessfully', + message: 'common.actionMsg.modifiedUnsuccessfully', }) }) }) @@ -518,14 +508,14 @@ describe('Operations', () => { fireEvent.click(moreButton) }) } - const downloadButton = screen.getByText('list.action.download') + const downloadButton = screen.getByText('datasetDocuments.list.action.download') await act(async () => { fireEvent.click(downloadButton) }) await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - message: 'actionMsg.downloadUnsuccessfully', + message: 'common.actionMsg.downloadUnsuccessfully', }) }) }) @@ -539,14 +529,14 @@ describe('Operations', () => { fireEvent.click(moreButton) }) } - const downloadButton = screen.getByText('list.action.download') + const downloadButton = screen.getByText('datasetDocuments.list.action.download') await act(async () => { fireEvent.click(downloadButton) }) await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - message: 'actionMsg.downloadUnsuccessfully', + message: 'common.actionMsg.downloadUnsuccessfully', }) }) }) @@ -586,8 +576,8 @@ describe('Operations', () => { fireEvent.click(moreButton) }) } - expect(screen.queryByText('list.action.pause')).not.toBeInTheDocument() - expect(screen.queryByText('list.action.resume')).not.toBeInTheDocument() + expect(screen.queryByText('datasetDocuments.list.action.pause')).not.toBeInTheDocument() + expect(screen.queryByText('datasetDocuments.list.action.resume')).not.toBeInTheDocument() }) }) @@ -625,7 +615,7 @@ describe('Operations', () => { fireEvent.click(moreButton) }) } - expect(screen.queryByText('list.action.download')).not.toBeInTheDocument() + expect(screen.queryByText('datasetDocuments.list.action.download')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/components/rename-modal.spec.tsx b/web/app/components/datasets/documents/components/__tests__/rename-modal.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/components/rename-modal.spec.tsx rename to web/app/components/datasets/documents/components/__tests__/rename-modal.spec.tsx index 4bacec6e9d..9ed61a66e0 100644 --- a/web/app/components/datasets/documents/components/rename-modal.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/rename-modal.spec.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' // Import after mock import { renameDocumentName } from '@/service/datasets' -import RenameModal from './rename-modal' +import RenameModal from '../rename-modal' // Mock the service vi.mock('@/service/datasets', () => ({ diff --git a/web/app/components/datasets/documents/components/document-list/index.spec.tsx b/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/components/document-list/index.spec.tsx rename to web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx index 32429cc0ac..5ea2a00a7d 100644 --- a/web/app/components/datasets/documents/components/document-list/index.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx @@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode, DataSourceType } from '@/models/datasets' -import DocumentList from '../list' +import DocumentList from '../../list' const mockPush = vi.fn() @@ -204,7 +204,6 @@ describe('DocumentList', () => { const props = { ...defaultProps, onSelectedIdChange } const { container } = render(, { wrapper: createWrapper() }) - // Click the second checkbox (first row checkbox) const checkboxes = findCheckboxes(container) if (checkboxes.length > 1) { fireEvent.click(checkboxes[1]) diff --git a/web/app/components/datasets/documents/components/document-list/components/document-source-icon.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/__tests__/document-source-icon.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/components/document-list/components/document-source-icon.spec.tsx rename to web/app/components/datasets/documents/components/document-list/components/__tests__/document-source-icon.spec.tsx index 33108fbbac..2a42273a9b 100644 --- a/web/app/components/datasets/documents/components/document-list/components/document-source-icon.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/__tests__/document-source-icon.spec.tsx @@ -3,7 +3,7 @@ import { render } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { DataSourceType } from '@/models/datasets' import { DatasourceType } from '@/models/pipeline' -import DocumentSourceIcon from './document-source-icon' +import DocumentSourceIcon from '../document-source-icon' const createMockDoc = (overrides: Record = {}): SimpleDocumentDetail => ({ id: 'doc-1', diff --git a/web/app/components/datasets/documents/components/document-list/components/document-table-row.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/__tests__/document-table-row.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/components/document-list/components/document-table-row.spec.tsx rename to web/app/components/datasets/documents/components/document-list/components/__tests__/document-table-row.spec.tsx index 7157a9bf4b..ad920e9a37 100644 --- a/web/app/components/datasets/documents/components/document-list/components/document-table-row.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/__tests__/document-table-row.spec.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DataSourceType } from '@/models/datasets' -import DocumentTableRow from './document-table-row' +import DocumentTableRow from '../document-table-row' const mockPush = vi.fn() @@ -153,7 +153,6 @@ describe('DocumentTableRow', () => { it('should stop propagation when checkbox container is clicked', () => { const { container } = render(, { wrapper: createWrapper() }) - // Click the div containing the checkbox (which has stopPropagation) const checkboxContainer = container.querySelector('td')?.querySelector('div') if (checkboxContainer) { fireEvent.click(checkboxContainer) diff --git a/web/app/components/datasets/documents/components/document-list/components/sort-header.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/__tests__/sort-header.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/components/document-list/components/sort-header.spec.tsx rename to web/app/components/datasets/documents/components/document-list/components/__tests__/sort-header.spec.tsx index 15cc55247b..777f240d00 100644 --- a/web/app/components/datasets/documents/components/document-list/components/sort-header.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/__tests__/sort-header.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import SortHeader from './sort-header' +import SortHeader from '../sort-header' describe('SortHeader', () => { const defaultProps = { diff --git a/web/app/components/datasets/documents/components/document-list/components/utils.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/__tests__/utils.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/components/document-list/components/utils.spec.tsx rename to web/app/components/datasets/documents/components/document-list/components/__tests__/utils.spec.tsx index 7dc66d4d39..51b6db9d63 100644 --- a/web/app/components/datasets/documents/components/document-list/components/utils.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/__tests__/utils.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import { renderTdValue } from './utils' +import { renderTdValue } from '../utils' describe('renderTdValue', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts new file mode 100644 index 0000000000..5f48be084e --- /dev/null +++ b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.ts @@ -0,0 +1,231 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DocumentActionType } from '@/models/datasets' +import { useDocumentActions } from '../use-document-actions' + +const mockArchive = vi.fn() +const mockSummary = vi.fn() +const mockEnable = vi.fn() +const mockDisable = vi.fn() +const mockDelete = vi.fn() +const mockRetryIndex = vi.fn() +const mockDownloadZip = vi.fn() +let mockIsDownloadingZip = false + +vi.mock('@/service/knowledge/use-document', () => ({ + useDocumentArchive: () => ({ mutateAsync: mockArchive }), + useDocumentSummary: () => ({ mutateAsync: mockSummary }), + useDocumentEnable: () => ({ mutateAsync: mockEnable }), + useDocumentDisable: () => ({ mutateAsync: mockDisable }), + useDocumentDelete: () => ({ mutateAsync: mockDelete }), + useDocumentBatchRetryIndex: () => ({ mutateAsync: mockRetryIndex }), + useDocumentDownloadZip: () => ({ mutateAsync: mockDownloadZip, isPending: mockIsDownloadingZip }), +})) + +const mockToastNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: (...args: unknown[]) => mockToastNotify(...args) }, +})) + +const mockDownloadBlob = vi.fn() +vi.mock('@/utils/download', () => ({ + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), +})) + +describe('useDocumentActions', () => { + const defaultOptions = { + datasetId: 'ds-1', + selectedIds: ['doc-1', 'doc-2'], + downloadableSelectedIds: ['doc-1'], + onUpdate: vi.fn(), + onClearSelection: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockIsDownloadingZip = false + }) + + it('should return expected functions and state', () => { + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + expect(result.current.handleAction).toBeInstanceOf(Function) + expect(result.current.handleBatchReIndex).toBeInstanceOf(Function) + expect(result.current.handleBatchDownload).toBeInstanceOf(Function) + expect(typeof result.current.isDownloadingZip).toBe('boolean') + }) + + describe('handleAction', () => { + it('should call archive API and show success toast', async () => { + mockArchive.mockResolvedValue({ result: 'success' }) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleAction(DocumentActionType.archive)() + }) + + expect(mockArchive).toHaveBeenCalledWith({ + datasetId: 'ds-1', + documentIds: ['doc-1', 'doc-2'], + }) + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'success' }), + ) + expect(defaultOptions.onUpdate).toHaveBeenCalled() + }) + + it('should call enable API on enable action', async () => { + mockEnable.mockResolvedValue({ result: 'success' }) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleAction(DocumentActionType.enable)() + }) + + expect(mockEnable).toHaveBeenCalledWith({ + datasetId: 'ds-1', + documentIds: ['doc-1', 'doc-2'], + }) + expect(defaultOptions.onUpdate).toHaveBeenCalled() + }) + + it('should call disable API on disable action', async () => { + mockDisable.mockResolvedValue({ result: 'success' }) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleAction(DocumentActionType.disable)() + }) + + expect(mockDisable).toHaveBeenCalled() + }) + + it('should call summary API on summary action', async () => { + mockSummary.mockResolvedValue({ result: 'success' }) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleAction(DocumentActionType.summary)() + }) + + expect(mockSummary).toHaveBeenCalled() + }) + + it('should call onClearSelection on delete action success', async () => { + mockDelete.mockResolvedValue({ result: 'success' }) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleAction(DocumentActionType.delete)() + }) + + expect(mockDelete).toHaveBeenCalled() + expect(defaultOptions.onClearSelection).toHaveBeenCalled() + expect(defaultOptions.onUpdate).toHaveBeenCalled() + }) + + it('should not call onClearSelection on non-delete action success', async () => { + mockArchive.mockResolvedValue({ result: 'success' }) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleAction(DocumentActionType.archive)() + }) + + expect(defaultOptions.onClearSelection).not.toHaveBeenCalled() + }) + + it('should show error toast on action failure', async () => { + mockArchive.mockRejectedValue(new Error('fail')) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleAction(DocumentActionType.archive)() + }) + + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + expect(defaultOptions.onUpdate).not.toHaveBeenCalled() + }) + }) + + describe('handleBatchReIndex', () => { + it('should call retry index API and show success toast', async () => { + mockRetryIndex.mockResolvedValue({ result: 'success' }) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleBatchReIndex() + }) + + expect(mockRetryIndex).toHaveBeenCalledWith({ + datasetId: 'ds-1', + documentIds: ['doc-1', 'doc-2'], + }) + expect(defaultOptions.onClearSelection).toHaveBeenCalled() + expect(defaultOptions.onUpdate).toHaveBeenCalled() + }) + + it('should show error toast on reindex failure', async () => { + mockRetryIndex.mockRejectedValue(new Error('fail')) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleBatchReIndex() + }) + + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + }) + + describe('handleBatchDownload', () => { + it('should download blob on success', async () => { + const blob = new Blob(['test']) + mockDownloadZip.mockResolvedValue(blob) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleBatchDownload() + }) + + expect(mockDownloadZip).toHaveBeenCalledWith({ + datasetId: 'ds-1', + documentIds: ['doc-1'], + }) + expect(mockDownloadBlob).toHaveBeenCalledWith( + expect.objectContaining({ + data: blob, + fileName: expect.stringContaining('-docs.zip'), + }), + ) + }) + + it('should show error toast on download failure', async () => { + mockDownloadZip.mockRejectedValue(new Error('fail')) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleBatchDownload() + }) + + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + + it('should show error toast when blob is null', async () => { + mockDownloadZip.mockResolvedValue(null) + const { result } = renderHook(() => useDocumentActions(defaultOptions)) + + await act(async () => { + await result.current.handleBatchDownload() + }) + + expect(mockToastNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + }) +}) diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.spec.tsx b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.spec.tsx rename to web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.tsx index bc84477744..4b537f95a3 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/use-document-actions.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-actions.spec.tsx @@ -4,7 +4,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DocumentActionType } from '@/models/datasets' import * as useDocument from '@/service/knowledge/use-document' -import { useDocumentActions } from './use-document-actions' +import { useDocumentActions } from '../use-document-actions' vi.mock('@/service/knowledge/use-document') diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.spec.ts b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-selection.spec.ts similarity index 99% rename from web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.spec.ts rename to web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-selection.spec.ts index 7775c83f1c..32e4ff88b4 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/use-document-selection.spec.ts +++ b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-selection.spec.ts @@ -2,7 +2,7 @@ import type { SimpleDocumentDetail } from '@/models/datasets' import { act, renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { DataSourceType } from '@/models/datasets' -import { useDocumentSelection } from './use-document-selection' +import { useDocumentSelection } from '../use-document-selection' type LocalDoc = SimpleDocumentDetail & { percent?: number } diff --git a/web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.spec.ts b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-sort.spec.ts similarity index 99% rename from web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.spec.ts rename to web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-sort.spec.ts index a41b42d6fa..43bc0e1dd5 100644 --- a/web/app/components/datasets/documents/components/document-list/hooks/use-document-sort.spec.ts +++ b/web/app/components/datasets/documents/components/document-list/hooks/__tests__/use-document-sort.spec.ts @@ -1,7 +1,7 @@ import type { SimpleDocumentDetail } from '@/models/datasets' import { act, renderHook } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import { useDocumentSort } from './use-document-sort' +import { useDocumentSort } from '../use-document-sort' type LocalDoc = SimpleDocumentDetail & { percent?: number } diff --git a/web/app/components/datasets/documents/create-from-pipeline/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx similarity index 95% rename from web/app/components/datasets/documents/create-from-pipeline/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx index c43678def0..0096dc8c29 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx @@ -18,19 +18,17 @@ import { useOnlineDocument, useOnlineDrive, useWebsiteCrawl, -} from './hooks' -import { StepOneContent, StepThreeContent, StepTwoContent } from './steps' -import { StepOnePreview, StepTwoPreview } from './steps/preview-panel' +} from '../hooks' +import { StepOneContent, StepThreeContent, StepTwoContent } from '../steps' +import { StepOnePreview, StepTwoPreview } from '../steps/preview-panel' import { buildLocalFileDatasourceInfo, buildOnlineDocumentDatasourceInfo, buildOnlineDriveDatasourceInfo, buildWebsiteCrawlDatasourceInfo, -} from './utils/datasource-info-builder' +} from '../utils/datasource-info-builder' -// ========================================== // Mock External Dependencies Only -// ========================================== // Mock context providers const mockPlan = { @@ -92,7 +90,6 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), })) -// Mock next/navigation vi.mock('next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id' }), useRouter: () => ({ @@ -171,21 +168,17 @@ const mockStoreState = { bucket: '', } -vi.mock('./data-source/store', () => ({ +vi.mock('../data-source/store', () => ({ useDataSourceStore: () => ({ getState: () => mockStoreState, }), useDataSourceStoreWithSelector: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState), })) -vi.mock('./data-source/store/provider', () => ({ +vi.mock('../data-source/store/provider', () => ({ default: ({ children }: { children: React.ReactNode }) => <>{children}, })) -// ========================================== -// Test Data Factories -// ========================================== - const createMockDatasource = (overrides?: Partial): Datasource => ({ nodeId: 'node-1', nodeData: { @@ -242,9 +235,7 @@ const createMockOnlineDriveFile = (overrides?: Partial): Online ...overrides, } as OnlineDriveFile) -// ========================================== // Hook Tests - useAddDocumentsSteps -// ========================================== describe('useAddDocumentsSteps', () => { it('should initialize with step 1', () => { const { result } = renderHook(() => useAddDocumentsSteps()) @@ -292,9 +283,7 @@ describe('useAddDocumentsSteps', () => { }) }) -// ========================================== // Hook Tests - useDatasourceUIState -// ========================================== describe('useDatasourceUIState', () => { const defaultParams = { datasource: undefined as Datasource | undefined, @@ -475,9 +464,7 @@ describe('useDatasourceUIState', () => { }) }) -// ========================================== // Utility Functions Tests - datasource-info-builder -// ========================================== describe('datasource-info-builder', () => { describe('buildLocalFileDatasourceInfo', () => { it('should build correct info for local file', () => { @@ -556,9 +543,7 @@ describe('datasource-info-builder', () => { }) }) -// ========================================== // Step Components Tests (with real components) -// ========================================== describe('StepOneContent', () => { const defaultProps = { datasource: undefined as Datasource | undefined, @@ -639,7 +624,7 @@ describe('StepOneContent', () => { describe('StepTwoContent', () => { // Mock ProcessDocuments since it has complex dependencies - vi.mock('./process-documents', () => ({ + vi.mock('../process-documents', () => ({ default: React.forwardRef(({ dataSourceNodeId, isRunning, onProcess, onPreview, onSubmit, onBack }: { dataSourceNodeId: string isRunning: boolean @@ -713,7 +698,7 @@ describe('StepTwoContent', () => { describe('StepThreeContent', () => { // Mock Processing since it has complex dependencies - vi.mock('./processing', () => ({ + vi.mock('../processing', () => ({ default: ({ batchId, documents }: { batchId: string, documents: unknown[] }) => (
{batchId} @@ -739,12 +724,10 @@ describe('StepThreeContent', () => { }) }) -// ========================================== // Preview Panel Tests -// ========================================== describe('StepOnePreview', () => { // Mock preview components - vi.mock('./preview/file-preview', () => ({ + vi.mock('../preview/file-preview', () => ({ default: ({ file, hidePreview }: { file: CustomFile, hidePreview: () => void }) => (
{file.name} @@ -753,7 +736,7 @@ describe('StepOnePreview', () => { ), })) - vi.mock('./preview/online-document-preview', () => ({ + vi.mock('../preview/online-document-preview', () => ({ default: ({ datasourceNodeId, currentPage, hidePreview }: { datasourceNodeId: string currentPage: NotionPage & { workspace_id: string } @@ -767,7 +750,7 @@ describe('StepOnePreview', () => { ), })) - vi.mock('./preview/web-preview', () => ({ + vi.mock('../preview/web-preview', () => ({ default: ({ currentWebsite, hidePreview }: { currentWebsite: CrawlResultItem, hidePreview: () => void }) => (
{currentWebsite.source_url} @@ -847,7 +830,7 @@ describe('StepOnePreview', () => { describe('StepTwoPreview', () => { // Mock ChunkPreview - vi.mock('./preview/chunk-preview', () => ({ + vi.mock('../preview/chunk-preview', () => ({ default: ({ dataSourceType, isIdle, isPending, onPreview }: { dataSourceType: string isIdle: boolean @@ -913,9 +896,6 @@ describe('StepTwoPreview', () => { }) }) -// ========================================== -// Edge Cases Tests -// ========================================== describe('Edge Cases', () => { describe('Empty States', () => { it('should handle undefined datasource in useDatasourceUIState', () => { @@ -996,22 +976,20 @@ describe('Edge Cases', () => { }) }) -// ========================================== // Component Memoization Tests -// ========================================== describe('Component Memoization', () => { it('StepOneContent should be memoized', async () => { - const StepOneContentModule = await import('./steps/step-one-content') + const StepOneContentModule = await import('../steps/step-one-content') expect(StepOneContentModule.default.$$typeof).toBe(Symbol.for('react.memo')) }) it('StepTwoContent should be memoized', async () => { - const StepTwoContentModule = await import('./steps/step-two-content') + const StepTwoContentModule = await import('../steps/step-two-content') expect(StepTwoContentModule.default.$$typeof).toBe(Symbol.for('react.memo')) }) it('StepThreeContent should be memoized', async () => { - const StepThreeContentModule = await import('./steps/step-three-content') + const StepThreeContentModule = await import('../steps/step-three-content') expect(StepThreeContentModule.default.$$typeof).toBe(Symbol.for('react.memo')) }) @@ -1024,9 +1002,7 @@ describe('Component Memoization', () => { }) }) -// ========================================== // Hook Callback Stability Tests -// ========================================== describe('Hook Callback Stability', () => { describe('useDatasourceUIState memoization', () => { it('should maintain stable reference for datasourceType when dependencies unchanged', () => { @@ -1054,9 +1030,7 @@ describe('Hook Callback Stability', () => { }) }) -// ========================================== // Store Hooks Tests -// ========================================== describe('Store Hooks', () => { describe('useLocalFile', () => { it('should return localFileList from store', () => { @@ -1123,9 +1097,7 @@ describe('Store Hooks', () => { }) }) -// ========================================== // All Datasource Types Tests -// ========================================== describe('All Datasource Types', () => { const datasourceTypes = [ { type: DatasourceType.localFile, name: 'Local File' }, @@ -1161,9 +1133,7 @@ describe('All Datasource Types', () => { }) }) -// ========================================== // useDatasourceOptions Hook Tests -// ========================================== describe('useDatasourceOptions', () => { it('should return empty array when no pipeline nodes', () => { const { result } = renderHook(() => useDatasourceOptions([])) @@ -1231,9 +1201,7 @@ describe('useDatasourceOptions', () => { }) }) -// ========================================== // useDatasourceActions Hook Tests -// ========================================== describe('useDatasourceActions', () => { const createMockDataSourceStore = () => ({ getState: () => ({ @@ -1496,9 +1464,7 @@ describe('useDatasourceActions', () => { }) }) -// ========================================== // Store Hooks - Additional Coverage Tests -// ========================================== describe('Store Hooks - Callbacks', () => { beforeEach(() => { vi.clearAllMocks() @@ -1600,24 +1566,22 @@ describe('Store Hooks - Callbacks', () => { }) }) -// ========================================== // StepOneContent - All Datasource Types -// ========================================== describe('StepOneContent - All Datasource Types', () => { // Mock data source components - vi.mock('./data-source/local-file', () => ({ + vi.mock('../data-source/local-file', () => ({ default: () =>
Local File
, })) - vi.mock('./data-source/online-documents', () => ({ + vi.mock('../data-source/online-documents', () => ({ default: () =>
Online Documents
, })) - vi.mock('./data-source/website-crawl', () => ({ + vi.mock('../data-source/website-crawl', () => ({ default: () =>
Website Crawl
, })) - vi.mock('./data-source/online-drive', () => ({ + vi.mock('../data-source/online-drive', () => ({ default: () =>
Online Drive
, })) @@ -1699,9 +1663,7 @@ describe('StepOneContent - All Datasource Types', () => { }) }) -// ========================================== // StepTwoPreview - with localFileList -// ========================================== describe('StepTwoPreview - File List Mapping', () => { it('should correctly map localFileList to localFiles', () => { const fileList = [ @@ -1732,9 +1694,7 @@ describe('StepTwoPreview - File List Mapping', () => { }) }) -// ========================================== // useDatasourceActions - Additional Coverage -// ========================================== describe('useDatasourceActions - Async Functions', () => { beforeEach(() => { vi.clearAllMocks() @@ -2099,9 +2059,7 @@ describe('useDatasourceActions - Async Functions', () => { }) }) -// ========================================== // useDatasourceActions - onSuccess Callbacks -// ========================================== describe('useDatasourceActions - API Success Callbacks', () => { beforeEach(() => { vi.clearAllMocks() @@ -2257,9 +2215,7 @@ describe('useDatasourceActions - API Success Callbacks', () => { }) }) -// ========================================== // useDatasourceActions - buildProcessDatasourceInfo Coverage -// ========================================== describe('useDatasourceActions - Process Mode for All Datasource Types', () => { beforeEach(() => { vi.clearAllMocks() @@ -2544,9 +2500,7 @@ describe('useDatasourceActions - Process Mode for All Datasource Types', () => { }) }) -// ========================================== // useDatasourceActions - Edge Case Branches -// ========================================== describe('useDatasourceActions - Edge Case Branches', () => { beforeEach(() => { vi.clearAllMocks() @@ -2632,67 +2586,63 @@ describe('useDatasourceActions - Edge Case Branches', () => { }) }) -// ========================================== // Hooks Index Re-exports Test -// ========================================== describe('Hooks Index Re-exports', () => { it('should export useAddDocumentsSteps', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useAddDocumentsSteps).toBeDefined() }) it('should export useDatasourceActions', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useDatasourceActions).toBeDefined() }) it('should export useDatasourceOptions', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useDatasourceOptions).toBeDefined() }) it('should export useLocalFile', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useLocalFile).toBeDefined() }) it('should export useOnlineDocument', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useOnlineDocument).toBeDefined() }) it('should export useOnlineDrive', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useOnlineDrive).toBeDefined() }) it('should export useWebsiteCrawl', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useWebsiteCrawl).toBeDefined() }) it('should export useDatasourceUIState', async () => { - const hooksModule = await import('./hooks') + const hooksModule = await import('../hooks') expect(hooksModule.useDatasourceUIState).toBeDefined() }) }) -// ========================================== // Steps Index Re-exports Test -// ========================================== describe('Steps Index Re-exports', () => { it('should export StepOneContent', async () => { - const stepsModule = await import('./steps') + const stepsModule = await import('../steps') expect(stepsModule.StepOneContent).toBeDefined() }) it('should export StepTwoContent', async () => { - const stepsModule = await import('./steps') + const stepsModule = await import('../steps') expect(stepsModule.StepTwoContent).toBeDefined() }) it('should export StepThreeContent', async () => { - const stepsModule = await import('./steps') + const stepsModule = await import('../steps') expect(stepsModule.StepThreeContent).toBeDefined() }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx new file mode 100644 index 0000000000..584c21e826 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx @@ -0,0 +1,110 @@ +import type { Step } from '../step-indicator' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import LeftHeader from '../left-header' + +vi.mock('next/navigation', () => ({ + useParams: () => ({ datasetId: 'test-ds-id' }), +})) + +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: React.ReactNode, href: string }) => ( + {children} + ), +})) + +vi.mock('../step-indicator', () => ({ + default: ({ steps, currentStep }: { steps: Step[], currentStep: number }) => ( +
+ ), +})) + +vi.mock('@/app/components/base/effect', () => ({ + default: ({ className }: { className?: string }) => ( +
+ ), +})) + +const createSteps = (): Step[] => [ + { label: 'Data Source', value: 'data-source' }, + { label: 'Processing', value: 'processing' }, + { label: 'Complete', value: 'complete' }, +] + +describe('LeftHeader', () => { + const steps = createSteps() + + const defaultProps = { + steps, + title: 'Add Documents', + currentStep: 1, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: title, step label, and step indicator + describe('Rendering', () => { + it('should render title text', () => { + render() + + expect(screen.getByText('Add Documents')).toBeInTheDocument() + }) + + it('should render current step label (steps[currentStep-1].label)', () => { + render() + + expect(screen.getByText('Processing')).toBeInTheDocument() + }) + + it('should render step indicator component', () => { + render() + + expect(screen.getByTestId('step-indicator')).toBeInTheDocument() + }) + + it('should render separator between title and step indicator', () => { + render() + + expect(screen.getByText('/')).toBeInTheDocument() + }) + }) + + // Back button visibility depends on currentStep vs total steps + describe('Back Button', () => { + it('should show back button when currentStep !== steps.length', () => { + render() + + expect(screen.getByTestId('back-link')).toBeInTheDocument() + }) + + it('should hide back button when currentStep === steps.length', () => { + render() + + expect(screen.queryByTestId('back-link')).not.toBeInTheDocument() + }) + + it('should link to correct URL using datasetId from params', () => { + render() + + const link = screen.getByTestId('back-link') + expect(link).toHaveAttribute('href', '/datasets/test-ds-id/documents') + }) + }) + + // Edge case: step label for boundary values + describe('Edge Cases', () => { + it('should render first step label when currentStep is 1', () => { + render() + + expect(screen.getByText('Data Source')).toBeInTheDocument() + }) + + it('should render last step label when currentStep equals steps.length', () => { + render() + + expect(screen.getByText('Complete')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/__tests__/step-indicator.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/step-indicator.spec.tsx new file mode 100644 index 0000000000..7103dced26 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/step-indicator.spec.tsx @@ -0,0 +1,32 @@ +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import StepIndicator from '../step-indicator' + +describe('StepIndicator', () => { + const steps = [ + { label: 'Data Source', value: 'data-source' }, + { label: 'Process', value: 'process' }, + { label: 'Embedding', value: 'embedding' }, + ] + + it('should render dots for each step', () => { + const { container } = render() + const dots = container.querySelectorAll('.rounded-lg') + expect(dots).toHaveLength(3) + }) + + it('should apply active style to current step', () => { + const { container } = render() + const dots = container.querySelectorAll('.rounded-lg') + // Second step (index 1) should be active + expect(dots[1].className).toContain('bg-state-accent-solid') + expect(dots[1].className).toContain('w-2') + }) + + it('should not apply active style to non-current steps', () => { + const { container } = render() + const dots = container.querySelectorAll('.rounded-lg') + expect(dots[1].className).toContain('bg-divider-solid') + expect(dots[2].className).toContain('bg-divider-solid') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/actions/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/actions/__tests__/index.spec.tsx similarity index 89% rename from web/app/components/datasets/documents/create-from-pipeline/actions/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/actions/__tests__/index.spec.tsx index cbb74bb796..45ecaa7e9b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/actions/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/actions/__tests__/index.spec.tsx @@ -1,10 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import Actions from './index' - -// ========================================== -// Mock External Dependencies -// ========================================== +import Actions from '../index' // Mock next/navigation - useParams returns datasetId const mockDatasetId = 'test-dataset-id' @@ -21,10 +17,6 @@ vi.mock('next/link', () => ({ ), })) -// ========================================== -// Test Suite -// ========================================== - describe('Actions', () => { // Default mock for required props const defaultProps = { @@ -35,85 +27,63 @@ describe('Actions', () => { vi.clearAllMocks() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { // Tests basic rendering functionality it('should render without crashing', () => { - // Arrange & Act render() - // Assert expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeInTheDocument() }) it('should render cancel button with correct link', () => { - // Arrange & Act render() - // Assert const cancelLink = screen.getByRole('link') expect(cancelLink).toHaveAttribute('href', `/datasets/${mockDatasetId}/documents`) expect(cancelLink).toHaveAttribute('data-replace', 'true') }) it('should render next step button with arrow icon', () => { - // Arrange & Act render() - // Assert const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) expect(nextButton).toBeInTheDocument() expect(nextButton.querySelector('svg')).toBeInTheDocument() }) it('should render cancel button with correct translation key', () => { - // Arrange & Act render() - // Assert expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() }) it('should not render select all section by default', () => { - // Arrange & Act render() - // Assert expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { // Tests for prop variations and defaults describe('disabled prop', () => { it('should not disable next step button when disabled is false', () => { - // Arrange & Act render() - // Assert const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) expect(nextButton).not.toBeDisabled() }) it('should disable next step button when disabled is true', () => { - // Arrange & Act render() - // Assert const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) expect(nextButton).toBeDisabled() }) it('should not disable next step button when disabled is undefined', () => { - // Arrange & Act render() - // Assert const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) expect(nextButton).not.toBeDisabled() }) @@ -121,66 +91,51 @@ describe('Actions', () => { describe('showSelect prop', () => { it('should show select all section when showSelect is true', () => { - // Arrange & Act render() - // Assert expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument() }) it('should hide select all section when showSelect is false', () => { - // Arrange & Act render() - // Assert expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() }) it('should hide select all section when showSelect defaults to false', () => { - // Arrange & Act render() - // Assert expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() }) }) describe('tip prop', () => { it('should show tip when showSelect is true and tip is provided', () => { - // Arrange const tip = 'This is a helpful tip' - // Act render() - // Assert expect(screen.getByText(tip)).toBeInTheDocument() expect(screen.getByTitle(tip)).toBeInTheDocument() }) it('should not show tip when showSelect is false even if tip is provided', () => { - // Arrange const tip = 'This is a helpful tip' - // Act render() - // Assert expect(screen.queryByText(tip)).not.toBeInTheDocument() }) it('should not show tip when tip is empty string', () => { - // Arrange & Act render() - // Assert const tipElements = screen.queryAllByTitle('') // Empty tip should not render a tip element expect(tipElements.length).toBe(0) }) it('should use empty string as default tip value', () => { - // Arrange & Act render() // Assert - tip container should not exist when tip defaults to empty string @@ -190,37 +145,28 @@ describe('Actions', () => { }) }) - // ========================================== // Event Handlers Testing - // ========================================== describe('User Interactions', () => { // Tests for event handlers it('should call handleNextStep when next button is clicked', () => { - // Arrange const handleNextStep = vi.fn() render() - // Act fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - // Assert expect(handleNextStep).toHaveBeenCalledTimes(1) }) it('should not call handleNextStep when next button is disabled and clicked', () => { - // Arrange const handleNextStep = vi.fn() render() - // Act fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - // Assert expect(handleNextStep).not.toHaveBeenCalled() }) it('should call onSelectAll when checkbox is clicked', () => { - // Arrange const onSelectAll = vi.fn() render( { if (checkbox) fireEvent.click(checkbox) - // Assert expect(onSelectAll).toHaveBeenCalledTimes(1) }) }) - // ========================================== // Memoization Logic Testing - // ========================================== describe('Memoization Logic', () => { // Tests for useMemo hooks (indeterminate and checked) describe('indeterminate calculation', () => { it('should return false when showSelect is false', () => { - // Arrange & Act render( { }) it('should return false when selectedOptions is undefined', () => { - // Arrange & Act const { container } = render( { }) it('should return false when totalOptions is undefined', () => { - // Arrange & Act const { container } = render( { }) it('should return true when some options are selected (0 < selectedOptions < totalOptions)', () => { - // Arrange & Act const { container } = render( { }) it('should return false when no options are selected (selectedOptions === 0)', () => { - // Arrange & Act const { container } = render( { }) it('should return false when all options are selected (selectedOptions === totalOptions)', () => { - // Arrange & Act const { container } = render( { describe('checked calculation', () => { it('should return false when showSelect is false', () => { - // Arrange & Act render( { }) it('should return false when selectedOptions is undefined', () => { - // Arrange & Act const { container } = render( { />, ) - // Assert const checkbox = container.querySelector('[class*="cursor-pointer"]') expect(checkbox).toBeInTheDocument() }) it('should return false when totalOptions is undefined', () => { - // Arrange & Act const { container } = render( { />, ) - // Assert const checkbox = container.querySelector('[class*="cursor-pointer"]') expect(checkbox).toBeInTheDocument() }) it('should return true when all options are selected (selectedOptions === totalOptions)', () => { - // Arrange & Act const { container } = render( { }) it('should return false when selectedOptions is 0', () => { - // Arrange & Act const { container } = render( { }) it('should return false when not all options are selected', () => { - // Arrange & Act const { container } = render( { }) }) - // ========================================== // Component Memoization Testing - // ========================================== describe('Component Memoization', () => { // Tests for React.memo behavior it('should be wrapped with React.memo', () => { @@ -468,7 +395,6 @@ describe('Actions', () => { }) it('should not re-render when props are the same', () => { - // Arrange const handleNextStep = vi.fn() const props = { handleNextStep, @@ -480,7 +406,6 @@ describe('Actions', () => { tip: 'Test tip', } - // Act const { rerender } = render() // Re-render with same props @@ -492,7 +417,6 @@ describe('Actions', () => { }) it('should re-render when props change', () => { - // Arrange const handleNextStep = vi.fn() const initialProps = { handleNextStep, @@ -504,26 +428,21 @@ describe('Actions', () => { tip: 'Initial tip', } - // Act const { rerender } = render() expect(screen.getByText('Initial tip')).toBeInTheDocument() // Rerender with different props rerender() - // Assert expect(screen.getByText('Updated tip')).toBeInTheDocument() expect(screen.queryByText('Initial tip')).not.toBeInTheDocument() }) }) - // ========================================== // Edge Cases Testing - // ========================================== describe('Edge Cases', () => { // Tests for boundary conditions and unusual inputs it('should handle totalOptions of 0', () => { - // Arrange & Act const { container } = render( { }) it('should handle very large totalOptions', () => { - // Arrange & Act const { container } = render( { />, ) - // Assert const checkbox = container.querySelector('[class*="cursor-pointer"]') expect(checkbox).toBeInTheDocument() }) it('should handle very long tip text', () => { - // Arrange const longTip = 'A'.repeat(500) - // Act render( { }) it('should handle tip with special characters', () => { - // Arrange const specialTip = ' & "quotes" \'apostrophes\'' - // Act render( { }) it('should handle tip with unicode characters', () => { - // Arrange const unicodeTip = '选中 5 个文件,共 10MB 🚀' - // Act render( { />, ) - // Assert expect(screen.getByText(unicodeTip)).toBeInTheDocument() }) it('should handle selectedOptions greater than totalOptions', () => { // This is an edge case that shouldn't happen but should be handled gracefully - // Arrange & Act const { container } = render( { }) it('should handle negative selectedOptions', () => { - // Arrange & Act const { container } = render( { }) it('should handle onSelectAll being undefined when showSelect is true', () => { - // Arrange & Act const { container } = render( { const checkbox = container.querySelector('[class*="cursor-pointer"]') expect(checkbox).toBeInTheDocument() - // Click should not throw if (checkbox) expect(() => fireEvent.click(checkbox)).not.toThrow() }) it('should handle empty datasetId from params', () => { // This test verifies the link is constructed even with empty datasetId - // Arrange & Act render() // Assert - link should still be present with the mocked datasetId @@ -678,23 +583,18 @@ describe('Actions', () => { }) }) - // ========================================== // All Prop Combinations Testing - // ========================================== describe('Prop Combinations', () => { // Tests for various combinations of props it('should handle disabled=true with showSelect=false', () => { - // Arrange & Act render() - // Assert const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) expect(nextButton).toBeDisabled() expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() }) it('should handle disabled=true with showSelect=true', () => { - // Arrange & Act render( { />, ) - // Assert const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }) expect(nextButton).toBeDisabled() expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument() }) it('should render complete component with all props provided', () => { - // Arrange const allProps = { disabled: false, handleNextStep: vi.fn(), @@ -724,10 +622,8 @@ describe('Actions', () => { tip: 'All props provided', } - // Act render() - // Assert expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument() expect(screen.getByText('All props provided')).toBeInTheDocument() expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() @@ -735,19 +631,15 @@ describe('Actions', () => { }) it('should render minimal component with only required props', () => { - // Arrange & Act render() - // Assert expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument() expect(screen.getByText('common.operation.cancel')).toBeInTheDocument() expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled() }) }) - // ========================================== // Selection State Variations Testing - // ========================================== describe('Selection State Variations', () => { // Tests for different selection states const selectionStates = [ @@ -763,7 +655,6 @@ describe('Actions', () => { it.each(selectionStates)( 'should render with $expectedState state when totalOptions=$totalOptions and selectedOptions=$selectedOptions', ({ totalOptions, selectedOptions }) => { - // Arrange & Act const { container } = render( { ) }) - // ========================================== // Layout Structure Testing - // ========================================== describe('Layout', () => { // Tests for correct layout structure it('should have correct container structure', () => { - // Arrange & Act const { container } = render() - // Assert const mainContainer = container.querySelector('.flex.items-center.gap-x-2.overflow-hidden') expect(mainContainer).toBeInTheDocument() }) it('should have correct button container structure', () => { - // Arrange & Act const { container } = render() // Assert - buttons should be in a flex container @@ -806,7 +692,6 @@ describe('Actions', () => { }) it('should position select all section before buttons when showSelect is true', () => { - // Arrange & Act const { container } = render( { + it('should render icon with background image', () => { + const { container } = render() + const iconDiv = container.querySelector('[style*="background-image"]') + expect(iconDiv).not.toBeNull() + expect(iconDiv?.getAttribute('style')).toContain('https://example.com/icon.png') + }) + + it('should apply size class for sm', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('w-5') + expect(wrapper.className).toContain('h-5') + }) + + it('should apply size class for md', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('w-6') + expect(wrapper.className).toContain('h-6') + }) + + it('should apply size class for xs', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('w-4') + expect(wrapper.className).toContain('h-4') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/hooks.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/hooks.spec.tsx new file mode 100644 index 0000000000..617da1f697 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/hooks.spec.tsx @@ -0,0 +1,141 @@ +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useDatasourceIcon } from '../hooks' + +const mockTransformDataSourceToTool = vi.fn() + +vi.mock('@/app/components/workflow/block-selector/utils', () => ({ + transformDataSourceToTool: (...args: unknown[]) => mockTransformDataSourceToTool(...args), +})) + +let mockDataSourceListReturn: { + data: Array<{ + plugin_id: string + provider: string + declaration: { identity: { icon: string, author: string } } + }> | undefined + isSuccess: boolean +} + +vi.mock('@/service/use-pipeline', () => ({ + useDataSourceList: () => mockDataSourceListReturn, +})) + +vi.mock('@/utils/var', () => ({ + basePath: '', +})) + +const createMockDataSourceNode = (overrides?: Partial): DataSourceNodeType => ({ + plugin_id: 'plugin-abc', + provider_type: 'builtin', + provider_name: 'web-scraper', + datasource_name: 'scraper', + datasource_label: 'Web Scraper', + datasource_parameters: {}, + datasource_configurations: {}, + title: 'DataSource', + desc: '', + type: '' as DataSourceNodeType['type'], + ...overrides, +} as DataSourceNodeType) + +describe('useDatasourceIcon', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDataSourceListReturn = { data: undefined, isSuccess: false } + mockTransformDataSourceToTool.mockReset() + }) + + // Returns undefined when data has not loaded + describe('Loading State', () => { + it('should return undefined when data is not loaded (isSuccess false)', () => { + mockDataSourceListReturn = { data: undefined, isSuccess: false } + + const { result } = renderHook(() => + useDatasourceIcon(createMockDataSourceNode()), + ) + + expect(result.current).toBeUndefined() + }) + }) + + // Returns correct icon when plugin_id matches + describe('Icon Resolution', () => { + it('should return correct icon when plugin_id matches', () => { + const mockIcon = 'https://example.com/icon.svg' + mockDataSourceListReturn = { + data: [ + { + plugin_id: 'plugin-abc', + provider: 'web-scraper', + declaration: { identity: { icon: mockIcon, author: 'dify' } }, + }, + ], + isSuccess: true, + } + mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({ + plugin_id: item.plugin_id, + icon: item.declaration.identity.icon, + })) + + const { result } = renderHook(() => + useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })), + ) + + expect(result.current).toBe(mockIcon) + }) + + it('should return undefined when plugin_id does not match', () => { + mockDataSourceListReturn = { + data: [ + { + plugin_id: 'plugin-xyz', + provider: 'other', + declaration: { identity: { icon: '/icon.svg', author: 'dify' } }, + }, + ], + isSuccess: true, + } + mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({ + plugin_id: item.plugin_id, + icon: item.declaration.identity.icon, + })) + + const { result } = renderHook(() => + useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })), + ) + + expect(result.current).toBeUndefined() + }) + }) + + // basePath prepending + describe('basePath Prepending', () => { + it('should prepend basePath to icon URL when not already included', () => { + // basePath is mocked as '' so prepending '' to '/icon.png' results in '/icon.png' + // The important thing is that the forEach logic runs without error + mockDataSourceListReturn = { + data: [ + { + plugin_id: 'plugin-abc', + provider: 'web-scraper', + declaration: { identity: { icon: '/icon.png', author: 'dify' } }, + }, + ], + isSuccess: true, + } + mockTransformDataSourceToTool.mockImplementation((item: { plugin_id: string, declaration: { identity: { icon: string } } }) => ({ + plugin_id: item.plugin_id, + icon: item.declaration.identity.icon, + })) + + const { result } = renderHook(() => + useDatasourceIcon(createMockDataSourceNode({ plugin_id: 'plugin-abc' })), + ) + + // With empty basePath, icon stays as '/icon.png' + expect(result.current).toBe('/icon.png') + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/index.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/index.spec.tsx index 57b73e9222..0ac2dfce20 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/index.spec.tsx @@ -5,18 +5,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, renderHook, screen } from '@testing-library/react' import * as React from 'react' import { BlockEnum } from '@/app/components/workflow/types' -import DatasourceIcon from './datasource-icon' -import { useDatasourceIcon } from './hooks' -import DataSourceOptions from './index' -import OptionCard from './option-card' - -// ========================================== -// Mock External Dependencies -// ========================================== +import DatasourceIcon from '../datasource-icon' +import { useDatasourceIcon } from '../hooks' +import DataSourceOptions from '../index' +import OptionCard from '../option-card' // Mock useDatasourceOptions hook from parent hooks const mockUseDatasourceOptions = vi.fn() -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ useDatasourceOptions: (nodes: Node[]) => mockUseDatasourceOptions(nodes), })) @@ -37,10 +33,6 @@ vi.mock('@/utils/var', () => ({ basePath: '/mock-base-path', })) -// ========================================== -// Test Data Builders -// ========================================== - const createMockDataSourceNodeData = (overrides?: Partial): DataSourceNodeType => ({ title: 'Test Data Source', desc: 'Test description', @@ -99,10 +91,6 @@ const createMockDataSourceListItem = (overrides?: Record) => ({ ...overrides, }) -// ========================================== -// Test Utilities -// ========================================== - const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -131,9 +119,7 @@ const createHookWrapper = () => { ) } -// ========================================== // DatasourceIcon Tests -// ========================================== describe('DatasourceIcon', () => { beforeEach(() => { vi.clearAllMocks() @@ -141,27 +127,21 @@ describe('DatasourceIcon', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render() - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render icon with background image', () => { - // Arrange const iconUrl = 'https://example.com/icon.png' - // Act const { container } = render() - // Assert const iconDiv = container.querySelector('[style*="background-image"]') expect(iconDiv).toHaveStyle({ backgroundImage: `url(${iconUrl})` }) }) it('should render with default size (sm)', () => { - // Arrange & Act const { container } = render() // Assert - Default size is 'sm' which maps to 'w-5 h-5' @@ -173,36 +153,30 @@ describe('DatasourceIcon', () => { describe('Props', () => { describe('size', () => { it('should render with xs size', () => { - // Arrange & Act const { container } = render( , ) - // Assert expect(container.firstChild).toHaveClass('w-4') expect(container.firstChild).toHaveClass('h-4') expect(container.firstChild).toHaveClass('rounded-[5px]') }) it('should render with sm size', () => { - // Arrange & Act const { container } = render( , ) - // Assert expect(container.firstChild).toHaveClass('w-5') expect(container.firstChild).toHaveClass('h-5') expect(container.firstChild).toHaveClass('rounded-md') }) it('should render with md size', () => { - // Arrange & Act const { container } = render( , ) - // Assert expect(container.firstChild).toHaveClass('w-6') expect(container.firstChild).toHaveClass('h-6') expect(container.firstChild).toHaveClass('rounded-lg') @@ -211,22 +185,18 @@ describe('DatasourceIcon', () => { describe('className', () => { it('should apply custom className', () => { - // Arrange & Act const { container } = render( , ) - // Assert expect(container.firstChild).toHaveClass('custom-class') }) it('should merge custom className with default classes', () => { - // Arrange & Act const { container } = render( , ) - // Assert expect(container.firstChild).toHaveClass('custom-class') expect(container.firstChild).toHaveClass('w-5') expect(container.firstChild).toHaveClass('h-5') @@ -235,34 +205,26 @@ describe('DatasourceIcon', () => { describe('iconUrl', () => { it('should handle empty iconUrl', () => { - // Arrange & Act const { container } = render() - // Assert const iconDiv = container.querySelector('[style*="background-image"]') expect(iconDiv).toHaveStyle({ backgroundImage: 'url()' }) }) it('should handle special characters in iconUrl', () => { - // Arrange const iconUrl = 'https://example.com/icon.png?param=value&other=123' - // Act const { container } = render() - // Assert const iconDiv = container.querySelector('[style*="background-image"]') expect(iconDiv).toHaveStyle({ backgroundImage: `url(${iconUrl})` }) }) it('should handle data URL as iconUrl', () => { - // Arrange const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' - // Act const { container } = render() - // Assert const iconDiv = container.querySelector('[style*="background-image"]') expect(iconDiv).toBeInTheDocument() }) @@ -271,17 +233,14 @@ describe('DatasourceIcon', () => { describe('Styling', () => { it('should have flex container classes', () => { - // Arrange & Act const { container } = render() - // Assert expect(container.firstChild).toHaveClass('flex') expect(container.firstChild).toHaveClass('items-center') expect(container.firstChild).toHaveClass('justify-center') }) it('should have shadow-xs class from size map', () => { - // Arrange & Act const { container } = render() // Assert - Default size 'sm' has shadow-xs @@ -289,10 +248,8 @@ describe('DatasourceIcon', () => { }) it('should have inner div with bg-cover class', () => { - // Arrange & Act const { container } = render() - // Assert const innerDiv = container.querySelector('.bg-cover') expect(innerDiv).toBeInTheDocument() expect(innerDiv).toHaveClass('bg-center') @@ -301,9 +258,7 @@ describe('DatasourceIcon', () => { }) }) -// ========================================== // useDatasourceIcon Hook Tests -// ========================================== describe('useDatasourceIcon', () => { beforeEach(() => { vi.clearAllMocks() @@ -319,39 +274,32 @@ describe('useDatasourceIcon', () => { describe('Loading State', () => { it('should return undefined when data is not loaded', () => { - // Arrange mockUseDataSourceList.mockReturnValue({ data: undefined, isSuccess: false, }) const nodeData = createMockDataSourceNodeData() - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) - // Assert expect(result.current).toBeUndefined() }) it('should call useDataSourceList with true', () => { - // Arrange const nodeData = createMockDataSourceNodeData() - // Act renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) - // Assert expect(mockUseDataSourceList).toHaveBeenCalledWith(true) }) }) describe('Success State', () => { it('should return icon when data is loaded and plugin matches', () => { - // Arrange const mockDataSourceList = [ createMockDataSourceListItem({ plugin_id: 'test-plugin-id', @@ -374,7 +322,6 @@ describe('useDatasourceIcon', () => { })) const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) @@ -384,7 +331,6 @@ describe('useDatasourceIcon', () => { }) it('should return undefined when plugin does not match', () => { - // Arrange const mockDataSourceList = [ createMockDataSourceListItem({ plugin_id: 'other-plugin-id', @@ -396,17 +342,14 @@ describe('useDatasourceIcon', () => { }) const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) - // Assert expect(result.current).toBeUndefined() }) it('should prepend basePath to icon when icon does not include basePath', () => { - // Arrange const mockDataSourceList = [ createMockDataSourceListItem({ plugin_id: 'test-plugin-id', @@ -429,7 +372,6 @@ describe('useDatasourceIcon', () => { })) const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) @@ -439,7 +381,6 @@ describe('useDatasourceIcon', () => { }) it('should not prepend basePath when icon already includes basePath', () => { - // Arrange const mockDataSourceList = [ createMockDataSourceListItem({ plugin_id: 'test-plugin-id', @@ -462,7 +403,6 @@ describe('useDatasourceIcon', () => { })) const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) @@ -474,41 +414,34 @@ describe('useDatasourceIcon', () => { describe('Edge Cases', () => { it('should handle empty dataSourceList', () => { - // Arrange mockUseDataSourceList.mockReturnValue({ data: [], isSuccess: true, }) const nodeData = createMockDataSourceNodeData() - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) - // Assert expect(result.current).toBeUndefined() }) it('should handle null dataSourceList', () => { - // Arrange mockUseDataSourceList.mockReturnValue({ data: null, isSuccess: true, }) const nodeData = createMockDataSourceNodeData() - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) - // Assert expect(result.current).toBeUndefined() }) it('should handle icon as non-string type', () => { - // Arrange const mockDataSourceList = [ createMockDataSourceListItem({ plugin_id: 'test-plugin-id', @@ -531,7 +464,6 @@ describe('useDatasourceIcon', () => { })) const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) - // Act const { result } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) @@ -541,7 +473,6 @@ describe('useDatasourceIcon', () => { }) it('should memoize result based on plugin_id', () => { - // Arrange const mockDataSourceList = [ createMockDataSourceListItem({ plugin_id: 'test-plugin-id', @@ -553,7 +484,6 @@ describe('useDatasourceIcon', () => { }) const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) - // Act const { result, rerender } = renderHook(() => useDatasourceIcon(nodeData), { wrapper: createHookWrapper(), }) @@ -568,9 +498,7 @@ describe('useDatasourceIcon', () => { }) }) -// ========================================== // OptionCard Tests -// ========================================== describe('OptionCard', () => { const defaultProps = { label: 'Test Option', @@ -589,23 +517,18 @@ describe('OptionCard', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act renderWithProviders() - // Assert expect(screen.getByText('Test Option')).toBeInTheDocument() }) it('should render label text', () => { - // Arrange & Act renderWithProviders() - // Assert expect(screen.getByText('Custom Label')).toBeInTheDocument() }) it('should render DatasourceIcon component', () => { - // Arrange & Act const { container } = renderWithProviders() // Assert - DatasourceIcon container should exist @@ -614,13 +537,10 @@ describe('OptionCard', () => { }) it('should set title attribute for label truncation', () => { - // Arrange const longLabel = 'This is a very long label that might be truncated' - // Act renderWithProviders() - // Assert const labelElement = screen.getByText(longLabel) expect(labelElement).toHaveAttribute('title', longLabel) }) @@ -629,43 +549,35 @@ describe('OptionCard', () => { describe('Props', () => { describe('selected', () => { it('should apply selected styles when selected is true', () => { - // Arrange & Act const { container } = renderWithProviders( , ) - // Assert const card = container.firstChild expect(card).toHaveClass('border-components-option-card-option-selected-border') expect(card).toHaveClass('bg-components-option-card-option-selected-bg') }) it('should apply unselected styles when selected is false', () => { - // Arrange & Act const { container } = renderWithProviders( , ) - // Assert const card = container.firstChild expect(card).toHaveClass('border-components-option-card-option-border') expect(card).toHaveClass('bg-components-option-card-option-bg') }) it('should apply text-text-primary to label when selected', () => { - // Arrange & Act renderWithProviders() - // Assert const label = screen.getByText('Test Option') expect(label).toHaveClass('text-text-primary') }) it('should apply text-text-secondary to label when not selected', () => { - // Arrange & Act renderWithProviders() - // Assert const label = screen.getByText('Test Option') expect(label).toHaveClass('text-text-secondary') }) @@ -673,7 +585,6 @@ describe('OptionCard', () => { describe('onClick', () => { it('should call onClick when card is clicked', () => { - // Arrange const mockOnClick = vi.fn() renderWithProviders( , @@ -685,12 +596,10 @@ describe('OptionCard', () => { expect(card).toBeInTheDocument() fireEvent.click(card!) - // Assert expect(mockOnClick).toHaveBeenCalledTimes(1) }) it('should not crash when onClick is not provided', () => { - // Arrange & Act renderWithProviders( , ) @@ -708,10 +617,8 @@ describe('OptionCard', () => { describe('nodeData', () => { it('should pass nodeData to useDatasourceIcon hook', () => { - // Arrange const customNodeData = createMockDataSourceNodeData({ plugin_id: 'custom-plugin' }) - // Act renderWithProviders() // Assert - Hook should be called (via useDataSourceList mock) @@ -722,45 +629,35 @@ describe('OptionCard', () => { describe('Styling', () => { it('should have cursor-pointer class', () => { - // Arrange & Act const { container } = renderWithProviders() - // Assert expect(container.firstChild).toHaveClass('cursor-pointer') }) it('should have flex layout classes', () => { - // Arrange & Act const { container } = renderWithProviders() - // Assert expect(container.firstChild).toHaveClass('flex') expect(container.firstChild).toHaveClass('items-center') expect(container.firstChild).toHaveClass('gap-2') }) it('should have rounded-xl border', () => { - // Arrange & Act const { container } = renderWithProviders() - // Assert expect(container.firstChild).toHaveClass('rounded-xl') expect(container.firstChild).toHaveClass('border') }) it('should have padding p-3', () => { - // Arrange & Act const { container } = renderWithProviders() - // Assert expect(container.firstChild).toHaveClass('p-3') }) it('should have line-clamp-2 for label truncation', () => { - // Arrange & Act renderWithProviders() - // Assert const label = screen.getByText('Test Option') expect(label).toHaveClass('line-clamp-2') }) @@ -777,9 +674,7 @@ describe('OptionCard', () => { }) }) -// ========================================== // DataSourceOptions Tests -// ========================================== describe('DataSourceOptions', () => { const defaultNodes = createMockPipelineNodes(3) const defaultOptions = defaultNodes.map(createMockDatasourceOption) @@ -799,35 +694,26 @@ describe('DataSourceOptions', () => { }) }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act renderWithProviders() - // Assert expect(screen.getByText('Data Source 1')).toBeInTheDocument() expect(screen.getByText('Data Source 2')).toBeInTheDocument() expect(screen.getByText('Data Source 3')).toBeInTheDocument() }) it('should render correct number of option cards', () => { - // Arrange & Act renderWithProviders() - // Assert expect(screen.getByText('Data Source 1')).toBeInTheDocument() expect(screen.getByText('Data Source 2')).toBeInTheDocument() expect(screen.getByText('Data Source 3')).toBeInTheDocument() }) it('should render with grid layout', () => { - // Arrange & Act const { container } = renderWithProviders() - // Assert const gridContainer = container.firstChild expect(gridContainer).toHaveClass('grid') expect(gridContainer).toHaveClass('w-full') @@ -836,68 +722,53 @@ describe('DataSourceOptions', () => { }) it('should render no option cards when options is empty', () => { - // Arrange mockUseDatasourceOptions.mockReturnValue([]) - // Act const { container } = renderWithProviders() - // Assert expect(screen.queryByText('Data Source')).not.toBeInTheDocument() // Grid container should still exist expect(container.firstChild).toHaveClass('grid') }) it('should render single option card when only one option exists', () => { - // Arrange const singleOption = [createMockDatasourceOption(defaultNodes[0])] mockUseDatasourceOptions.mockReturnValue(singleOption) - // Act renderWithProviders() - // Assert expect(screen.getByText('Data Source 1')).toBeInTheDocument() expect(screen.queryByText('Data Source 2')).not.toBeInTheDocument() }) }) - // ========================================== // Props Tests - // ========================================== describe('Props', () => { describe('pipelineNodes', () => { it('should pass pipelineNodes to useDatasourceOptions hook', () => { - // Arrange const customNodes = createMockPipelineNodes(2) mockUseDatasourceOptions.mockReturnValue(customNodes.map(createMockDatasourceOption)) - // Act renderWithProviders( , ) - // Assert expect(mockUseDatasourceOptions).toHaveBeenCalledWith(customNodes) }) it('should handle empty pipelineNodes array', () => { - // Arrange mockUseDatasourceOptions.mockReturnValue([]) - // Act renderWithProviders( , ) - // Assert expect(mockUseDatasourceOptions).toHaveBeenCalledWith([]) }) }) describe('datasourceNodeId', () => { it('should mark corresponding option as selected', () => { - // Arrange & Act const { container } = renderWithProviders( { }) it('should show no selection when datasourceNodeId is empty', () => { - // Arrange & Act const { container } = renderWithProviders( { }) it('should show no selection when datasourceNodeId does not match any option', () => { - // Arrange & Act const { container } = renderWithProviders( { />, ) - // Assert const selectedCards = container.querySelectorAll('.border-components-option-card-option-selected-border') expect(selectedCards).toHaveLength(0) }) it('should update selection when datasourceNodeId changes', () => { - // Arrange const { container, rerender } = renderWithProviders( { describe('onSelect', () => { it('should receive onSelect callback', () => { - // Arrange const mockOnSelect = vi.fn() - // Act renderWithProviders( { }) }) - // ========================================== // Side Effects and Cleanup Tests - // ========================================== describe('Side Effects and Cleanup', () => { describe('useEffect - Auto-select first option', () => { it('should auto-select first option when options exist and no datasourceNodeId', () => { - // Arrange const mockOnSelect = vi.fn() - // Act renderWithProviders( { }) it('should NOT auto-select when datasourceNodeId is provided', () => { - // Arrange const mockOnSelect = vi.fn() - // Act renderWithProviders( { }) it('should NOT auto-select when options array is empty', () => { - // Arrange mockUseDatasourceOptions.mockReturnValue([]) const mockOnSelect = vi.fn() - // Act renderWithProviders( { />, ) - // Assert expect(mockOnSelect).not.toHaveBeenCalled() }) it('should only run useEffect once on initial mount', () => { - // Arrange const mockOnSelect = vi.fn() const { rerender } = renderWithProviders( { }) }) - // ========================================== // Callback Stability and Memoization Tests - // ========================================== describe('Callback Stability and Memoization', () => { it('should maintain callback reference stability across renders with same props', () => { - // Arrange const mockOnSelect = vi.fn() const { rerender } = renderWithProviders( @@ -1118,7 +970,6 @@ describe('DataSourceOptions', () => { }) it('should update callback when onSelect changes', () => { - // Arrange const mockOnSelect1 = vi.fn() const mockOnSelect2 = vi.fn() @@ -1157,7 +1008,6 @@ describe('DataSourceOptions', () => { }) it('should update callback when options change', () => { - // Arrange const mockOnSelect = vi.fn() const { rerender } = renderWithProviders( @@ -1201,13 +1051,10 @@ describe('DataSourceOptions', () => { }) }) - // ========================================== // User Interactions and Event Handlers Tests - // ========================================== describe('User Interactions and Event Handlers', () => { describe('Option Selection', () => { it('should call onSelect with correct datasource when clicking an option', () => { - // Arrange const mockOnSelect = vi.fn() renderWithProviders( { // Act - Click second option fireEvent.click(screen.getByText('Data Source 2')) - // Assert expect(mockOnSelect).toHaveBeenCalledTimes(1) expect(mockOnSelect).toHaveBeenCalledWith({ nodeId: 'node-2', @@ -1229,7 +1075,6 @@ describe('DataSourceOptions', () => { }) it('should allow selecting already selected option', () => { - // Arrange const mockOnSelect = vi.fn() renderWithProviders( { // Act - Click already selected option fireEvent.click(screen.getByText('Data Source 1')) - // Assert expect(mockOnSelect).toHaveBeenCalledTimes(1) expect(mockOnSelect).toHaveBeenCalledWith({ nodeId: 'node-1', @@ -1251,7 +1095,6 @@ describe('DataSourceOptions', () => { }) it('should allow multiple sequential selections', () => { - // Arrange const mockOnSelect = vi.fn() renderWithProviders( { fireEvent.click(screen.getByText('Data Source 2')) fireEvent.click(screen.getByText('Data Source 3')) - // Assert expect(mockOnSelect).toHaveBeenCalledTimes(3) expect(mockOnSelect).toHaveBeenNthCalledWith(1, { nodeId: 'node-1', @@ -1285,7 +1127,6 @@ describe('DataSourceOptions', () => { describe('handelSelect Internal Logic', () => { it('should handle rapid successive clicks', async () => { - // Arrange const mockOnSelect = vi.fn() renderWithProviders( { }) }) - // ========================================== // Edge Cases and Error Handling Tests - // ========================================== describe('Edge Cases and Error Handling', () => { describe('Empty States', () => { it('should handle empty options array gracefully', () => { - // Arrange mockUseDatasourceOptions.mockReturnValue([]) - // Act const { container } = renderWithProviders( { />, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should not crash when datasourceNodeId is undefined', () => { - // Arrange & Act renderWithProviders( { />, ) - // Assert expect(screen.getByText('Data Source 1')).toBeInTheDocument() }) }) describe('Null/Undefined Values', () => { it('should handle option with missing data properties', () => { - // Arrange const optionWithMinimalData = [{ label: 'Minimal Option', value: 'minimal-1', @@ -1367,22 +1200,18 @@ describe('DataSourceOptions', () => { }] mockUseDatasourceOptions.mockReturnValue(optionWithMinimalData) - // Act renderWithProviders() - // Assert expect(screen.getByText('Minimal Option')).toBeInTheDocument() }) }) describe('Large Data Sets', () => { it('should handle large number of options', () => { - // Arrange const manyNodes = createMockPipelineNodes(50) const manyOptions = manyNodes.map(createMockDatasourceOption) mockUseDatasourceOptions.mockReturnValue(manyOptions) - // Act renderWithProviders( { />, ) - // Assert expect(screen.getByText('Data Source 1')).toBeInTheDocument() expect(screen.getByText('Data Source 50')).toBeInTheDocument() }) @@ -1398,7 +1226,6 @@ describe('DataSourceOptions', () => { describe('Special Characters in Data', () => { it('should handle special characters in option labels', () => { - // Arrange const specialNode = createMockPipelineNode({ id: 'special-node', data: createMockDataSourceNodeData({ @@ -1408,7 +1235,6 @@ describe('DataSourceOptions', () => { const specialOptions = [createMockDatasourceOption(specialNode)] mockUseDatasourceOptions.mockReturnValue(specialOptions) - // Act renderWithProviders( { }) it('should handle unicode characters in option labels', () => { - // Arrange const unicodeNode = createMockPipelineNode({ id: 'unicode-node', data: createMockDataSourceNodeData({ @@ -1431,7 +1256,6 @@ describe('DataSourceOptions', () => { const unicodeOptions = [createMockDatasourceOption(unicodeNode)] mockUseDatasourceOptions.mockReturnValue(unicodeOptions) - // Act renderWithProviders( { />, ) - // Assert expect(screen.getByText('数据源 📁 Source émoji')).toBeInTheDocument() }) it('should handle empty string as option value', () => { - // Arrange const emptyValueOption = [{ label: 'Empty Value Option', value: '', @@ -1452,22 +1274,18 @@ describe('DataSourceOptions', () => { }] mockUseDatasourceOptions.mockReturnValue(emptyValueOption) - // Act renderWithProviders() - // Assert expect(screen.getByText('Empty Value Option')).toBeInTheDocument() }) }) describe('Boundary Conditions', () => { it('should handle single option selection correctly', () => { - // Arrange const singleOption = [createMockDatasourceOption(defaultNodes[0])] mockUseDatasourceOptions.mockReturnValue(singleOption) const mockOnSelect = vi.fn() - // Act renderWithProviders( { }) it('should handle options with same labels but different values', () => { - // Arrange const duplicateLabelOptions = [ { label: 'Duplicate Label', @@ -1498,7 +1315,6 @@ describe('DataSourceOptions', () => { mockUseDatasourceOptions.mockReturnValue(duplicateLabelOptions) const mockOnSelect = vi.fn() - // Act renderWithProviders( { const labels = screen.getAllByText('Duplicate Label') expect(labels).toHaveLength(2) - // Click second one fireEvent.click(labels[1]) expect(mockOnSelect).toHaveBeenCalledWith({ nodeId: 'node-b', @@ -1522,7 +1337,6 @@ describe('DataSourceOptions', () => { describe('Component Unmounting', () => { it('should handle unmounting without errors', () => { - // Arrange const mockOnSelect = vi.fn() const { unmount } = renderWithProviders( { />, ) - // Act unmount() // Assert - No errors thrown, component cleanly unmounted @@ -1539,7 +1352,6 @@ describe('DataSourceOptions', () => { }) it('should handle unmounting during rapid interactions', async () => { - // Arrange const mockOnSelect = vi.fn() const { unmount } = renderWithProviders( { }) }) - // ========================================== - // Integration Tests - // ========================================== describe('Integration', () => { it('should render OptionCard with correct props', () => { - // Arrange & Act const { container } = renderWithProviders() // Assert - Verify real OptionCard components are rendered @@ -1575,7 +1383,6 @@ describe('DataSourceOptions', () => { }) it('should correctly pass selected state to OptionCard', () => { - // Arrange & Act const { container } = renderWithProviders( { />, ) - // Assert const cards = container.querySelectorAll('.rounded-xl.border') expect(cards[0]).not.toHaveClass('border-components-option-card-option-selected-border') expect(cards[1]).toHaveClass('border-components-option-card-option-selected-border') @@ -1592,7 +1398,6 @@ describe('DataSourceOptions', () => { it('should use option.value as key for React rendering', () => { // This test verifies that React doesn't throw duplicate key warnings - // Arrange const uniqueValueOptions = createMockPipelineNodes(5).map(createMockDatasourceOption) mockUseDatasourceOptions.mockReturnValue(uniqueValueOptions) @@ -1600,7 +1405,6 @@ describe('DataSourceOptions', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(vi.fn()) renderWithProviders() - // Assert expect(consoleSpy).not.toHaveBeenCalledWith( expect.stringContaining('key'), ) @@ -1608,9 +1412,6 @@ describe('DataSourceOptions', () => { }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('All Prop Variations', () => { it.each([ { datasourceNodeId: '', description: 'empty string' }, @@ -1619,7 +1420,6 @@ describe('DataSourceOptions', () => { { datasourceNodeId: 'node-3', description: 'last node' }, { datasourceNodeId: 'non-existent', description: 'non-existent node' }, ])('should handle datasourceNodeId as $description', ({ datasourceNodeId }) => { - // Arrange & Act renderWithProviders( { />, ) - // Assert expect(screen.getByText('Data Source 1')).toBeInTheDocument() }) @@ -1637,12 +1436,10 @@ describe('DataSourceOptions', () => { { count: 3, description: 'few options' }, { count: 10, description: 'many options' }, ])('should render correctly with $description', ({ count }) => { - // Arrange const nodes = createMockPipelineNodes(count) const options = nodes.map(createMockDatasourceOption) mockUseDatasourceOptions.mockReturnValue(options) - // Act renderWithProviders( { />, ) - // Assert if (count > 0) expect(screen.getByText('Data Source 1')).toBeInTheDocument() else diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/option-card.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/option-card.spec.tsx new file mode 100644 index 0000000000..8f05b2671b --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/__tests__/option-card.spec.tsx @@ -0,0 +1,110 @@ +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import OptionCard from '../option-card' + +const TEST_ICON_URL = 'https://example.com/test-icon.png' + +vi.mock('../hooks', () => ({ + useDatasourceIcon: () => TEST_ICON_URL, +})) + +vi.mock('../datasource-icon', () => ({ + default: ({ iconUrl }: { iconUrl: string }) => ( + datasource + ), +})) + +const createMockNodeData = (overrides: Partial = {}): DataSourceNodeType => ({ + title: 'Test Node', + desc: '', + type: {} as DataSourceNodeType['type'], + plugin_id: 'test-plugin', + provider_type: 'builtin', + provider_name: 'test-provider', + datasource_name: 'test-ds', + datasource_label: 'Test DS', + datasource_parameters: {}, + datasource_configurations: {}, + ...overrides, +} as DataSourceNodeType) + +describe('OptionCard', () => { + const defaultProps = { + label: 'Google Drive', + selected: false, + nodeData: createMockNodeData(), + onClick: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: label text and icon + describe('Rendering', () => { + it('should render label text', () => { + render() + + expect(screen.getByText('Google Drive')).toBeInTheDocument() + }) + + it('should render datasource icon with correct URL', () => { + render() + + const icon = screen.getByTestId('datasource-icon') + expect(icon).toHaveAttribute('src', TEST_ICON_URL) + }) + + it('should set title attribute on label element', () => { + render() + + expect(screen.getByTitle('Google Drive')).toBeInTheDocument() + }) + }) + + // User interactions: clicking the card + describe('User Interactions', () => { + it('should call onClick when clicked', () => { + render() + + fireEvent.click(screen.getByText('Google Drive')) + + expect(defaultProps.onClick).toHaveBeenCalledOnce() + }) + + it('should not throw when onClick is undefined', () => { + expect(() => { + const { container } = render( + , + ) + fireEvent.click(container.firstElementChild!) + }).not.toThrow() + }) + }) + + // Props: selected state applies different styles + describe('Props', () => { + it('should apply selected styles when selected is true', () => { + const { container } = render() + + const card = container.firstElementChild + expect(card?.className).toContain('border-components-option-card-option-selected-border') + expect(card?.className).toContain('bg-components-option-card-option-selected-bg') + }) + + it('should apply default styles when selected is false', () => { + const { container } = render() + + const card = container.firstElementChild + expect(card?.className).not.toContain('border-components-option-card-option-selected-border') + }) + + it('should apply text-text-primary class to label when selected', () => { + render() + + const labelEl = screen.getByTitle('Google Drive') + expect(labelEl.className).toContain('text-text-primary') + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/__tests__/header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/__tests__/header.spec.tsx new file mode 100644 index 0000000000..48a0615bcc --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/__tests__/header.spec.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import Header from '../header' + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children }: { children: React.ReactNode }) => , +})) + +vi.mock('@/app/components/base/divider', () => ({ + default: () => , +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +vi.mock('../credential-selector', () => ({ + default: () =>
, +})) + +describe('Header', () => { + const defaultProps = { + docTitle: 'Documentation', + docLink: 'https://docs.example.com', + onClickConfiguration: vi.fn(), + pluginName: 'TestPlugin', + credentials: [], + currentCredentialId: '', + onCredentialChange: vi.fn(), + } + + it('should render doc link with title', () => { + render(
) + expect(screen.getByText('Documentation')).toBeInTheDocument() + }) + + it('should render credential selector', () => { + render(
) + expect(screen.getByTestId('credential-selector')).toBeInTheDocument() + }) + + it('should link to external doc', () => { + render(
) + const link = screen.getByText('Documentation').closest('a') + expect(link).toHaveAttribute('href', 'https://docs.example.com') + expect(link).toHaveAttribute('target', '_blank') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx similarity index 89% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx index da5075ec8a..d595a50fe1 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/index.spec.tsx @@ -1,8 +1,8 @@ -import type { CredentialSelectorProps } from './index' +import type { CredentialSelectorProps } from '../index' import type { DataSourceCredential } from '@/types/pipeline' import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import * as React from 'react' -import CredentialSelector from './index' +import CredentialSelector from '../index' // Mock CredentialTypeEnum to avoid deep import chain issues enum MockCredentialTypeEnum { @@ -20,26 +20,25 @@ vi.mock('@/app/components/plugins/plugin-auth', () => ({ // Mock portal-to-follow-elem - use React state to properly handle open/close vi.mock('@/app/components/base/portal-to-follow-elem', () => { - const MockPortalToFollowElem = ({ children, open }: any) => { + const MockPortalToFollowElem = ({ children, open }: { children: React.ReactNode, open: boolean }) => { return (
- {React.Children.map(children, (child: any) => { - if (!child) + {React.Children.map(children, (child) => { + if (!React.isValidElement(child)) return null - // Pass open state to children via context-like prop cloning - return React.cloneElement(child, { __portalOpen: open }) + return React.cloneElement(child as React.ReactElement<{ __portalOpen?: boolean }>, { __portalOpen: open }) })}
) } - const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: any) => ( + const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: { children: React.ReactNode, onClick?: React.MouseEventHandler, className?: string, __portalOpen?: boolean }) => (
{children}
) - const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: any) => { + const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: { children: React.ReactNode, className?: string, __portalOpen?: boolean }) => { // Match actual behavior: returns null when not open if (!__portalOpen) return null @@ -60,9 +59,6 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => { // CredentialIcon - imported directly (not mocked) // This is a simple UI component with no external dependencies -// ========================================== -// Test Data Builders -// ========================================== const createMockCredential = (overrides?: Partial): DataSourceCredential => ({ id: 'cred-1', name: 'Test Credential', @@ -94,38 +90,28 @@ describe('CredentialSelector', () => { vi.clearAllMocks() }) - // ========================================== // Rendering Tests - Verify component renders correctly - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('portal-root')).toBeInTheDocument() expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() }) it('should render current credential name in trigger', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByText('Credential 1')).toBeInTheDocument() }) it('should render credential icon with correct props', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() // Assert - CredentialIcon renders an img when avatarUrl is provided @@ -135,30 +121,23 @@ describe('CredentialSelector', () => { }) it('should render dropdown arrow icon', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render() - // Assert const svgIcon = container.querySelector('svg') expect(svgIcon).toBeInTheDocument() }) it('should not render dropdown content initially', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() }) it('should render all credentials in dropdown when opened', () => { - // Arrange const props = createDefaultProps() render() @@ -173,41 +152,30 @@ describe('CredentialSelector', () => { }) }) - // ========================================== // Props Testing - Verify all prop variations - // ========================================== describe('Props', () => { describe('currentCredentialId prop', () => { it('should display first credential when currentCredentialId matches first', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-1' }) - // Act render() - // Assert expect(screen.getByText('Credential 1')).toBeInTheDocument() }) it('should display second credential when currentCredentialId matches second', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-2' }) - // Act render() - // Assert expect(screen.getByText('Credential 2')).toBeInTheDocument() }) it('should display third credential when currentCredentialId matches third', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-3' }) - // Act render() - // Assert expect(screen.getByText('Credential 3')).toBeInTheDocument() }) @@ -216,41 +184,33 @@ describe('CredentialSelector', () => { ['cred-2', 'Credential 2'], ['cred-3', 'Credential 3'], ])('should display %s credential name when currentCredentialId is %s', (credId, expectedName) => { - // Arrange const props = createDefaultProps({ currentCredentialId: credId }) - // Act render() - // Assert expect(screen.getByText(expectedName)).toBeInTheDocument() }) }) describe('credentials prop', () => { it('should render single credential correctly', () => { - // Arrange const props = createDefaultProps({ credentials: [createMockCredential()], currentCredentialId: 'cred-1', }) - // Act render() - // Assert expect(screen.getByText('Test Credential')).toBeInTheDocument() }) it('should render multiple credentials in dropdown', () => { - // Arrange const props = createDefaultProps({ credentials: createMockCredentials(5), currentCredentialId: 'cred-1', }) render() - // Act const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) @@ -259,23 +219,19 @@ describe('CredentialSelector', () => { }) it('should handle credentials with special characters in name', () => { - // Arrange const props = createDefaultProps({ credentials: [createMockCredential({ id: 'cred-special', name: 'Test & Credential ' })], currentCredentialId: 'cred-special', }) - // Act render() - // Assert expect(screen.getByText('Test & Credential ')).toBeInTheDocument() }) }) describe('onCredentialChange prop', () => { it('should be called when selecting a credential', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) render() @@ -284,11 +240,9 @@ describe('CredentialSelector', () => { const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) - // Click on second credential const credential2 = screen.getByText('Credential 2') fireEvent.click(credential2) - // Assert expect(mockOnChange).toHaveBeenCalledWith('cred-2') }) @@ -296,7 +250,6 @@ describe('CredentialSelector', () => { ['cred-2', 'Credential 2'], ['cred-3', 'Credential 3'], ])('should call onCredentialChange with %s when selecting %s', (credId, credentialName) => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) render() @@ -310,7 +263,6 @@ describe('CredentialSelector', () => { const credentialOption = within(portalContent).getByText(credentialName) fireEvent.click(credentialOption) - // Assert expect(mockOnChange).toHaveBeenCalledWith(credId) }) @@ -330,18 +282,14 @@ describe('CredentialSelector', () => { const credential1 = screen.getByText('Credential 1') fireEvent.click(credential1) - // Assert expect(mockOnChange).toHaveBeenCalledWith('cred-1') }) }) }) - // ========================================== // User Interactions - Test event handlers - // ========================================== describe('User Interactions', () => { it('should toggle dropdown open when trigger is clicked', () => { - // Arrange const props = createDefaultProps() render() @@ -357,24 +305,20 @@ describe('CredentialSelector', () => { }) it('should call onCredentialChange when clicking a credential item', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) render() - // Act const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) const credential2 = screen.getByText('Credential 2') fireEvent.click(credential2) - // Assert expect(mockOnChange).toHaveBeenCalledTimes(1) expect(mockOnChange).toHaveBeenCalledWith('cred-2') }) it('should close dropdown after selecting a credential', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) render() @@ -393,7 +337,6 @@ describe('CredentialSelector', () => { }) it('should handle rapid consecutive clicks on trigger', () => { - // Arrange const props = createDefaultProps() render() @@ -428,19 +371,15 @@ describe('CredentialSelector', () => { }) }) - // ========================================== // Side Effects and Cleanup - Test useEffect behavior - // ========================================== describe('Side Effects and Cleanup', () => { it('should auto-select first credential when currentCredential is not found and credentials exist', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ currentCredentialId: 'non-existent-id', onCredentialChange: mockOnChange, }) - // Act render() // Assert - Should auto-select first credential @@ -448,14 +387,12 @@ describe('CredentialSelector', () => { }) it('should not call onCredentialChange when currentCredential is found', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ currentCredentialId: 'cred-2', onCredentialChange: mockOnChange, }) - // Act render() // Assert - Should not auto-select @@ -463,7 +400,6 @@ describe('CredentialSelector', () => { }) it('should not call onCredentialChange when credentials array is empty', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ currentCredentialId: 'cred-1', @@ -471,7 +407,6 @@ describe('CredentialSelector', () => { onCredentialChange: mockOnChange, }) - // Act render() // Assert - Should not call since no credentials to select @@ -479,7 +414,6 @@ describe('CredentialSelector', () => { }) it('should auto-select when credentials change and currentCredential becomes invalid', async () => { - // Arrange const mockOnChange = vi.fn() const initialCredentials = createMockCredentials(3) const props = createDefaultProps({ @@ -510,7 +444,6 @@ describe('CredentialSelector', () => { }) it('should not trigger auto-select effect on every render with same props', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) @@ -524,12 +457,9 @@ describe('CredentialSelector', () => { }) }) - // ========================================== // Callback Stability and Memoization - Test useCallback behavior - // ========================================== describe('Callback Stability and Memoization', () => { it('should have stable handleCredentialChange callback', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) render() @@ -545,7 +475,6 @@ describe('CredentialSelector', () => { }) it('should update handleCredentialChange when onCredentialChange changes', () => { - // Arrange const mockOnChange1 = vi.fn() const mockOnChange2 = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange1 }) @@ -567,15 +496,11 @@ describe('CredentialSelector', () => { }) }) - // ========================================== // Memoization Logic and Dependencies - Test useMemo behavior - // ========================================== describe('Memoization Logic and Dependencies', () => { it('should find currentCredential by id', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-2' }) - // Act render() // Assert - Should display credential 2 @@ -583,7 +508,6 @@ describe('CredentialSelector', () => { }) it('should update currentCredential when currentCredentialId changes', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-1' }) const { rerender } = render() @@ -598,7 +522,6 @@ describe('CredentialSelector', () => { }) it('should update currentCredential when credentials array changes', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-1' }) const { rerender } = render() @@ -616,14 +539,12 @@ describe('CredentialSelector', () => { }) it('should return undefined currentCredential when id not found', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ currentCredentialId: 'non-existent', onCredentialChange: mockOnChange, }) - // Act render() // Assert - Should trigger auto-select effect @@ -631,17 +552,13 @@ describe('CredentialSelector', () => { }) }) - // ========================================== // Component Memoization - Test React.memo behavior - // ========================================== describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Assert expect(CredentialSelector.$$typeof).toBe(Symbol.for('react.memo')) }) it('should not re-render when props remain the same', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) const renderSpy = vi.fn() @@ -652,7 +569,6 @@ describe('CredentialSelector', () => { } const MemoizedTracked = React.memo(TrackedCredentialSelector) - // Act const { rerender } = render() rerender() @@ -661,22 +577,18 @@ describe('CredentialSelector', () => { }) it('should re-render when currentCredentialId changes', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-1' }) const { rerender } = render() // Assert initial expect(screen.getByText('Credential 1')).toBeInTheDocument() - // Act rerender() - // Assert expect(screen.getByText('Credential 2')).toBeInTheDocument() }) it('should re-render when credentials array reference changes', () => { - // Arrange const props = createDefaultProps() const { rerender } = render() @@ -686,12 +598,10 @@ describe('CredentialSelector', () => { ] rerender() - // Assert expect(screen.getByText('New Name 1')).toBeInTheDocument() }) it('should re-render when onCredentialChange reference changes', () => { - // Arrange const mockOnChange1 = vi.fn() const mockOnChange2 = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange1 }) @@ -711,18 +621,13 @@ describe('CredentialSelector', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle empty credentials array', () => { - // Arrange const props = createDefaultProps({ credentials: [], currentCredentialId: 'cred-1', }) - // Act render() // Assert - Should render without crashing @@ -730,7 +635,6 @@ describe('CredentialSelector', () => { }) it('should handle undefined avatar_url in credential', () => { - // Arrange const credentialWithoutAvatar = createMockCredential({ id: 'cred-no-avatar', name: 'No Avatar Credential', @@ -741,7 +645,6 @@ describe('CredentialSelector', () => { currentCredentialId: 'cred-no-avatar', }) - // Act const { container } = render() // Assert - Should render without crashing and show first letter fallback @@ -754,7 +657,6 @@ describe('CredentialSelector', () => { }) it('should handle empty string name in credential', () => { - // Arrange const credentialWithEmptyName = createMockCredential({ id: 'cred-empty-name', name: '', @@ -764,7 +666,6 @@ describe('CredentialSelector', () => { currentCredentialId: 'cred-empty-name', }) - // Act render() // Assert - Should render without crashing @@ -772,7 +673,6 @@ describe('CredentialSelector', () => { }) it('should handle very long credential name', () => { - // Arrange const longName = 'A'.repeat(200) const credentialWithLongName = createMockCredential({ id: 'cred-long-name', @@ -783,15 +683,12 @@ describe('CredentialSelector', () => { currentCredentialId: 'cred-long-name', }) - // Act render() - // Assert expect(screen.getByText(longName)).toBeInTheDocument() }) it('should handle special characters in credential name', () => { - // Arrange const specialName = '测试 Credential & "quoted"' const credentialWithSpecialName = createMockCredential({ id: 'cred-special', @@ -802,15 +699,12 @@ describe('CredentialSelector', () => { currentCredentialId: 'cred-special', }) - // Act render() - // Assert expect(screen.getByText(specialName)).toBeInTheDocument() }) it('should handle numeric id as string', () => { - // Arrange const credentialWithNumericId = createMockCredential({ id: '123456', name: 'Numeric ID Credential', @@ -820,30 +714,24 @@ describe('CredentialSelector', () => { currentCredentialId: '123456', }) - // Act render() - // Assert expect(screen.getByText('Numeric ID Credential')).toBeInTheDocument() }) it('should handle large number of credentials', () => { - // Arrange const manyCredentials = createMockCredentials(100) const props = createDefaultProps({ credentials: manyCredentials, currentCredentialId: 'cred-50', }) - // Act render() - // Assert expect(screen.getByText('Credential 50')).toBeInTheDocument() }) it('should handle credential selection with duplicate names', () => { - // Arrange const mockOnChange = vi.fn() const duplicateCredentials = [ createMockCredential({ id: 'cred-1', name: 'Same Name' }), @@ -855,7 +743,6 @@ describe('CredentialSelector', () => { onCredentialChange: mockOnChange, }) - // Act render() const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) @@ -865,7 +752,6 @@ describe('CredentialSelector', () => { const sameNameElements = screen.getAllByText('Same Name') expect(sameNameElements.length).toBe(3) - // Click the last dropdown item (cred-2 in dropdown) fireEvent.click(sameNameElements[2]) // Assert - Should call with the correct id even with duplicate names @@ -873,12 +759,10 @@ describe('CredentialSelector', () => { }) it('should not crash when clicking credential after unmount', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) const { unmount } = render() - // Act const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) @@ -891,7 +775,6 @@ describe('CredentialSelector', () => { }) it('should handle whitespace-only credential name', () => { - // Arrange const credentialWithWhitespace = createMockCredential({ id: 'cred-whitespace', name: ' ', @@ -901,7 +784,6 @@ describe('CredentialSelector', () => { currentCredentialId: 'cred-whitespace', }) - // Act render() // Assert - Should render without crashing @@ -909,58 +791,43 @@ describe('CredentialSelector', () => { }) }) - // ========================================== // Styling and CSS Classes - // ========================================== describe('Styling', () => { it('should apply overflow-hidden class to trigger', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert const trigger = screen.getByTestId('portal-trigger') expect(trigger).toHaveClass('overflow-hidden') }) it('should apply grow class to trigger', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert const trigger = screen.getByTestId('portal-trigger') expect(trigger).toHaveClass('grow') }) it('should apply z-10 class to dropdown content', () => { - // Arrange const props = createDefaultProps() render() - // Act const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) - // Assert const content = screen.getByTestId('portal-content') expect(content).toHaveClass('z-10') }) }) - // ========================================== // Integration with Child Components - // ========================================== describe('Integration with Child Components', () => { it('should pass currentCredential to Trigger component', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-2' }) - // Act render() // Assert - Trigger should display the correct credential @@ -968,7 +835,6 @@ describe('CredentialSelector', () => { }) it('should pass isOpen state to Trigger component', () => { - // Arrange const props = createDefaultProps() render() @@ -985,11 +851,9 @@ describe('CredentialSelector', () => { }) it('should pass credentials to List component', () => { - // Arrange const props = createDefaultProps() render() - // Act const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) @@ -1000,11 +864,9 @@ describe('CredentialSelector', () => { }) it('should pass currentCredentialId to List component', () => { - // Arrange const props = createDefaultProps({ currentCredentialId: 'cred-2' }) render() - // Act const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) @@ -1015,12 +877,10 @@ describe('CredentialSelector', () => { }) it('should pass handleCredentialChange to List component', () => { - // Arrange const mockOnChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnChange }) render() - // Act const trigger = screen.getByTestId('portal-trigger') fireEvent.click(trigger) const credential3 = screen.getByText('Credential 3') @@ -1031,9 +891,7 @@ describe('CredentialSelector', () => { }) }) - // ========================================== // Portal Configuration - // ========================================== describe('Portal Configuration', () => { it('should configure PortalToFollowElem with placement bottom-start', () => { // This test verifies the portal is configured correctly diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/item.spec.tsx new file mode 100644 index 0000000000..7aa6c8f0c3 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/item.spec.tsx @@ -0,0 +1,32 @@ +import type { DataSourceCredential } from '@/types/pipeline' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Item from '../item' + +vi.mock('@/app/components/datasets/common/credential-icon', () => ({ + CredentialIcon: () => , +})) + +describe('CredentialSelectorItem', () => { + const defaultProps = { + credential: { id: 'cred-1', name: 'My Account', avatar_url: 'https://example.com/avatar.png' } as DataSourceCredential, + isSelected: false, + onCredentialChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render credential name and icon', () => { + render() + expect(screen.getByText('My Account')).toBeInTheDocument() + expect(screen.getByTestId('credential-icon')).toBeInTheDocument() + }) + + it('should call onCredentialChange with credential id on click', () => { + render() + fireEvent.click(screen.getByText('My Account')) + expect(defaultProps.onCredentialChange).toHaveBeenCalledWith('cred-1') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/list.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/list.spec.tsx new file mode 100644 index 0000000000..e67ee24524 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/list.spec.tsx @@ -0,0 +1,37 @@ +import type { DataSourceCredential } from '@/types/pipeline' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import List from '../list' + +vi.mock('@/app/components/datasets/common/credential-icon', () => ({ + CredentialIcon: () => , +})) + +describe('CredentialSelectorList', () => { + const mockCredentials: DataSourceCredential[] = [ + { id: 'cred-1', name: 'Account A', avatar_url: '' } as DataSourceCredential, + { id: 'cred-2', name: 'Account B', avatar_url: '' } as DataSourceCredential, + ] + + const defaultProps = { + currentCredentialId: 'cred-1', + credentials: mockCredentials, + onCredentialChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render all credentials', () => { + render() + expect(screen.getByText('Account A')).toBeInTheDocument() + expect(screen.getByText('Account B')).toBeInTheDocument() + }) + + it('should call onCredentialChange on item click', () => { + render() + fireEvent.click(screen.getByText('Account B')) + expect(defaultProps.onCredentialChange).toHaveBeenCalledWith('cred-2') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/trigger.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/trigger.spec.tsx new file mode 100644 index 0000000000..3e5cec12b8 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/__tests__/trigger.spec.tsx @@ -0,0 +1,36 @@ +import type { DataSourceCredential } from '@/types/pipeline' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import Trigger from '../trigger' + +vi.mock('@/app/components/datasets/common/credential-icon', () => ({ + CredentialIcon: () => , +})) + +describe('CredentialSelectorTrigger', () => { + it('should render credential name when provided', () => { + render( + , + ) + expect(screen.getByText('Account A')).toBeInTheDocument() + }) + + it('should render empty name when no credential', () => { + render() + expect(screen.getByTestId('credential-icon')).toBeInTheDocument() + }) + + it('should apply hover style when open', () => { + const { container } = render( + , + ) + const wrapper = container.firstChild as HTMLElement + expect(wrapper.className).toContain('bg-state-base-hover') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.spec.tsx deleted file mode 100644 index 31be2cdba6..0000000000 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.spec.tsx +++ /dev/null @@ -1,658 +0,0 @@ -import type { DataSourceCredential } from '@/types/pipeline' -import { fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' -import Header from './header' - -// Mock CredentialTypeEnum to avoid deep import chain issues -enum MockCredentialTypeEnum { - OAUTH2 = 'oauth2', - API_KEY = 'api_key', -} - -// Mock plugin-auth module to avoid deep import chain issues -vi.mock('@/app/components/plugins/plugin-auth', () => ({ - CredentialTypeEnum: { - OAUTH2: 'oauth2', - API_KEY: 'api_key', - }, -})) - -// Mock portal-to-follow-elem - required for CredentialSelector -vi.mock('@/app/components/base/portal-to-follow-elem', () => { - const MockPortalToFollowElem = ({ children, open }: any) => { - return ( -
- {React.Children.map(children, (child: any) => { - if (!child) - return null - return React.cloneElement(child, { __portalOpen: open }) - })} -
- ) - } - - const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: any) => ( -
- {children} -
- ) - - const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: any) => { - if (!__portalOpen) - return null - return ( -
- {children} -
- ) - } - - return { - PortalToFollowElem: MockPortalToFollowElem, - PortalToFollowElemTrigger: MockPortalToFollowElemTrigger, - PortalToFollowElemContent: MockPortalToFollowElemContent, - } -}) - -// ========================================== -// Test Data Builders -// ========================================== -const createMockCredential = (overrides?: Partial): DataSourceCredential => ({ - id: 'cred-1', - name: 'Test Credential', - avatar_url: 'https://example.com/avatar.png', - credential: { key: 'value' }, - is_default: false, - type: MockCredentialTypeEnum.OAUTH2 as unknown as DataSourceCredential['type'], - ...overrides, -}) - -const createMockCredentials = (count: number = 3): DataSourceCredential[] => - Array.from({ length: count }, (_, i) => - createMockCredential({ - id: `cred-${i + 1}`, - name: `Credential ${i + 1}`, - avatar_url: `https://example.com/avatar-${i + 1}.png`, - is_default: i === 0, - })) - -type HeaderProps = React.ComponentProps - -const createDefaultProps = (overrides?: Partial): HeaderProps => ({ - docTitle: 'Documentation', - docLink: 'https://docs.example.com', - pluginName: 'Test Plugin', - currentCredentialId: 'cred-1', - onCredentialChange: vi.fn(), - credentials: createMockCredentials(), - ...overrides, -}) - -describe('Header', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // ========================================== - // Rendering Tests - // ========================================== - describe('Rendering', () => { - it('should render without crashing', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - expect(screen.getByText('Documentation')).toBeInTheDocument() - }) - - it('should render documentation link with correct attributes', () => { - // Arrange - const props = createDefaultProps({ - docTitle: 'API Docs', - docLink: 'https://api.example.com/docs', - }) - - // Act - render(
) - - // Assert - const link = screen.getByRole('link', { name: /API Docs/i }) - expect(link).toHaveAttribute('href', 'https://api.example.com/docs') - expect(link).toHaveAttribute('target', '_blank') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') - }) - - it('should render document title with title attribute', () => { - // Arrange - const props = createDefaultProps({ docTitle: 'My Documentation' }) - - // Act - render(
) - - // Assert - const titleSpan = screen.getByText('My Documentation') - expect(titleSpan).toHaveAttribute('title', 'My Documentation') - }) - - it('should render CredentialSelector with correct props', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - CredentialSelector should render current credential name - expect(screen.getByText('Credential 1')).toBeInTheDocument() - }) - - it('should render configuration button', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should render book icon in documentation link', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - RiBookOpenLine renders as SVG - const link = screen.getByRole('link') - const svg = link.querySelector('svg') - expect(svg).toBeInTheDocument() - }) - - it('should render divider between credential selector and configuration button', () => { - // Arrange - const props = createDefaultProps() - - // Act - const { container } = render(
) - - // Assert - Divider component should be rendered - // Divider typically renders as a div with specific styling - const divider = container.querySelector('[class*="divider"]') || container.querySelector('.mx-1.h-3\\.5') - expect(divider).toBeInTheDocument() - }) - }) - - // ========================================== - // Props Testing - // ========================================== - describe('Props', () => { - describe('docTitle prop', () => { - it('should display the document title', () => { - // Arrange - const props = createDefaultProps({ docTitle: 'Getting Started Guide' }) - - // Act - render(
) - - // Assert - expect(screen.getByText('Getting Started Guide')).toBeInTheDocument() - }) - - it.each([ - 'Quick Start', - 'API Reference', - 'Configuration Guide', - 'Plugin Documentation', - ])('should display "%s" as document title', (title) => { - // Arrange - const props = createDefaultProps({ docTitle: title }) - - // Act - render(
) - - // Assert - expect(screen.getByText(title)).toBeInTheDocument() - }) - }) - - describe('docLink prop', () => { - it('should set correct href on documentation link', () => { - // Arrange - const props = createDefaultProps({ docLink: 'https://custom.docs.com/guide' }) - - // Act - render(
) - - // Assert - const link = screen.getByRole('link') - expect(link).toHaveAttribute('href', 'https://custom.docs.com/guide') - }) - - it.each([ - 'https://docs.dify.ai', - 'https://example.com/api', - '/local/docs', - ])('should accept "%s" as docLink', (link) => { - // Arrange - const props = createDefaultProps({ docLink: link }) - - // Act - render(
) - - // Assert - expect(screen.getByRole('link')).toHaveAttribute('href', link) - }) - }) - - describe('pluginName prop', () => { - it('should pass pluginName to translation function', () => { - // Arrange - const props = createDefaultProps({ pluginName: 'MyPlugin' }) - - // Act - render(
) - - // Assert - The translation mock returns the key with options - // Tooltip uses the translated content - expect(screen.getByRole('button')).toBeInTheDocument() - }) - }) - - describe('onClickConfiguration prop', () => { - it('should call onClickConfiguration when configuration icon is clicked', () => { - // Arrange - const mockOnClick = vi.fn() - const props = createDefaultProps({ onClickConfiguration: mockOnClick }) - render(
) - - // Act - Find the configuration button and click the icon inside - // The button contains the RiEqualizer2Line icon with onClick handler - const configButton = screen.getByRole('button') - const configIcon = configButton.querySelector('svg') - expect(configIcon).toBeInTheDocument() - fireEvent.click(configIcon!) - - // Assert - expect(mockOnClick).toHaveBeenCalledTimes(1) - }) - - it('should not crash when onClickConfiguration is undefined', () => { - // Arrange - const props = createDefaultProps({ onClickConfiguration: undefined }) - render(
) - - // Act - Find the configuration button and click the icon inside - const configButton = screen.getByRole('button') - const configIcon = configButton.querySelector('svg') - expect(configIcon).toBeInTheDocument() - fireEvent.click(configIcon!) - - // Assert - Component should still be rendered (no crash) - expect(screen.getByRole('button')).toBeInTheDocument() - }) - }) - - describe('CredentialSelector props passthrough', () => { - it('should pass currentCredentialId to CredentialSelector', () => { - // Arrange - const props = createDefaultProps({ currentCredentialId: 'cred-2' }) - - // Act - render(
) - - // Assert - Should display the second credential - expect(screen.getByText('Credential 2')).toBeInTheDocument() - }) - - it('should pass credentials to CredentialSelector', () => { - // Arrange - const customCredentials = [ - createMockCredential({ id: 'custom-1', name: 'Custom Credential' }), - ] - const props = createDefaultProps({ - credentials: customCredentials, - currentCredentialId: 'custom-1', - }) - - // Act - render(
) - - // Assert - expect(screen.getByText('Custom Credential')).toBeInTheDocument() - }) - - it('should pass onCredentialChange to CredentialSelector', () => { - // Arrange - const mockOnChange = vi.fn() - const props = createDefaultProps({ onCredentialChange: mockOnChange }) - render(
) - - // Act - Open dropdown and select a credential - // Use getAllByTestId and select the first one (CredentialSelector's trigger) - const triggers = screen.getAllByTestId('portal-trigger') - fireEvent.click(triggers[0]) - const credential2 = screen.getByText('Credential 2') - fireEvent.click(credential2) - - // Assert - expect(mockOnChange).toHaveBeenCalledWith('cred-2') - }) - }) - }) - - // ========================================== - // User Interactions - // ========================================== - describe('User Interactions', () => { - it('should open external link in new tab when clicking documentation link', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - Link has target="_blank" for new tab - const link = screen.getByRole('link') - expect(link).toHaveAttribute('target', '_blank') - }) - - it('should allow credential selection through CredentialSelector', () => { - // Arrange - const mockOnChange = vi.fn() - const props = createDefaultProps({ onCredentialChange: mockOnChange }) - render(
) - - // Act - Open dropdown (use first trigger which is CredentialSelector's) - const triggers = screen.getAllByTestId('portal-trigger') - fireEvent.click(triggers[0]) - - // Assert - Dropdown should be open - expect(screen.getByTestId('portal-content')).toBeInTheDocument() - }) - - it('should trigger configuration callback when clicking config icon', () => { - // Arrange - const mockOnConfig = vi.fn() - const props = createDefaultProps({ onClickConfiguration: mockOnConfig }) - const { container } = render(
) - - // Act - const configIcon = container.querySelector('.h-4.w-4') - fireEvent.click(configIcon!) - - // Assert - expect(mockOnConfig).toHaveBeenCalled() - }) - }) - - // ========================================== - // Component Memoization - // ========================================== - describe('Component Memoization', () => { - it('should be wrapped with React.memo', () => { - // Assert - expect(Header.$$typeof).toBe(Symbol.for('react.memo')) - }) - - it('should not re-render when props remain the same', () => { - // Arrange - const props = createDefaultProps() - const renderSpy = vi.fn() - - const TrackedHeader: React.FC = (trackedProps) => { - renderSpy() - return
- } - const MemoizedTracked = React.memo(TrackedHeader) - - // Act - const { rerender } = render() - rerender() - - // Assert - Should only render once due to same props - expect(renderSpy).toHaveBeenCalledTimes(1) - }) - - it('should re-render when docTitle changes', () => { - // Arrange - const props = createDefaultProps({ docTitle: 'Original Title' }) - const { rerender } = render(
) - - // Assert initial - expect(screen.getByText('Original Title')).toBeInTheDocument() - - // Act - rerender(
) - - // Assert - expect(screen.getByText('Updated Title')).toBeInTheDocument() - }) - - it('should re-render when currentCredentialId changes', () => { - // Arrange - const props = createDefaultProps({ currentCredentialId: 'cred-1' }) - const { rerender } = render(
) - - // Assert initial - expect(screen.getByText('Credential 1')).toBeInTheDocument() - - // Act - rerender(
) - - // Assert - expect(screen.getByText('Credential 2')).toBeInTheDocument() - }) - }) - - // ========================================== - // Edge Cases - // ========================================== - describe('Edge Cases', () => { - it('should handle empty docTitle', () => { - // Arrange - const props = createDefaultProps({ docTitle: '' }) - - // Act - render(
) - - // Assert - Should render without crashing - const link = screen.getByRole('link') - expect(link).toBeInTheDocument() - }) - - it('should handle very long docTitle', () => { - // Arrange - const longTitle = 'A'.repeat(200) - const props = createDefaultProps({ docTitle: longTitle }) - - // Act - render(
) - - // Assert - expect(screen.getByText(longTitle)).toBeInTheDocument() - }) - - it('should handle special characters in docTitle', () => { - // Arrange - const specialTitle = 'Docs & Guide "Special"' - const props = createDefaultProps({ docTitle: specialTitle }) - - // Act - render(
) - - // Assert - expect(screen.getByText(specialTitle)).toBeInTheDocument() - }) - - it('should handle empty credentials array', () => { - // Arrange - const props = createDefaultProps({ - credentials: [], - currentCredentialId: '', - }) - - // Act - render(
) - - // Assert - Should render without crashing - expect(screen.getByRole('link')).toBeInTheDocument() - }) - - it('should handle special characters in pluginName', () => { - // Arrange - const props = createDefaultProps({ pluginName: 'Plugin & Tool ' }) - - // Act - render(
) - - // Assert - Should render without crashing - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should handle unicode characters in docTitle', () => { - // Arrange - const props = createDefaultProps({ docTitle: '文档说明 📚' }) - - // Act - render(
) - - // Assert - expect(screen.getByText('文档说明 📚')).toBeInTheDocument() - }) - }) - - // ========================================== - // Styling - // ========================================== - describe('Styling', () => { - it('should apply correct classes to container', () => { - // Arrange - const props = createDefaultProps() - - // Act - const { container } = render(
) - - // Assert - const rootDiv = container.firstChild as HTMLElement - expect(rootDiv).toHaveClass('flex', 'items-center', 'justify-between', 'gap-x-2') - }) - - it('should apply correct classes to documentation link', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - const link = screen.getByRole('link') - expect(link).toHaveClass('system-xs-medium', 'text-text-accent') - }) - - it('should apply shrink-0 to documentation link', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - const link = screen.getByRole('link') - expect(link).toHaveClass('shrink-0') - }) - }) - - // ========================================== - // Integration Tests - // ========================================== - describe('Integration', () => { - it('should work with full credential workflow', () => { - // Arrange - const mockOnCredentialChange = vi.fn() - const props = createDefaultProps({ - onCredentialChange: mockOnCredentialChange, - currentCredentialId: 'cred-1', - }) - render(
) - - // Assert initial state - expect(screen.getByText('Credential 1')).toBeInTheDocument() - - // Act - Open dropdown and select different credential - // Use first trigger which is CredentialSelector's - const triggers = screen.getAllByTestId('portal-trigger') - fireEvent.click(triggers[0]) - - const credential3 = screen.getByText('Credential 3') - fireEvent.click(credential3) - - // Assert - expect(mockOnCredentialChange).toHaveBeenCalledWith('cred-3') - }) - - it('should display all components together correctly', () => { - // Arrange - const mockOnConfig = vi.fn() - const props = createDefaultProps({ - docTitle: 'Integration Test Docs', - docLink: 'https://test.com/docs', - pluginName: 'TestPlugin', - onClickConfiguration: mockOnConfig, - }) - - // Act - render(
) - - // Assert - All main elements present - expect(screen.getByText('Credential 1')).toBeInTheDocument() // CredentialSelector - expect(screen.getByRole('button')).toBeInTheDocument() // Config button - expect(screen.getByText('Integration Test Docs')).toBeInTheDocument() // Doc link - expect(screen.getByRole('link')).toHaveAttribute('href', 'https://test.com/docs') - }) - }) - - // ========================================== - // Accessibility - // ========================================== - describe('Accessibility', () => { - it('should have accessible link', () => { - // Arrange - const props = createDefaultProps({ docTitle: 'Accessible Docs' }) - - // Act - render(
) - - // Assert - const link = screen.getByRole('link', { name: /Accessible Docs/i }) - expect(link).toBeInTheDocument() - }) - - it('should have accessible button for configuration', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - const button = screen.getByRole('button') - expect(button).toBeInTheDocument() - }) - - it('should have noopener noreferrer for security on external links', () => { - // Arrange - const props = createDefaultProps() - - // Act - render(
) - - // Assert - const link = screen.getByRole('link') - expect(link).toHaveAttribute('rel', 'noopener noreferrer') - }) - }) -}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx index 66f13be84f..87010638b2 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx @@ -1,21 +1,15 @@ import type { FileItem } from '@/models/datasets' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import LocalFile from './index' +import LocalFile from '../index' // Mock the hook const mockUseLocalFileUpload = vi.fn() -vi.mock('./hooks/use-local-file-upload', () => ({ +vi.mock('../hooks/use-local-file-upload', () => ({ useLocalFileUpload: (...args: unknown[]) => mockUseLocalFileUpload(...args), })) // Mock react-i18next for sub-components -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - // Mock theme hook for sub-components vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: 'light' }), diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx index 7754ba6970..df7fe3540b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/file-list-item.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx @@ -1,9 +1,9 @@ -import type { FileListItemProps } from './file-list-item' +import type { FileListItemProps } from '../file-list-item' import type { CustomFile as File, FileItem } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants' -import FileListItem from './file-list-item' +import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants' +import FileListItem from '../file-list-item' // Mock theme hook - can be changed per test let mockTheme = 'light' diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx similarity index 83% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx index 21742b731c..74b4a3b194 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx @@ -1,33 +1,12 @@ import type { RefObject } from 'react' -import type { UploadDropzoneProps } from './upload-dropzone' +import type { UploadDropzoneProps } from '../upload-dropzone' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import UploadDropzone from './upload-dropzone' +import UploadDropzone from '../upload-dropzone' // Helper to create mock ref objects for testing const createMockRef = (value: T | null = null): RefObject => ({ current: value }) -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { ns?: string }) => { - const translations: Record = { - 'stepOne.uploader.button': 'Drag and drop files, or', - 'stepOne.uploader.buttonSingleFile': 'Drag and drop file, or', - 'stepOne.uploader.browse': 'Browse', - 'stepOne.uploader.tip': 'Supports {{supportTypes}}, Max {{size}}MB each, up to {{batchCount}} files at a time, {{totalCount}} files total', - } - let result = translations[key] || key - if (options && typeof options === 'object') { - Object.entries(options).forEach(([k, v]) => { - result = result.replace(`{{${k}}}`, String(v)) - }) - } - return result - }, - }), -})) - describe('UploadDropzone', () => { const defaultProps: UploadDropzoneProps = { dropRef: createMockRef() as RefObject, @@ -78,20 +57,19 @@ describe('UploadDropzone', () => { it('should render browse label when extensions are allowed', () => { render() - expect(screen.getByText('Browse')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.uploader.browse')).toBeInTheDocument() }) it('should not render browse label when no extensions allowed', () => { render() - expect(screen.queryByText('Browse')).not.toBeInTheDocument() + expect(screen.queryByText('datasetCreation.stepOne.uploader.browse')).not.toBeInTheDocument() }) it('should render file size and count limits', () => { render() - const tipText = screen.getByText(/Supports.*Max.*15MB/i) - expect(tipText).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.tip/)).toBeInTheDocument() }) }) @@ -122,13 +100,13 @@ describe('UploadDropzone', () => { it('should show batch upload text when supportBatchUpload is true', () => { render() - expect(screen.getByText(/Drag and drop files/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.button/)).toBeInTheDocument() }) it('should show single file text when supportBatchUpload is false', () => { render() - expect(screen.getByText(/Drag and drop file/i)).toBeInTheDocument() + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.buttonSingleFile/)).toBeInTheDocument() }) }) @@ -161,7 +139,7 @@ describe('UploadDropzone', () => { const onSelectFile = vi.fn() render() - const browseLabel = screen.getByText('Browse') + const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse') fireEvent.click(browseLabel) expect(onSelectFile).toHaveBeenCalledTimes(1) @@ -215,7 +193,7 @@ describe('UploadDropzone', () => { it('should have cursor-pointer on browse label', () => { render() - const browseLabel = screen.getByText('Browse') + const browseLabel = screen.getByText('datasetCreation.stepOne.uploader.browse') expect(browseLabel).toHaveClass('cursor-pointer') }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx index 6248b70506..bc9ce04beb 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/use-local-file-upload.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/hooks/__tests__/use-local-file-upload.spec.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import type { CustomFile, FileItem } from '@/models/datasets' import { act, render, renderHook, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../constants' +import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants' // Mock notify function - defined before mocks const mockNotify = vi.fn() @@ -32,12 +32,6 @@ vi.mock('@/utils/format', () => ({ })) // Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - // Mock locale context vi.mock('@/context/i18n', () => ({ useLocale: () => 'en-US', @@ -48,7 +42,6 @@ vi.mock('@/i18n-config/language', () => ({ LanguagesSupported: ['en-US', 'zh-Hans'], })) -// Mock config vi.mock('@/config', () => ({ IS_CE_EDITION: false, })) @@ -62,7 +55,7 @@ const mockGetState = vi.fn(() => ({ })) const mockStore = { getState: mockGetState } -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ useDataSourceStoreWithSelector: vi.fn((selector: (state: { localFileList: FileItem[] }) => FileItem[]) => selector({ localFileList: [] }), ), @@ -93,7 +86,7 @@ vi.mock('@/service/base', () => ({ })) // Import after all mocks are set up -const { useLocalFileUpload } = await import('./use-local-file-upload') +const { useLocalFileUpload } = await import('../use-local-file-upload') const { ToastContext } = await import('@/app/components/base/toast') const createWrapper = () => { @@ -728,7 +721,7 @@ describe('useLocalFileUpload', () => { describe('file upload limit', () => { it('should reject files exceeding total file upload limit', async () => { // Mock store to return existing files - const { useDataSourceStoreWithSelector } = vi.mocked(await import('../../store')) + const { useDataSourceStoreWithSelector } = vi.mocked(await import('../../../store')) const existingFiles: FileItem[] = Array.from({ length: 8 }, (_, i) => ({ fileID: `existing-${i}`, file: { name: `existing-${i}.pdf`, size: 1024 } as CustomFile, diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx similarity index 87% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx index 21e79ef92e..894ee60060 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx @@ -3,13 +3,7 @@ import type { DataSourceNotionWorkspace, NotionPage } from '@/models/common' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { VarKindType } from '@/app/components/workflow/nodes/_base/types' -import OnlineDocuments from './index' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import OnlineDocuments from '../index' // Mock useDocLink - context hook requires mocking const mockDocLink = vi.fn((path?: string) => `https://docs.example.com${path || ''}`) @@ -20,13 +14,13 @@ vi.mock('@/context/i18n', () => ({ // Mock dataset-detail context - context provider requires mocking let mockPipelineId = 'pipeline-123' vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (s: any) => any) => selector({ dataset: { pipeline_id: mockPipelineId } }), + useDatasetDetailContextWithSelector: (selector: (s: Record) => unknown) => selector({ dataset: { pipeline_id: mockPipelineId } }), })) // Mock modal context - context provider requires mocking const mockSetShowAccountSettingModal = vi.fn() vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: (selector: (s: any) => any) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), + useModalContextSelector: (selector: (s: Record) => unknown) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), })) // Mock ssePost - API service requires mocking @@ -60,7 +54,6 @@ vi.mock('@/service/use-datasource', () => ({ // Note: zustand/react/shallow useShallow is imported directly (simple utility function) -// Mock store const mockStoreState = { documentsData: [] as DataSourceNotionWorkspace[], searchValue: '', @@ -76,22 +69,22 @@ const mockStoreState = { const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } -vi.mock('../store', () => ({ - useDataSourceStoreWithSelector: (selector: (s: any) => any) => selector(mockStoreState), +vi.mock('../../store', () => ({ + useDataSourceStoreWithSelector: (selector: (s: Record) => unknown) => selector(mockStoreState as unknown as Record), useDataSourceStore: () => mockDataSourceStore, })) // Mock Header component -vi.mock('../base/header', () => ({ - default: (props: any) => ( +vi.mock('../../base/header', () => ({ + default: (props: Record) => (
- {props.docTitle} - {props.docLink} - {props.pluginName} - {props.currentCredentialId} - - - {props.credentials?.length || 0} + {props.docTitle as string} + {props.docLink as string} + {props.pluginName as string} + {props.currentCredentialId as string} + + + {(props.credentials as unknown[] | undefined)?.length || 0}
), })) @@ -111,23 +104,23 @@ vi.mock('@/app/components/base/notion-page-selector/search-input', () => ({ })) // Mock PageSelector component -vi.mock('./page-selector', () => ({ - default: (props: any) => ( +vi.mock('../page-selector', () => ({ + default: (props: Record) => (
- {props.checkedIds?.size || 0} - {props.searchValue} + {(props.checkedIds as Set | undefined)?.size || 0} + {props.searchValue as string} {String(props.canPreview)} {String(props.isMultipleChoice)} - {props.currentCredentialId} + {props.currentCredentialId as string} @@ -136,7 +129,7 @@ vi.mock('./page-selector', () => ({ })) // Mock Title component -vi.mock('./title', () => ({ +vi.mock('../title', () => ({ default: ({ name }: { name: string }) => (
{name} @@ -144,9 +137,6 @@ vi.mock('./title', () => ({ ), })) -// ========================================== -// Test Data Builders -// ========================================== const createMockNodeData = (overrides?: Partial): DataSourceNodeType => ({ title: 'Test Node', plugin_id: 'plugin-123', @@ -199,9 +189,6 @@ const createDefaultProps = (overrides?: Partial): OnlineDo ...overrides, }) -// ========================================== -// Test Suites -// ========================================== describe('OnlineDocuments', () => { beforeEach(() => { vi.clearAllMocks() @@ -229,105 +216,79 @@ describe('OnlineDocuments', () => { mockGetState.mockReturnValue(mockStoreState) }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('header')).toBeInTheDocument() }) it('should render Header with correct props', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-123' const props = createDefaultProps({ nodeData: createMockNodeData({ datasource_label: 'My Notion' }), }) - // Act render() - // Assert expect(screen.getByTestId('header-doc-title')).toHaveTextContent('Docs') expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('My Notion') expect(screen.getByTestId('header-credential-id')).toHaveTextContent('cred-123') }) it('should render Loading when documentsData is empty', () => { - // Arrange mockStoreState.documentsData = [] const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByRole('status')).toBeInTheDocument() }) it('should render PageSelector when documentsData has content', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('page-selector')).toBeInTheDocument() expect(screen.queryByRole('status')).not.toBeInTheDocument() }) it('should render Title with datasource_label', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps({ nodeData: createMockNodeData({ datasource_label: 'Notion Integration' }), }) - // Act render() - // Assert expect(screen.getByTestId('title-name')).toHaveTextContent('Notion Integration') }) it('should render SearchInput with current searchValue', () => { - // Arrange mockStoreState.searchValue = 'test search' mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps() - // Act render() - // Assert const searchInput = screen.getByTestId('search-input-field') as HTMLInputElement expect(searchInput.value).toBe('test search') }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('nodeId prop', () => { it('should use nodeId in datasourceNodeRunURL for non-pipeline mode', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ nodeId: 'custom-node-id', isInPipeline: false, }) - // Act render() // Assert - Effect triggers ssePost with correct URL @@ -341,7 +302,6 @@ describe('OnlineDocuments', () => { describe('nodeData prop', () => { it('should pass datasource_parameters to ssePost', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const nodeData = createMockNodeData({ datasource_parameters: { @@ -351,10 +311,8 @@ describe('OnlineDocuments', () => { }) const props = createDefaultProps({ nodeData }) - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ @@ -367,17 +325,14 @@ describe('OnlineDocuments', () => { }) it('should pass plugin_id and provider_name to useGetDataSourceAuth', () => { - // Arrange const nodeData = createMockNodeData({ plugin_id: 'my-plugin-id', provider_name: 'my-provider', }) const props = createDefaultProps({ nodeData }) - // Act render() - // Assert expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ pluginId: 'my-plugin-id', provider: 'my-provider', @@ -387,14 +342,11 @@ describe('OnlineDocuments', () => { describe('isInPipeline prop', () => { it('should use draft URL when isInPipeline is true', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ isInPipeline: true }) - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( expect.stringContaining('/workflows/draft/'), expect.any(Object), @@ -403,14 +355,11 @@ describe('OnlineDocuments', () => { }) it('should use published URL when isInPipeline is false', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ isInPipeline: false }) - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( expect.stringContaining('/workflows/published/'), expect.any(Object), @@ -419,52 +368,40 @@ describe('OnlineDocuments', () => { }) it('should pass canPreview as false to PageSelector when isInPipeline is true', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps({ isInPipeline: true }) - // Act render() - // Assert expect(screen.getByTestId('page-selector-can-preview')).toHaveTextContent('false') }) it('should pass canPreview as true to PageSelector when isInPipeline is false', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps({ isInPipeline: false }) - // Act render() - // Assert expect(screen.getByTestId('page-selector-can-preview')).toHaveTextContent('true') }) }) describe('supportBatchUpload prop', () => { it('should pass isMultipleChoice as true to PageSelector when supportBatchUpload is true', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps({ supportBatchUpload: true }) - // Act render() - // Assert expect(screen.getByTestId('page-selector-multiple-choice')).toHaveTextContent('true') }) it('should pass isMultipleChoice as false to PageSelector when supportBatchUpload is false', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps({ supportBatchUpload: false }) - // Act render() - // Assert expect(screen.getByTestId('page-selector-multiple-choice')).toHaveTextContent('false') }) @@ -473,71 +410,54 @@ describe('OnlineDocuments', () => { [false, 'false'], [undefined, 'true'], // Default value ])('should handle supportBatchUpload=%s correctly', (value, expected) => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps({ supportBatchUpload: value }) - // Act render() - // Assert expect(screen.getByTestId('page-selector-multiple-choice')).toHaveTextContent(expected) }) }) describe('onCredentialChange prop', () => { it('should pass onCredentialChange to Header', () => { - // Arrange const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) - // Act render() fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) }) }) - // ========================================== // Side Effects and Cleanup - // ========================================== describe('Side Effects and Cleanup', () => { it('should call getOnlineDocuments when currentCredentialId changes', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps() - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledTimes(1) }) it('should not call getOnlineDocuments when currentCredentialId is empty', () => { - // Arrange mockStoreState.currentCredentialId = '' const props = createDefaultProps() - // Act render() - // Assert expect(mockSsePost).not.toHaveBeenCalled() }) it('should pass correct body parameters to ssePost', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-123' const props = createDefaultProps() - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( expect.any(String), { @@ -552,7 +472,6 @@ describe('OnlineDocuments', () => { }) it('should handle onDataSourceNodeCompleted callback correctly', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockWorkspaces = [createMockWorkspace()] @@ -567,17 +486,14 @@ describe('OnlineDocuments', () => { const props = createDefaultProps() - // Act render() - // Assert await waitFor(() => { expect(mockStoreState.setDocumentsData).toHaveBeenCalledWith(mockWorkspaces) }) }) it('should handle onDataSourceNodeError callback correctly', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -590,10 +506,8 @@ describe('OnlineDocuments', () => { const props = createDefaultProps() - // Act render() - // Assert await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -603,7 +517,6 @@ describe('OnlineDocuments', () => { }) it('should construct correct URL for draft workflow', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockPipelineId = 'pipeline-456' const props = createDefaultProps({ @@ -611,10 +524,8 @@ describe('OnlineDocuments', () => { isInPipeline: true, }) - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( '/rag/pipelines/pipeline-456/workflows/draft/datasource/nodes/node-789/run', expect.any(Object), @@ -623,7 +534,6 @@ describe('OnlineDocuments', () => { }) it('should construct correct URL for published workflow', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockPipelineId = 'pipeline-456' const props = createDefaultProps({ @@ -631,10 +541,8 @@ describe('OnlineDocuments', () => { isInPipeline: false, }) - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( '/rag/pipelines/pipeline-456/workflows/published/datasource/nodes/node-789/run', expect.any(Object), @@ -643,40 +551,31 @@ describe('OnlineDocuments', () => { }) }) - // ========================================== // Callback Stability and Memoization - // ========================================== describe('Callback Stability and Memoization', () => { it('should have stable handleSearchValueChange that updates store', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps() render() - // Act const searchInput = screen.getByTestId('search-input-field') fireEvent.change(searchInput, { target: { value: 'new search value' } }) - // Assert expect(mockStoreState.setSearchValue).toHaveBeenCalledWith('new search value') }) it('should have stable handleSelectPages that updates store', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps() render() - // Act fireEvent.click(screen.getByTestId('page-selector-select-btn')) - // Assert expect(mockStoreState.setSelectedPagesId).toHaveBeenCalled() expect(mockStoreState.setOnlineDocuments).toHaveBeenCalled() }) it('should have stable handlePreviewPage that updates store', () => { - // Arrange const mockPages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), ] @@ -684,34 +583,26 @@ describe('OnlineDocuments', () => { const props = createDefaultProps() render() - // Act fireEvent.click(screen.getByTestId('page-selector-preview-btn')) - // Assert expect(mockStoreState.setCurrentDocument).toHaveBeenCalled() }) it('should have stable handleSetting callback', () => { - // Arrange const props = createDefaultProps() render() - // Act fireEvent.click(screen.getByTestId('header-config-btn')) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source', }) }) }) - // ========================================== // Memoization Logic and Dependencies - // ========================================== describe('Memoization Logic and Dependencies', () => { it('should compute PagesMapAndSelectedPagesId correctly from documentsData', () => { - // Arrange const mockPages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), @@ -721,7 +612,6 @@ describe('OnlineDocuments', () => { ] const props = createDefaultProps() - // Act render() // Assert - PageSelector receives the pagesMap (verified via mock) @@ -729,7 +619,6 @@ describe('OnlineDocuments', () => { }) it('should recompute PagesMapAndSelectedPagesId when documentsData changes', () => { - // Arrange const initialPages = [createMockPage({ page_id: 'page-1' })] mockStoreState.documentsData = [createMockWorkspace({ pages: initialPages })] const props = createDefaultProps() @@ -743,16 +632,13 @@ describe('OnlineDocuments', () => { mockStoreState.documentsData = [createMockWorkspace({ pages: newPages })] rerender() - // Assert expect(screen.getByTestId('page-selector')).toBeInTheDocument() }) it('should handle empty documentsData in PagesMapAndSelectedPagesId computation', () => { - // Arrange mockStoreState.documentsData = [] const props = createDefaultProps() - // Act render() // Assert - Should show loading instead of PageSelector @@ -760,26 +646,20 @@ describe('OnlineDocuments', () => { }) }) - // ========================================== // User Interactions and Event Handlers - // ========================================== describe('User Interactions and Event Handlers', () => { it('should handle search input changes', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps() render() - // Act const searchInput = screen.getByTestId('search-input-field') fireEvent.change(searchInput, { target: { value: 'search query' } }) - // Assert expect(mockStoreState.setSearchValue).toHaveBeenCalledWith('search query') }) it('should handle page selection', () => { - // Arrange const mockPages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), @@ -788,62 +668,48 @@ describe('OnlineDocuments', () => { const props = createDefaultProps() render() - // Act fireEvent.click(screen.getByTestId('page-selector-select-btn')) - // Assert expect(mockStoreState.setSelectedPagesId).toHaveBeenCalled() expect(mockStoreState.setOnlineDocuments).toHaveBeenCalled() }) it('should handle page preview', () => { - // Arrange const mockPages = [createMockPage({ page_id: 'page-1', page_name: 'Page 1' })] mockStoreState.documentsData = [createMockWorkspace({ pages: mockPages })] const props = createDefaultProps() render() - // Act fireEvent.click(screen.getByTestId('page-selector-preview-btn')) - // Assert expect(mockStoreState.setCurrentDocument).toHaveBeenCalled() }) it('should handle configuration button click', () => { - // Arrange const props = createDefaultProps() render() - // Act fireEvent.click(screen.getByTestId('header-config-btn')) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source', }) }) it('should handle credential change', () => { - // Arrange const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) render() - // Act fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) }) - // ========================================== // API Calls Mocking - // ========================================== describe('API Calls', () => { it('should call ssePost with correct parameters', () => { - // Arrange mockStoreState.currentCredentialId = 'test-cred' const props = createDefaultProps({ nodeData: createMockNodeData({ @@ -854,10 +720,8 @@ describe('OnlineDocuments', () => { }), }) - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( expect.any(String), { @@ -875,7 +739,6 @@ describe('OnlineDocuments', () => { }) it('should handle successful API response', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockData = [createMockWorkspace()] @@ -889,17 +752,14 @@ describe('OnlineDocuments', () => { const props = createDefaultProps() - // Act render() - // Assert await waitFor(() => { expect(mockStoreState.setDocumentsData).toHaveBeenCalledWith(mockData) }) }) it('should handle API error response', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -911,10 +771,8 @@ describe('OnlineDocuments', () => { const props = createDefaultProps() - // Act render() - // Assert await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -924,17 +782,14 @@ describe('OnlineDocuments', () => { }) it('should use useGetDataSourceAuth with correct parameters', () => { - // Arrange const nodeData = createMockNodeData({ plugin_id: 'notion-plugin', provider_name: 'notion-provider', }) const props = createDefaultProps({ nodeData }) - // Act render() - // Assert expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ pluginId: 'notion-plugin', provider: 'notion-provider', @@ -942,7 +797,6 @@ describe('OnlineDocuments', () => { }) it('should pass credentials from useGetDataSourceAuth to Header', () => { - // Arrange const mockCredentials = [ createMockCredential({ id: 'cred-1', name: 'Credential 1' }), createMockCredential({ id: 'cred-2', name: 'Credential 2' }), @@ -952,69 +806,52 @@ describe('OnlineDocuments', () => { }) const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('2') }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle empty credentials array', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: { result: [] }, }) const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle undefined dataSourceAuth result', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: { result: undefined }, }) const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle null dataSourceAuth data', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: null, }) const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle documentsData with empty pages array', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace({ pages: [] })] const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('page-selector')).toBeInTheDocument() }) @@ -1023,7 +860,6 @@ describe('OnlineDocuments', () => { mockStoreState.documentsData = undefined as unknown as DataSourceNotionWorkspace[] const props = createDefaultProps() - // Act render() // Assert - Should show loading when documentsData is undefined @@ -1038,7 +874,6 @@ describe('OnlineDocuments', () => { nodeData.datasource_parameters = undefined const props = createDefaultProps({ nodeData }) - // Act render() // Assert - ssePost should be called with empty inputs @@ -1061,12 +896,11 @@ describe('OnlineDocuments', () => { const nodeData = createMockNodeData({ datasource_parameters: { // Object without 'value' key - should use the object itself - objWithoutValue: { type: VarKindType.constant, other: 'data' } as any, + objWithoutValue: { type: VarKindType.constant, other: 'data' } as Record & { type: VarKindType }, }, }) const props = createDefaultProps({ nodeData }) - // Act render() // Assert - The object without 'value' property should be passed as-is @@ -1084,62 +918,49 @@ describe('OnlineDocuments', () => { }) it('should handle multiple workspaces in documentsData', () => { - // Arrange mockStoreState.documentsData = [ createMockWorkspace({ workspace_id: 'ws-1', pages: [createMockPage({ page_id: 'page-1' })] }), createMockWorkspace({ workspace_id: 'ws-2', pages: [createMockPage({ page_id: 'page-2' })] }), ] const props = createDefaultProps() - // Act render() - // Assert expect(screen.getByTestId('page-selector')).toBeInTheDocument() }) it('should handle special characters in searchValue', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps() render() - // Act const searchInput = screen.getByTestId('search-input-field') fireEvent.change(searchInput, { target: { value: 'test' } }) - // Assert expect(mockStoreState.setSearchValue).toHaveBeenCalledWith('test') }) it('should handle unicode characters in searchValue', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps() render() - // Act const searchInput = screen.getByTestId('search-input-field') fireEvent.change(searchInput, { target: { value: '测试搜索 🔍' } }) - // Assert expect(mockStoreState.setSearchValue).toHaveBeenCalledWith('测试搜索 🔍') }) it('should handle empty string currentCredentialId', () => { - // Arrange mockStoreState.currentCredentialId = '' const props = createDefaultProps() - // Act render() - // Assert expect(mockSsePost).not.toHaveBeenCalled() }) it('should handle complex datasource_parameters with nested objects', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const nodeData = createMockNodeData({ datasource_parameters: { @@ -1149,10 +970,8 @@ describe('OnlineDocuments', () => { }) const props = createDefaultProps({ nodeData }) - // Act render() - // Assert expect(mockSsePost).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ @@ -1168,12 +987,10 @@ describe('OnlineDocuments', () => { }) it('should handle undefined pipelineId gracefully', () => { - // Arrange - mockPipelineId = undefined as any + mockPipelineId = undefined as unknown as string mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps() - // Act render() // Assert - Should still call ssePost with undefined in URL @@ -1181,9 +998,7 @@ describe('OnlineDocuments', () => { }) }) - // ========================================== // All Prop Variations - // ========================================== describe('Prop Variations', () => { it.each([ [{ isInPipeline: true, supportBatchUpload: true }], @@ -1191,14 +1006,11 @@ describe('OnlineDocuments', () => { [{ isInPipeline: false, supportBatchUpload: true }], [{ isInPipeline: false, supportBatchUpload: false }], ])('should render correctly with props %o', (propVariation) => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props = createDefaultProps(propVariation) - // Act render() - // Assert expect(screen.getByTestId('page-selector')).toBeInTheDocument() expect(screen.getByTestId('page-selector-can-preview')).toHaveTextContent( String(!propVariation.isInPipeline), @@ -1209,7 +1021,6 @@ describe('OnlineDocuments', () => { }) it('should use default values for optional props', () => { - // Arrange mockStoreState.documentsData = [createMockWorkspace()] const props: OnlineDocumentsProps = { nodeId: 'node-1', @@ -1218,7 +1029,6 @@ describe('OnlineDocuments', () => { // isInPipeline and supportBatchUpload are not provided } - // Act render() // Assert - Default values: isInPipeline = false, supportBatchUpload = true @@ -1227,12 +1037,8 @@ describe('OnlineDocuments', () => { }) }) - // ========================================== - // Integration Tests - // ========================================== describe('Integration', () => { it('should complete full workflow: load data -> search -> select -> preview', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockPages = [ createMockPage({ page_id: 'page-1', page_name: 'Test Page 1' }), @@ -1274,7 +1080,6 @@ describe('OnlineDocuments', () => { }) it('should handle error flow correctly', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -1286,10 +1091,8 @@ describe('OnlineDocuments', () => { const props = createDefaultProps() - // Act render() - // Assert await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -1302,12 +1105,10 @@ describe('OnlineDocuments', () => { }) it('should handle credential change and refetch documents', () => { - // Arrange mockStoreState.currentCredentialId = 'initial-cred' const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) - // Act render() // Initial fetch @@ -1318,6 +1119,4 @@ describe('OnlineDocuments', () => { expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) }) - - // ========================================== }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/title.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/title.spec.tsx new file mode 100644 index 0000000000..3f0d7efb24 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/title.spec.tsx @@ -0,0 +1,10 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import Title from '../title' + +describe('OnlineDocumentTitle', () => { + it('should render title with name prop', () => { + render() + expect(screen.getByText('datasetPipeline.onlineDocument.pageSelectorTitle:{"name":"Notion Workspace"}')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/index.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/index.spec.tsx index 60da0e7c9f..bdfa809aed 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/index.spec.tsx @@ -1,38 +1,30 @@ -import type { NotionPageTreeItem, NotionPageTreeMap } from './index' +import type { NotionPageTreeItem, NotionPageTreeMap } from '../index' import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import PageSelector from './index' -import { recursivePushInParentDescendants } from './utils' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import PageSelector from '../index' +import { recursivePushInParentDescendants } from '../utils' // Mock react-window FixedSizeList - renders items directly for testing vi.mock('react-window', () => ({ - FixedSizeList: ({ children: ItemComponent, itemCount, itemData, itemKey }: any) => ( + FixedSizeList: ({ children: ItemComponent, itemCount, itemData, itemKey }: { children: React.ComponentType<{ index: number, style: React.CSSProperties, data: unknown }>, itemCount: number, itemData: unknown, itemKey?: (index: number, data: unknown) => string | number }) => ( <div data-testid="virtual-list"> {Array.from({ length: itemCount }).map((_, index) => ( <ItemComponent key={itemKey?.(index, itemData) || index} index={index} - style={{ top: index * 28, left: 0, right: 0, width: '100%', position: 'absolute' }} + style={{ top: index * 28, left: 0, right: 0, width: '100%', position: 'absolute' as const }} data={itemData} /> ))} </div> ), - areEqual: (prevProps: any, nextProps: any) => prevProps === nextProps, + areEqual: (prevProps: Record<string, unknown>, nextProps: Record<string, unknown>) => prevProps === nextProps, })) // Note: NotionIcon from @/app/components/base/ is NOT mocked - using real component per testing guidelines -// ========================================== // Helper Functions for Base Components -// ========================================== // Get checkbox element (uses data-testid pattern from base Checkbox component) const getCheckbox = () => document.querySelector('[data-testid^="checkbox-"]') as HTMLElement const getAllCheckboxes = () => document.querySelectorAll('[data-testid^="checkbox-"]') @@ -47,9 +39,6 @@ const isCheckboxChecked = (checkbox: Element) => checkbox.querySelector('[data-t // Check if checkbox is disabled by looking for disabled class const isCheckboxDisabled = (checkbox: Element) => checkbox.classList.contains('cursor-not-allowed') -// ========================================== -// Test Data Builders -// ========================================== const createMockPage = (overrides?: Partial<DataSourceNotionPage>): DataSourceNotionPage => ({ page_id: 'page-1', page_name: 'Test Page', @@ -99,46 +88,33 @@ const createHierarchicalPages = () => { return { list, pagesMap, rootPage, childPage1, childPage2, grandChild } } -// ========================================== -// Test Suites -// ========================================== describe('PageSelector', () => { beforeEach(() => { vi.clearAllMocks() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByTestId('virtual-list')).toBeInTheDocument() }) it('should render empty state when list is empty', () => { - // Arrange const props = createDefaultProps({ list: [], pagesMap: {}, }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() expect(screen.queryByTestId('virtual-list')).not.toBeInTheDocument() }) it('should render items using FixedSizeList', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), @@ -148,63 +124,47 @@ describe('PageSelector', () => { pagesMap: createMockPagesMap(pages), }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('Page 1')).toBeInTheDocument() expect(screen.getByText('Page 2')).toBeInTheDocument() }) it('should render checkboxes when isMultipleChoice is true', () => { - // Arrange const props = createDefaultProps({ isMultipleChoice: true }) - // Act render(<PageSelector {...props} />) - // Assert expect(getCheckbox()).toBeInTheDocument() }) it('should render radio buttons when isMultipleChoice is false', () => { - // Arrange const props = createDefaultProps({ isMultipleChoice: false }) - // Act render(<PageSelector {...props} />) - // Assert expect(getRadio()).toBeInTheDocument() }) it('should render preview button when canPreview is true', () => { - // Arrange const props = createDefaultProps({ canPreview: true }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument() }) it('should not render preview button when canPreview is false', () => { - // Arrange const props = createDefaultProps({ canPreview: false }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.queryByText('common.dataSource.notion.selector.preview')).not.toBeInTheDocument() }) it('should render NotionIcon for each page', () => { - // Arrange const props = createDefaultProps() - // Act render(<PageSelector {...props} />) // Assert - NotionIcon renders svg when page_icon is null @@ -213,27 +173,20 @@ describe('PageSelector', () => { }) it('should render page name', () => { - // Arrange const props = createDefaultProps({ list: [createMockPage({ page_name: 'My Custom Page' })], pagesMap: createMockPagesMap([createMockPage({ page_name: 'My Custom Page' })]), }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('My Custom Page')).toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('checkedIds prop', () => { it('should mark checkbox as checked when page is in checkedIds', () => { - // Arrange const page = createMockPage({ page_id: 'page-1' }) const props = createDefaultProps({ list: [page], @@ -241,17 +194,14 @@ describe('PageSelector', () => { checkedIds: new Set(['page-1']), }) - // Act render(<PageSelector {...props} />) - // Assert const checkbox = getCheckbox() expect(checkbox).toBeInTheDocument() expect(isCheckboxChecked(checkbox)).toBe(true) }) it('should mark checkbox as unchecked when page is not in checkedIds', () => { - // Arrange const page = createMockPage({ page_id: 'page-1' }) const props = createDefaultProps({ list: [page], @@ -259,30 +209,24 @@ describe('PageSelector', () => { checkedIds: new Set(), }) - // Act render(<PageSelector {...props} />) - // Assert const checkbox = getCheckbox() expect(checkbox).toBeInTheDocument() expect(isCheckboxChecked(checkbox)).toBe(false) }) it('should handle empty checkedIds', () => { - // Arrange const props = createDefaultProps({ checkedIds: new Set() }) - // Act render(<PageSelector {...props} />) - // Assert const checkbox = getCheckbox() expect(checkbox).toBeInTheDocument() expect(isCheckboxChecked(checkbox)).toBe(false) }) it('should handle multiple checked items', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), @@ -294,10 +238,8 @@ describe('PageSelector', () => { checkedIds: new Set(['page-1', 'page-3']), }) - // Act render(<PageSelector {...props} />) - // Assert const checkboxes = getAllCheckboxes() expect(isCheckboxChecked(checkboxes[0])).toBe(true) expect(isCheckboxChecked(checkboxes[1])).toBe(false) @@ -307,7 +249,6 @@ describe('PageSelector', () => { describe('disabledValue prop', () => { it('should disable checkbox when page is in disabledValue', () => { - // Arrange const page = createMockPage({ page_id: 'page-1' }) const props = createDefaultProps({ list: [page], @@ -315,17 +256,14 @@ describe('PageSelector', () => { disabledValue: new Set(['page-1']), }) - // Act render(<PageSelector {...props} />) - // Assert const checkbox = getCheckbox() expect(checkbox).toBeInTheDocument() expect(isCheckboxDisabled(checkbox)).toBe(true) }) it('should not disable checkbox when page is not in disabledValue', () => { - // Arrange const page = createMockPage({ page_id: 'page-1' }) const props = createDefaultProps({ list: [page], @@ -333,17 +271,14 @@ describe('PageSelector', () => { disabledValue: new Set(), }) - // Act render(<PageSelector {...props} />) - // Assert const checkbox = getCheckbox() expect(checkbox).toBeInTheDocument() expect(isCheckboxDisabled(checkbox)).toBe(false) }) it('should handle partial disabled items', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), @@ -354,10 +289,8 @@ describe('PageSelector', () => { disabledValue: new Set(['page-1']), }) - // Act render(<PageSelector {...props} />) - // Assert const checkboxes = getAllCheckboxes() expect(isCheckboxDisabled(checkboxes[0])).toBe(true) expect(isCheckboxDisabled(checkboxes[1])).toBe(false) @@ -366,7 +299,6 @@ describe('PageSelector', () => { describe('searchValue prop', () => { it('should filter pages by search value', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Apple Page' }), createMockPage({ page_id: 'page-2', page_name: 'Banana Page' }), @@ -378,7 +310,6 @@ describe('PageSelector', () => { searchValue: 'Apple', }) - // Act render(<PageSelector {...props} />) // Assert - Only pages containing "Apple" should be visible @@ -390,7 +321,6 @@ describe('PageSelector', () => { }) it('should show empty state when no pages match search', () => { - // Arrange const pages = [createMockPage({ page_id: 'page-1', page_name: 'Test Page' })] const props = createDefaultProps({ list: pages, @@ -398,15 +328,12 @@ describe('PageSelector', () => { searchValue: 'NonExistent', }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() }) it('should show all pages when searchValue is empty', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), @@ -417,16 +344,13 @@ describe('PageSelector', () => { searchValue: '', }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('Page 1')).toBeInTheDocument() expect(screen.getByText('Page 2')).toBeInTheDocument() }) it('should show breadcrumbs when searchValue is present', () => { - // Arrange const { list, pagesMap } = createHierarchicalPages() const props = createDefaultProps({ list, @@ -434,7 +358,6 @@ describe('PageSelector', () => { searchValue: 'Grandchild', }) - // Act render(<PageSelector {...props} />) // Assert - page name should be visible @@ -442,7 +365,6 @@ describe('PageSelector', () => { }) it('should perform case-sensitive search', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Apple Page' }), createMockPage({ page_id: 'page-2', page_name: 'apple page' }), @@ -453,7 +375,6 @@ describe('PageSelector', () => { searchValue: 'Apple', }) - // Act render(<PageSelector {...props} />) // Assert - Only 'Apple Page' should match (case-sensitive) @@ -465,95 +386,73 @@ describe('PageSelector', () => { describe('canPreview prop', () => { it('should show preview button when canPreview is true', () => { - // Arrange const props = createDefaultProps({ canPreview: true }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument() }) it('should hide preview button when canPreview is false', () => { - // Arrange const props = createDefaultProps({ canPreview: false }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.queryByText('common.dataSource.notion.selector.preview')).not.toBeInTheDocument() }) it('should use default value true when canPreview is not provided', () => { - // Arrange const props = createDefaultProps() - delete (props as any).canPreview + delete (props as Partial<PageSelectorProps>).canPreview - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument() }) }) describe('isMultipleChoice prop', () => { it('should render checkbox when isMultipleChoice is true', () => { - // Arrange const props = createDefaultProps({ isMultipleChoice: true }) - // Act render(<PageSelector {...props} />) - // Assert expect(getCheckbox()).toBeInTheDocument() expect(getRadio()).not.toBeInTheDocument() }) it('should render radio when isMultipleChoice is false', () => { - // Arrange const props = createDefaultProps({ isMultipleChoice: false }) - // Act render(<PageSelector {...props} />) - // Assert expect(getRadio()).toBeInTheDocument() expect(getCheckbox()).not.toBeInTheDocument() }) it('should use default value true when isMultipleChoice is not provided', () => { - // Arrange const props = createDefaultProps() - delete (props as any).isMultipleChoice + delete (props as Partial<PageSelectorProps>).isMultipleChoice - // Act render(<PageSelector {...props} />) - // Assert expect(getCheckbox()).toBeInTheDocument() }) }) describe('onSelect prop', () => { it('should call onSelect when checkbox is clicked', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect }) - // Act render(<PageSelector {...props} />) fireEvent.click(getCheckbox()) - // Assert expect(mockOnSelect).toHaveBeenCalledTimes(1) expect(mockOnSelect).toHaveBeenCalledWith(expect.any(Set)) }) it('should pass updated set to onSelect', () => { - // Arrange const mockOnSelect = vi.fn() const page = createMockPage({ page_id: 'page-1' }) const props = createDefaultProps({ @@ -563,11 +462,9 @@ describe('PageSelector', () => { onSelect: mockOnSelect, }) - // Act render(<PageSelector {...props} />) fireEvent.click(getCheckbox()) - // Assert const calledSet = mockOnSelect.mock.calls[0][0] as Set<string> expect(calledSet.has('page-1')).toBe(true) }) @@ -575,7 +472,6 @@ describe('PageSelector', () => { describe('onPreview prop', () => { it('should call onPreview when preview button is clicked', () => { - // Arrange const mockOnPreview = vi.fn() const page = createMockPage({ page_id: 'page-1' }) const props = createDefaultProps({ @@ -585,22 +481,18 @@ describe('PageSelector', () => { canPreview: true, }) - // Act render(<PageSelector {...props} />) fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) - // Assert expect(mockOnPreview).toHaveBeenCalledWith('page-1') }) it('should not throw when onPreview is undefined', () => { - // Arrange const props = createDefaultProps({ onPreview: undefined, canPreview: true, }) - // Act & Assert expect(() => { render(<PageSelector {...props} />) fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) @@ -610,7 +502,6 @@ describe('PageSelector', () => { describe('currentCredentialId prop', () => { it('should reset dataList when currentCredentialId changes', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), ] @@ -620,7 +511,6 @@ describe('PageSelector', () => { currentCredentialId: 'cred-1', }) - // Act const { rerender } = render(<PageSelector {...props} />) // Assert - Initial render @@ -635,19 +525,15 @@ describe('PageSelector', () => { }) }) - // ========================================== // State Management and Updates - // ========================================== describe('State Management and Updates', () => { it('should initialize dataList with root level pages', () => { - // Arrange const { list, pagesMap, rootPage, childPage1 } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // Assert - Only root level page should be visible initially @@ -657,14 +543,12 @@ describe('PageSelector', () => { }) it('should update dataList when expanding a page with children', () => { - // Arrange const { list, pagesMap, rootPage, childPage1, childPage2 } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // Find and click the expand arrow (uses hover:bg-components-button-ghost-bg-hover class) @@ -672,14 +556,12 @@ describe('PageSelector', () => { if (arrowButton) fireEvent.click(arrowButton) - // Assert expect(screen.getByText(rootPage.page_name)).toBeInTheDocument() expect(screen.getByText(childPage1.page_name)).toBeInTheDocument() expect(screen.getByText(childPage2.page_name)).toBeInTheDocument() }) it('should maintain currentPreviewPageId state', () => { - // Arrange const mockOnPreview = vi.fn() const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), @@ -692,17 +574,14 @@ describe('PageSelector', () => { canPreview: true, }) - // Act render(<PageSelector {...props} />) const previewButtons = screen.getAllByText('common.dataSource.notion.selector.preview') fireEvent.click(previewButtons[0]) - // Assert expect(mockOnPreview).toHaveBeenCalledWith('page-1') }) it('should use searchDataList when searchValue is present', () => { - // Arrange const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Apple' }), createMockPage({ page_id: 'page-2', page_name: 'Banana' }), @@ -713,7 +592,6 @@ describe('PageSelector', () => { searchValue: 'Apple', }) - // Act render(<PageSelector {...props} />) // Assert - Only pages matching search should be visible @@ -723,12 +601,9 @@ describe('PageSelector', () => { }) }) - // ========================================== // Side Effects and Cleanup - // ========================================== describe('Side Effects and Cleanup', () => { it('should reinitialize dataList when currentCredentialId changes', () => { - // Arrange const pages = [createMockPage({ page_id: 'page-1', page_name: 'Page 1' })] const props = createDefaultProps({ list: pages, @@ -736,7 +611,6 @@ describe('PageSelector', () => { currentCredentialId: 'cred-1', }) - // Act const { rerender } = render(<PageSelector {...props} />) expect(screen.getByText('Page 1')).toBeInTheDocument() @@ -748,14 +622,12 @@ describe('PageSelector', () => { }) it('should filter root pages correctly on initialization', () => { - // Arrange const { list, pagesMap, rootPage, childPage1 } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // Assert - Only root level pages visible @@ -764,7 +636,6 @@ describe('PageSelector', () => { }) it('should include pages whose parent is not in pagesMap', () => { - // Arrange const orphanPage = createMockPage({ page_id: 'orphan-page', page_name: 'Orphan Page', @@ -775,7 +646,6 @@ describe('PageSelector', () => { pagesMap: createMockPagesMap([orphanPage]), }) - // Act render(<PageSelector {...props} />) // Assert - Orphan page should be visible at root level @@ -783,19 +653,15 @@ describe('PageSelector', () => { }) }) - // ========================================== // Callback Stability and Memoization - // ========================================== describe('Callback Stability and Memoization', () => { it('should have stable handleToggle that expands children', () => { - // Arrange const { list, pagesMap, childPage1, childPage2 } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // Find expand arrow for root page (has RiArrowRightSLine icon) @@ -809,14 +675,12 @@ describe('PageSelector', () => { }) it('should have stable handleToggle that collapses descendants', () => { - // Arrange const { list, pagesMap, childPage1, childPage2 } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // First expand @@ -833,7 +697,6 @@ describe('PageSelector', () => { }) it('should have stable handleCheck that adds page and descendants to selection', () => { - // Arrange const mockOnSelect = vi.fn() const { list, pagesMap } = createHierarchicalPages() const props = createDefaultProps({ @@ -844,7 +707,6 @@ describe('PageSelector', () => { isMultipleChoice: true, }) - // Act render(<PageSelector {...props} />) // Check the root page @@ -857,7 +719,6 @@ describe('PageSelector', () => { }) it('should have stable handleCheck that removes page and descendants from selection', () => { - // Arrange const mockOnSelect = vi.fn() const { list, pagesMap } = createHierarchicalPages() const props = createDefaultProps({ @@ -868,7 +729,6 @@ describe('PageSelector', () => { isMultipleChoice: true, }) - // Act render(<PageSelector {...props} />) // Uncheck the root page @@ -879,7 +739,6 @@ describe('PageSelector', () => { }) it('should have stable handlePreview that updates currentPreviewPageId', () => { - // Arrange const mockOnPreview = vi.fn() const page = createMockPage({ page_id: 'preview-page' }) const props = createDefaultProps({ @@ -889,28 +748,22 @@ describe('PageSelector', () => { canPreview: true, }) - // Act render(<PageSelector {...props} />) fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) - // Assert expect(mockOnPreview).toHaveBeenCalledWith('preview-page') }) }) - // ========================================== // Memoization Logic and Dependencies - // ========================================== describe('Memoization Logic and Dependencies', () => { it('should compute listMapWithChildrenAndDescendants correctly', () => { - // Arrange const { list, pagesMap } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // Assert - Tree structure should be built (verified by expand functionality) @@ -919,14 +772,12 @@ describe('PageSelector', () => { }) it('should recompute listMapWithChildrenAndDescendants when list changes', () => { - // Arrange const initialList = [createMockPage({ page_id: 'page-1', page_name: 'Page 1' })] const props = createDefaultProps({ list: initialList, pagesMap: createMockPagesMap(initialList), }) - // Act const { rerender } = render(<PageSelector {...props} />) expect(screen.getByText('Page 1')).toBeInTheDocument() @@ -937,20 +788,17 @@ describe('PageSelector', () => { ] rerender(<PageSelector {...props} list={newList} pagesMap={createMockPagesMap(newList)} />) - // Assert expect(screen.getByText('Page 1')).toBeInTheDocument() // Page 2 won't show because dataList state hasn't updated (only resets on credentialId change) }) it('should recompute listMapWithChildrenAndDescendants when pagesMap changes', () => { - // Arrange const initialList = [createMockPage({ page_id: 'page-1', page_name: 'Page 1' })] const props = createDefaultProps({ list: initialList, pagesMap: createMockPagesMap(initialList), }) - // Act const { rerender } = render(<PageSelector {...props} />) // Update pagesMap @@ -965,39 +813,31 @@ describe('PageSelector', () => { }) it('should handle empty list in memoization', () => { - // Arrange const props = createDefaultProps({ list: [], pagesMap: {}, }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() }) }) - // ========================================== // User Interactions and Event Handlers - // ========================================== describe('User Interactions and Event Handlers', () => { it('should toggle expansion when clicking arrow button', () => { - // Arrange const { list, pagesMap, childPage1 } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // Initially children are hidden expect(screen.queryByText(childPage1.page_name)).not.toBeInTheDocument() - // Click to expand const expandArrow = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') if (expandArrow) fireEvent.click(expandArrow) @@ -1007,23 +847,19 @@ describe('PageSelector', () => { }) it('should check/uncheck page when clicking checkbox', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect, checkedIds: new Set(), }) - // Act render(<PageSelector {...props} />) fireEvent.click(getCheckbox()) - // Assert expect(mockOnSelect).toHaveBeenCalled() }) it('should select radio when clicking in single choice mode', () => { - // Arrange const mockOnSelect = vi.fn() const props = createDefaultProps({ onSelect: mockOnSelect, @@ -1031,16 +867,13 @@ describe('PageSelector', () => { checkedIds: new Set(), }) - // Act render(<PageSelector {...props} />) fireEvent.click(getRadio()) - // Assert expect(mockOnSelect).toHaveBeenCalled() }) it('should clear previous selection in single choice mode', () => { - // Arrange const mockOnSelect = vi.fn() const pages = [ createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), @@ -1054,7 +887,6 @@ describe('PageSelector', () => { checkedIds: new Set(['page-1']), }) - // Act render(<PageSelector {...props} />) const radios = getAllRadios() fireEvent.click(radios[1]) // Click on page-2 @@ -1067,23 +899,19 @@ describe('PageSelector', () => { }) it('should trigger preview when clicking preview button', () => { - // Arrange const mockOnPreview = vi.fn() const props = createDefaultProps({ onPreview: mockOnPreview, canPreview: true, }) - // Act render(<PageSelector {...props} />) fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) - // Assert expect(mockOnPreview).toHaveBeenCalledWith('page-1') }) it('should not cascade selection in search mode', () => { - // Arrange const mockOnSelect = vi.fn() const { list, pagesMap } = createHierarchicalPages() const props = createDefaultProps({ @@ -1095,7 +923,6 @@ describe('PageSelector', () => { isMultipleChoice: true, }) - // Act render(<PageSelector {...props} />) fireEvent.click(getCheckbox()) @@ -1107,33 +934,25 @@ describe('PageSelector', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle empty list', () => { - // Arrange const props = createDefaultProps({ list: [], pagesMap: {}, }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() }) it('should handle null page_icon', () => { - // Arrange const page = createMockPage({ page_icon: null }) const props = createDefaultProps({ list: [page], pagesMap: createMockPagesMap([page]), }) - // Act render(<PageSelector {...props} />) // Assert - NotionIcon renders svg (RiFileTextLine) when page_icon is null @@ -1142,7 +961,6 @@ describe('PageSelector', () => { }) it('should handle page_icon with all properties', () => { - // Arrange const page = createMockPage({ page_icon: { type: 'emoji', url: null, emoji: '📄' }, }) @@ -1151,7 +969,6 @@ describe('PageSelector', () => { pagesMap: createMockPagesMap([page]), }) - // Act render(<PageSelector {...props} />) // Assert - NotionIcon renders the emoji @@ -1159,48 +976,38 @@ describe('PageSelector', () => { }) it('should handle empty searchValue correctly', () => { - // Arrange const props = createDefaultProps({ searchValue: '' }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByTestId('virtual-list')).toBeInTheDocument() }) it('should handle special characters in page name', () => { - // Arrange const page = createMockPage({ page_name: 'Test <script>alert("xss")</script>' }) const props = createDefaultProps({ list: [page], pagesMap: createMockPagesMap([page]), }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('Test <script>alert("xss")</script>')).toBeInTheDocument() }) it('should handle unicode characters in page name', () => { - // Arrange const page = createMockPage({ page_name: '测试页面 🔍 привет' }) const props = createDefaultProps({ list: [page], pagesMap: createMockPagesMap([page]), }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText('测试页面 🔍 привет')).toBeInTheDocument() }) it('should handle very long page names', () => { - // Arrange const longName = 'A'.repeat(500) const page = createMockPage({ page_name: longName }) const props = createDefaultProps({ @@ -1208,10 +1015,8 @@ describe('PageSelector', () => { pagesMap: createMockPagesMap([page]), }) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByText(longName)).toBeInTheDocument() }) @@ -1235,7 +1040,6 @@ describe('PageSelector', () => { pagesMap: createMockPagesMap(pages), }) - // Act render(<PageSelector {...props} />) // Assert - Only root level visible @@ -1257,7 +1061,6 @@ describe('PageSelector', () => { pagesMap, }) - // Act render(<PageSelector {...props} />) // Assert - Should render the orphan page at root level @@ -1265,39 +1068,31 @@ describe('PageSelector', () => { }) it('should handle empty checkedIds Set', () => { - // Arrange const props = createDefaultProps({ checkedIds: new Set() }) - // Act render(<PageSelector {...props} />) - // Assert const checkbox = getCheckbox() expect(checkbox).toBeInTheDocument() expect(isCheckboxChecked(checkbox)).toBe(false) }) it('should handle empty disabledValue Set', () => { - // Arrange const props = createDefaultProps({ disabledValue: new Set() }) - // Act render(<PageSelector {...props} />) - // Assert const checkbox = getCheckbox() expect(checkbox).toBeInTheDocument() expect(isCheckboxDisabled(checkbox)).toBe(false) }) it('should handle undefined onPreview gracefully', () => { - // Arrange const props = createDefaultProps({ onPreview: undefined, canPreview: true, }) - // Act render(<PageSelector {...props} />) // Assert - Click should not throw @@ -1307,14 +1102,12 @@ describe('PageSelector', () => { }) it('should handle page without descendants correctly', () => { - // Arrange const leafPage = createMockPage({ page_id: 'leaf', page_name: 'Leaf Page' }) const props = createDefaultProps({ list: [leafPage], pagesMap: createMockPagesMap([leafPage]), }) - // Act render(<PageSelector {...props} />) // Assert - No expand arrow for leaf pages @@ -1323,9 +1116,7 @@ describe('PageSelector', () => { }) }) - // ========================================== // All Prop Variations - // ========================================== describe('Prop Variations', () => { it.each([ [{ canPreview: true, isMultipleChoice: true }], @@ -1333,13 +1124,10 @@ describe('PageSelector', () => { [{ canPreview: false, isMultipleChoice: true }], [{ canPreview: false, isMultipleChoice: false }], ])('should render correctly with props %o', (propVariation) => { - // Arrange const props = createDefaultProps(propVariation) - // Act render(<PageSelector {...props} />) - // Assert expect(screen.getByTestId('virtual-list')).toBeInTheDocument() if (propVariation.canPreview) expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument() @@ -1353,7 +1141,6 @@ describe('PageSelector', () => { }) it('should handle all default prop values', () => { - // Arrange const minimalProps: PageSelectorProps = { checkedIds: new Set(), disabledValue: new Set(), @@ -1366,7 +1153,6 @@ describe('PageSelector', () => { // isMultipleChoice defaults to true } - // Act render(<PageSelector {...minimalProps} />) // Assert - Defaults should be applied @@ -1375,12 +1161,9 @@ describe('PageSelector', () => { }) }) - // ========================================== // Utils Function Tests - // ========================================== describe('Utils - recursivePushInParentDescendants', () => { it('should build tree structure for simple parent-child relationship', () => { - // Arrange const parent = createMockPage({ page_id: 'parent', page_name: 'Parent', parent_id: 'root' }) const child = createMockPage({ page_id: 'child', page_name: 'Child', parent_id: 'parent' }) const pagesMap = createMockPagesMap([parent, child]) @@ -1396,10 +1179,8 @@ describe('PageSelector', () => { } listTreeMap[child.page_id] = childEntry - // Act recursivePushInParentDescendants(pagesMap, listTreeMap, childEntry, childEntry) - // Assert expect(listTreeMap.parent).toBeDefined() expect(listTreeMap.parent.children.has('child')).toBe(true) expect(listTreeMap.parent.descendants.has('child')).toBe(true) @@ -1408,7 +1189,6 @@ describe('PageSelector', () => { }) it('should handle root level pages', () => { - // Arrange const rootPage = createMockPage({ page_id: 'root-page', parent_id: 'root' }) const pagesMap = createMockPagesMap([rootPage]) const listTreeMap: NotionPageTreeMap = {} @@ -1422,7 +1202,6 @@ describe('PageSelector', () => { } listTreeMap[rootPage.page_id] = rootEntry - // Act recursivePushInParentDescendants(pagesMap, listTreeMap, rootEntry, rootEntry) // Assert - No parent should be created for root level @@ -1432,7 +1211,6 @@ describe('PageSelector', () => { }) it('should handle missing parent in pagesMap', () => { - // Arrange const orphan = createMockPage({ page_id: 'orphan', parent_id: 'missing-parent' }) const pagesMap = createMockPagesMap([orphan]) const listTreeMap: NotionPageTreeMap = {} @@ -1446,7 +1224,6 @@ describe('PageSelector', () => { } listTreeMap[orphan.page_id] = orphanEntry - // Act recursivePushInParentDescendants(pagesMap, listTreeMap, orphanEntry, orphanEntry) // Assert - Should not create parent entry for missing parent @@ -1454,7 +1231,6 @@ describe('PageSelector', () => { }) it('should handle null parent_id', () => { - // Arrange const page = createMockPage({ page_id: 'page', parent_id: '' }) const pagesMap = createMockPagesMap([page]) const listTreeMap: NotionPageTreeMap = {} @@ -1468,7 +1244,6 @@ describe('PageSelector', () => { } listTreeMap[page.page_id] = pageEntry - // Act recursivePushInParentDescendants(pagesMap, listTreeMap, pageEntry, pageEntry) // Assert - Early return, no changes @@ -1513,7 +1288,6 @@ describe('PageSelector', () => { // Act - Process from leaf to root recursivePushInParentDescendants(pagesMap, listTreeMap, l2Entry, l2Entry) - // Assert expect(l2Entry.depth).toBe(2) expect(l2Entry.ancestors).toEqual(['Level 0', 'Level 1']) expect(listTreeMap.l1.children.has('l2')).toBe(true) @@ -1521,7 +1295,6 @@ describe('PageSelector', () => { }) it('should update existing parent entry', () => { - // Arrange const parent = createMockPage({ page_id: 'parent', page_name: 'Parent', parent_id: 'root' }) const child1 = createMockPage({ page_id: 'child1', parent_id: 'parent' }) const child2 = createMockPage({ page_id: 'child2', parent_id: 'parent' }) @@ -1546,7 +1319,6 @@ describe('PageSelector', () => { } listTreeMap[child2.page_id] = child2Entry - // Act recursivePushInParentDescendants(pagesMap, listTreeMap, child2Entry, child2Entry) // Assert - Should add child2 to existing parent @@ -1557,12 +1329,9 @@ describe('PageSelector', () => { }) }) - // ========================================== // Item Component Integration Tests - // ========================================== describe('Item Component Integration', () => { it('should render item with correct styling for preview state', () => { - // Arrange const page = createMockPage({ page_id: 'page-1', page_name: 'Test Page' }) const props = createDefaultProps({ list: [page], @@ -1570,10 +1339,8 @@ describe('PageSelector', () => { canPreview: true, }) - // Act render(<PageSelector {...props} />) - // Click preview to set currentPreviewPageId fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) // Assert - Item should have preview styling class @@ -1582,14 +1349,12 @@ describe('PageSelector', () => { }) it('should show arrow for pages with children', () => { - // Arrange const { list, pagesMap } = createHierarchicalPages() const props = createDefaultProps({ list, pagesMap, }) - // Act render(<PageSelector {...props} />) // Assert - Root page should have expand arrow @@ -1598,14 +1363,12 @@ describe('PageSelector', () => { }) it('should not show arrow for leaf pages', () => { - // Arrange const leafPage = createMockPage({ page_id: 'leaf', page_name: 'Leaf' }) const props = createDefaultProps({ list: [leafPage], pagesMap: createMockPagesMap([leafPage]), }) - // Act render(<PageSelector {...props} />) // Assert - No expand arrow for leaf pages @@ -1614,7 +1377,6 @@ describe('PageSelector', () => { }) it('should hide arrows in search mode', () => { - // Arrange const { list, pagesMap } = createHierarchicalPages() const props = createDefaultProps({ list, @@ -1622,7 +1384,6 @@ describe('PageSelector', () => { searchValue: 'Root', }) - // Act render(<PageSelector {...props} />) // Assert - No expand arrows in search mode (renderArrow returns null when searchValue) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/utils.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/utils.spec.ts new file mode 100644 index 0000000000..601dc2f5bf --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/__tests__/utils.spec.ts @@ -0,0 +1,100 @@ +import type { NotionPageTreeItem, NotionPageTreeMap } from '../index' +import type { DataSourceNotionPageMap } from '@/models/common' +import { describe, expect, it } from 'vitest' +import { recursivePushInParentDescendants } from '../utils' + +const makePageEntry = (overrides: Partial<NotionPageTreeItem>): NotionPageTreeItem => ({ + page_icon: null, + page_id: '', + page_name: '', + parent_id: '', + type: 'page', + is_bound: false, + children: new Set(), + descendants: new Set(), + depth: 0, + ancestors: [], + ...overrides, +}) + +describe('recursivePushInParentDescendants', () => { + it('should add child to parent descendants', () => { + const pagesMap = { + parent1: { page_id: 'parent1', parent_id: 'root', page_name: 'Parent' }, + child1: { page_id: 'child1', parent_id: 'parent1', page_name: 'Child' }, + } as unknown as DataSourceNotionPageMap + + const listTreeMap: NotionPageTreeMap = { + child1: makePageEntry({ page_id: 'child1', parent_id: 'parent1', page_name: 'Child' }), + } + + recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child1, listTreeMap.child1) + + expect(listTreeMap.parent1).toBeDefined() + expect(listTreeMap.parent1.children.has('child1')).toBe(true) + expect(listTreeMap.parent1.descendants.has('child1')).toBe(true) + }) + + it('should recursively populate ancestors for deeply nested items', () => { + const pagesMap = { + grandparent: { page_id: 'grandparent', parent_id: 'root', page_name: 'Grandparent' }, + parent: { page_id: 'parent', parent_id: 'grandparent', page_name: 'Parent' }, + child: { page_id: 'child', parent_id: 'parent', page_name: 'Child' }, + } as unknown as DataSourceNotionPageMap + + const listTreeMap: NotionPageTreeMap = { + parent: makePageEntry({ page_id: 'parent', parent_id: 'grandparent', page_name: 'Parent' }), + child: makePageEntry({ page_id: 'child', parent_id: 'parent', page_name: 'Child' }), + } + + recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child, listTreeMap.child) + + expect(listTreeMap.child.depth).toBe(2) + expect(listTreeMap.child.ancestors).toContain('Grandparent') + expect(listTreeMap.child.ancestors).toContain('Parent') + }) + + it('should do nothing for root parent', () => { + const pagesMap = { + root_child: { page_id: 'root_child', parent_id: 'root', page_name: 'Root Child' }, + } as unknown as DataSourceNotionPageMap + + const listTreeMap: NotionPageTreeMap = { + root_child: makePageEntry({ page_id: 'root_child', parent_id: 'root', page_name: 'Root Child' }), + } + + recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.root_child, listTreeMap.root_child) + + // No new entries should be added since parent is root + expect(Object.keys(listTreeMap)).toEqual(['root_child']) + }) + + it('should handle missing parent_id gracefully', () => { + const pagesMap = {} as DataSourceNotionPageMap + const current = makePageEntry({ page_id: 'orphan', parent_id: undefined as unknown as string }) + const listTreeMap: NotionPageTreeMap = { orphan: current } + + // Should not throw + recursivePushInParentDescendants(pagesMap, listTreeMap, current, current) + expect(listTreeMap.orphan.depth).toBe(0) + }) + + it('should add to existing parent entry when parent already in tree', () => { + const pagesMap = { + parent: { page_id: 'parent', parent_id: 'root', page_name: 'Parent' }, + child1: { page_id: 'child1', parent_id: 'parent', page_name: 'Child1' }, + child2: { page_id: 'child2', parent_id: 'parent', page_name: 'Child2' }, + } as unknown as DataSourceNotionPageMap + + const listTreeMap: NotionPageTreeMap = { + parent: makePageEntry({ page_id: 'parent', parent_id: 'root', children: new Set(['child1']), descendants: new Set(['child1']), page_name: 'Parent' }), + child2: makePageEntry({ page_id: 'child2', parent_id: 'parent', page_name: 'Child2' }), + } + + recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap.child2, listTreeMap.child2) + + expect(listTreeMap.parent.children.has('child2')).toBe(true) + expect(listTreeMap.parent.descendants.has('child2')).toBe(true) + expect(listTreeMap.parent.children.has('child1')).toBe(true) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/header.spec.tsx new file mode 100644 index 0000000000..c7a61dfdad --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/header.spec.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Header from '../header' + +describe('OnlineDriveHeader', () => { + const defaultProps = { + docTitle: 'S3 Guide', + docLink: 'https://docs.aws.com/s3', + onClickConfiguration: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render doc link with title', () => { + render(<Header {...defaultProps} />) + const link = screen.getByText('S3 Guide').closest('a') + expect(link).toHaveAttribute('href', 'https://docs.aws.com/s3') + expect(link).toHaveAttribute('target', '_blank') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx index fb7fef1cbb..1721b72e1c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx @@ -5,15 +5,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline' -import Header from './header' -import OnlineDrive from './index' -import { convertOnlineDriveData, isBucketListInitiation, isFile } from './utils' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import Header from '../header' +import OnlineDrive from '../index' +import { convertOnlineDriveData, isBucketListInitiation, isFile } from '../utils' // Mock useDocLink - context hook requires mocking const mockDocLink = vi.fn((path?: string) => `https://docs.example.com${path || ''}`) @@ -24,13 +18,13 @@ vi.mock('@/context/i18n', () => ({ // Mock dataset-detail context - context provider requires mocking let mockPipelineId: string | undefined = 'pipeline-123' vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (s: any) => any) => selector({ dataset: { pipeline_id: mockPipelineId } }), + useDatasetDetailContextWithSelector: (selector: (s: Record<string, unknown>) => unknown) => selector({ dataset: { pipeline_id: mockPipelineId } }), })) // Mock modal context - context provider requires mocking const mockSetShowAccountSettingModal = vi.fn() vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: (selector: (s: any) => any) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), + useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), })) // Mock ssePost - API service requires mocking @@ -51,7 +45,6 @@ vi.mock('@/service/use-datasource', () => ({ useGetDataSourceAuth: mockUseGetDataSourceAuth, })) -// Mock Toast const { mockToastNotify } = vi.hoisted(() => ({ mockToastNotify: vi.fn(), })) @@ -66,7 +59,7 @@ vi.mock('@/app/components/base/toast', () => ({ // Mock store state const mockStoreState = { - nextPageParameters: {} as Record<string, any>, + nextPageParameters: {} as Record<string, unknown>, breadcrumbs: [] as string[], prefix: [] as string[], keywords: '', @@ -88,48 +81,48 @@ const mockStoreState = { const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } -vi.mock('../store', () => ({ - useDataSourceStoreWithSelector: (selector: (s: any) => any) => selector(mockStoreState), +vi.mock('../../store', () => ({ + useDataSourceStoreWithSelector: (selector: (s: Record<string, unknown>) => unknown) => selector(mockStoreState as unknown as Record<string, unknown>), useDataSourceStore: () => mockDataSourceStore, })) // Mock Header component -vi.mock('../base/header', () => ({ - default: (props: any) => ( +vi.mock('../../base/header', () => ({ + default: (props: Record<string, unknown>) => ( <div data-testid="header"> - <span data-testid="header-doc-title">{props.docTitle}</span> - <span data-testid="header-doc-link">{props.docLink}</span> - <span data-testid="header-plugin-name">{props.pluginName}</span> - <span data-testid="header-credential-id">{props.currentCredentialId}</span> - <button data-testid="header-config-btn" onClick={props.onClickConfiguration}>Configure</button> - <button data-testid="header-credential-change" onClick={() => props.onCredentialChange('new-cred-id')}>Change Credential</button> - <span data-testid="header-credentials-count">{props.credentials?.length || 0}</span> + <span data-testid="header-doc-title">{props.docTitle as string}</span> + <span data-testid="header-doc-link">{props.docLink as string}</span> + <span data-testid="header-plugin-name">{props.pluginName as string}</span> + <span data-testid="header-credential-id">{props.currentCredentialId as string}</span> + <button data-testid="header-config-btn" onClick={props.onClickConfiguration as React.MouseEventHandler}>Configure</button> + <button data-testid="header-credential-change" onClick={() => (props.onCredentialChange as (id: string) => void)('new-cred-id')}>Change Credential</button> + <span data-testid="header-credentials-count">{(props.credentials as unknown[] | undefined)?.length || 0}</span> </div> ), })) // Mock FileList component -vi.mock('./file-list', () => ({ - default: (props: any) => ( +vi.mock('../file-list', () => ({ + default: (props: Record<string, unknown>) => ( <div data-testid="file-list"> - <span data-testid="file-list-count">{props.fileList?.length || 0}</span> - <span data-testid="file-list-selected-count">{props.selectedFileIds?.length || 0}</span> - <span data-testid="file-list-breadcrumbs">{props.breadcrumbs?.join('/') || ''}</span> - <span data-testid="file-list-keywords">{props.keywords}</span> - <span data-testid="file-list-bucket">{props.bucket}</span> + <span data-testid="file-list-count">{(props.fileList as unknown[] | undefined)?.length || 0}</span> + <span data-testid="file-list-selected-count">{(props.selectedFileIds as unknown[] | undefined)?.length || 0}</span> + <span data-testid="file-list-breadcrumbs">{(props.breadcrumbs as string[] | undefined)?.join('/') || ''}</span> + <span data-testid="file-list-keywords">{props.keywords as string}</span> + <span data-testid="file-list-bucket">{props.bucket as string}</span> <span data-testid="file-list-loading">{String(props.isLoading)}</span> <span data-testid="file-list-is-in-pipeline">{String(props.isInPipeline)}</span> <span data-testid="file-list-support-batch">{String(props.supportBatchUpload)}</span> <input data-testid="file-list-search-input" - onChange={e => props.updateKeywords(e.target.value)} + onChange={e => (props.updateKeywords as (v: string) => void)(e.target.value)} /> - <button data-testid="file-list-reset-keywords" onClick={props.resetKeywords}>Reset</button> + <button data-testid="file-list-reset-keywords" onClick={props.resetKeywords as React.MouseEventHandler}>Reset</button> <button data-testid="file-list-select-file" onClick={() => { const file: OnlineDriveFile = { id: 'file-1', name: 'test.txt', type: OnlineDriveFileType.file } - props.handleSelectFile(file) + ;(props.handleSelectFile as (f: OnlineDriveFile) => void)(file) }} > Select File @@ -138,7 +131,7 @@ vi.mock('./file-list', () => ({ data-testid="file-list-select-bucket" onClick={() => { const file: OnlineDriveFile = { id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket } - props.handleSelectFile(file) + ;(props.handleSelectFile as (f: OnlineDriveFile) => void)(file) }} > Select Bucket @@ -147,7 +140,7 @@ vi.mock('./file-list', () => ({ data-testid="file-list-open-folder" onClick={() => { const file: OnlineDriveFile = { id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder } - props.handleOpenFolder(file) + ;(props.handleOpenFolder as (f: OnlineDriveFile) => void)(file) }} > Open Folder @@ -156,7 +149,7 @@ vi.mock('./file-list', () => ({ data-testid="file-list-open-bucket" onClick={() => { const file: OnlineDriveFile = { id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket } - props.handleOpenFolder(file) + ;(props.handleOpenFolder as (f: OnlineDriveFile) => void)(file) }} > Open Bucket @@ -165,7 +158,7 @@ vi.mock('./file-list', () => ({ data-testid="file-list-open-file" onClick={() => { const file: OnlineDriveFile = { id: 'file-1', name: 'test.txt', type: OnlineDriveFileType.file } - props.handleOpenFolder(file) + ;(props.handleOpenFolder as (f: OnlineDriveFile) => void)(file) }} > Open File @@ -174,9 +167,6 @@ vi.mock('./file-list', () => ({ ), })) -// ========================================== -// Test Data Builders -// ========================================== const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({ title: 'Test Node', plugin_id: 'plugin-123', @@ -218,9 +208,6 @@ const createDefaultProps = (overrides?: Partial<OnlineDriveProps>): OnlineDriveP ...overrides, }) -// ========================================== -// Helper Functions -// ========================================== const resetMockStoreState = () => { mockStoreState.nextPageParameters = {} mockStoreState.breadcrumbs = [] @@ -241,9 +228,6 @@ const resetMockStoreState = () => { mockStoreState.setHasBucket = vi.fn() } -// ========================================== -// Test Suites -// ========================================== describe('OnlineDrive', () => { beforeEach(() => { vi.clearAllMocks() @@ -263,40 +247,30 @@ describe('OnlineDrive', () => { mockGetState.mockReturnValue(mockStoreState) }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('header')).toBeInTheDocument() expect(screen.getByTestId('file-list')).toBeInTheDocument() }) it('should render Header with correct props', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-123' const props = createDefaultProps({ nodeData: createMockNodeData({ datasource_label: 'My Online Drive' }), }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('header-doc-title')).toHaveTextContent('Docs') expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('My Online Drive') expect(screen.getByTestId('header-credential-id')).toHaveTextContent('cred-123') }) it('should render FileList with correct props', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.keywords = 'search-term' mockStoreState.breadcrumbs = ['folder1', 'folder2'] @@ -308,10 +282,8 @@ describe('OnlineDrive', () => { ] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list')).toBeInTheDocument() expect(screen.getByTestId('file-list-keywords')).toHaveTextContent('search-term') expect(screen.getByTestId('file-list-breadcrumbs')).toHaveTextContent('folder1/folder2') @@ -320,31 +292,23 @@ describe('OnlineDrive', () => { }) it('should pass docLink with correct path to Header', () => { - // Arrange const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(mockDocLink).toHaveBeenCalledWith('/use-dify/knowledge/knowledge-pipeline/authorize-data-source') }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('nodeId prop', () => { it('should use nodeId in datasourceNodeRunURL for non-pipeline mode', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ nodeId: 'custom-node-id', isInPipeline: false, }) - // Act render(<OnlineDrive {...props} />) // Assert - ssePost should be called with correct URL @@ -358,14 +322,12 @@ describe('OnlineDrive', () => { }) it('should use nodeId in datasourceNodeRunURL for pipeline mode', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ nodeId: 'custom-node-id', isInPipeline: true, }) - // Act render(<OnlineDrive {...props} />) // Assert - ssePost should be called with correct URL for draft @@ -381,17 +343,14 @@ describe('OnlineDrive', () => { describe('nodeData prop', () => { it('should pass plugin_id and provider_name to useGetDataSourceAuth', () => { - // Arrange const nodeData = createMockNodeData({ plugin_id: 'my-plugin-id', provider_name: 'my-provider', }) const props = createDefaultProps({ nodeData }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ pluginId: 'my-plugin-id', provider: 'my-provider', @@ -399,30 +358,24 @@ describe('OnlineDrive', () => { }) it('should pass datasource_label to Header as pluginName', () => { - // Arrange const nodeData = createMockNodeData({ datasource_label: 'Custom Online Drive', }) const props = createDefaultProps({ nodeData }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('Custom Online Drive') }) }) describe('isInPipeline prop', () => { it('should use draft URL when isInPipeline is true', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ isInPipeline: true }) - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalledWith( expect.stringContaining('/workflows/draft/'), @@ -433,14 +386,11 @@ describe('OnlineDrive', () => { }) it('should use published URL when isInPipeline is false', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ isInPipeline: false }) - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalledWith( expect.stringContaining('/workflows/published/'), @@ -451,37 +401,28 @@ describe('OnlineDrive', () => { }) it('should pass isInPipeline to FileList', () => { - // Arrange const props = createDefaultProps({ isInPipeline: true }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-is-in-pipeline')).toHaveTextContent('true') }) }) describe('supportBatchUpload prop', () => { it('should pass supportBatchUpload true to FileList when supportBatchUpload is true', () => { - // Arrange const props = createDefaultProps({ supportBatchUpload: true }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-support-batch')).toHaveTextContent('true') }) it('should pass supportBatchUpload false to FileList when supportBatchUpload is false', () => { - // Arrange const props = createDefaultProps({ supportBatchUpload: false }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-support-batch')).toHaveTextContent('false') }) @@ -490,59 +431,45 @@ describe('OnlineDrive', () => { [false, 'false'], [undefined, 'true'], // Default value ])('should handle supportBatchUpload=%s correctly', (value, expected) => { - // Arrange const props = createDefaultProps({ supportBatchUpload: value }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-support-batch')).toHaveTextContent(expected) }) }) describe('onCredentialChange prop', () => { it('should call onCredentialChange with credential id', () => { - // Arrange const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) - // Act render(<OnlineDrive {...props} />) fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) }) }) - // ========================================== - // State Management Tests - // ========================================== describe('State Management', () => { it('should fetch files on initial mount when fileList is empty', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.onlineDriveFileList = [] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalled() }) }) it('should not fetch files on initial mount when fileList is not empty', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.onlineDriveFileList = [createMockOnlineDriveFile()] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) // Assert - Wait a bit to ensure no call is made @@ -551,11 +478,9 @@ describe('OnlineDrive', () => { }) it('should not fetch files when currentCredentialId is empty', async () => { - // Arrange mockStoreState.currentCredentialId = '' const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) // Assert - Wait a bit to ensure no call is made @@ -564,24 +489,20 @@ describe('OnlineDrive', () => { }) it('should show loading state during fetch', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation(() => { // Never resolves to keep loading state }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(screen.getByTestId('file-list-loading')).toHaveTextContent('true') }) }) it('should update file list on successful fetch', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockFiles = [ { id: 'file-1', name: 'file1.txt', type: 'file' as const }, @@ -600,17 +521,14 @@ describe('OnlineDrive', () => { }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalled() }) }) it('should show error toast on fetch error', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const errorMessage = 'Failed to fetch files' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -620,10 +538,8 @@ describe('OnlineDrive', () => { }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -633,12 +549,9 @@ describe('OnlineDrive', () => { }) }) - // ========================================== // Memoization Logic and Dependencies Tests - // ========================================== describe('Memoization Logic', () => { it('should filter files by keywords', () => { - // Arrange mockStoreState.keywords = 'test' mockStoreState.onlineDriveFileList = [ createMockOnlineDriveFile({ id: '1', name: 'test-file.txt' }), @@ -647,7 +560,6 @@ describe('OnlineDrive', () => { ] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) // Assert - filteredOnlineDriveFileList should have 2 items matching 'test' @@ -655,7 +567,6 @@ describe('OnlineDrive', () => { }) it('should return all files when keywords is empty', () => { - // Arrange mockStoreState.keywords = '' mockStoreState.onlineDriveFileList = [ createMockOnlineDriveFile({ id: '1', name: 'file1.txt' }), @@ -664,15 +575,12 @@ describe('OnlineDrive', () => { ] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-count')).toHaveTextContent('3') }) it('should filter files case-insensitively', () => { - // Arrange mockStoreState.keywords = 'TEST' mockStoreState.onlineDriveFileList = [ createMockOnlineDriveFile({ id: '1', name: 'test-file.txt' }), @@ -681,109 +589,83 @@ describe('OnlineDrive', () => { ] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-count')).toHaveTextContent('2') }) }) - // ========================================== // Callback Stability and Memoization - // ========================================== describe('Callback Stability and Memoization', () => { it('should have stable handleSetting callback', () => { - // Arrange const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('header-config-btn')) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }) it('should have stable updateKeywords that updates store', () => { - // Arrange const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.change(screen.getByTestId('file-list-search-input'), { target: { value: 'new-keyword' } }) - // Assert expect(mockStoreState.setKeywords).toHaveBeenCalledWith('new-keyword') }) it('should have stable resetKeywords that clears keywords', () => { - // Arrange mockStoreState.keywords = 'old-keyword' const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-reset-keywords')) - // Assert expect(mockStoreState.setKeywords).toHaveBeenCalledWith('') }) }) - // ========================================== // User Interactions and Event Handlers - // ========================================== describe('User Interactions', () => { describe('File Selection', () => { it('should toggle file selection on file click', () => { - // Arrange mockStoreState.selectedFileIds = [] const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-select-file')) - // Assert expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith(['file-1']) }) it('should deselect file if already selected', () => { - // Arrange mockStoreState.selectedFileIds = ['file-1'] const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-select-file')) - // Assert expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) }) it('should not select bucket type items', () => { - // Arrange mockStoreState.selectedFileIds = [] const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-select-bucket')) - // Assert expect(mockStoreState.setSelectedFileIds).not.toHaveBeenCalled() }) it('should limit selection to one file when supportBatchUpload is false', () => { - // Arrange mockStoreState.selectedFileIds = ['existing-file'] const props = createDefaultProps({ supportBatchUpload: false }) render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-select-file')) // Assert - Should not add new file because there's already one selected @@ -791,31 +673,25 @@ describe('OnlineDrive', () => { }) it('should allow multiple selections when supportBatchUpload is true', () => { - // Arrange mockStoreState.selectedFileIds = ['existing-file'] const props = createDefaultProps({ supportBatchUpload: true }) render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-select-file')) - // Assert expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith(['existing-file', 'file-1']) }) }) describe('Folder Navigation', () => { it('should open folder and update breadcrumbs/prefix', () => { - // Arrange mockStoreState.breadcrumbs = [] mockStoreState.prefix = [] const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-open-folder')) - // Assert expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith(['my-folder']) @@ -823,24 +699,19 @@ describe('OnlineDrive', () => { }) it('should open bucket and set bucket name', () => { - // Arrange const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-open-bucket')) - // Assert expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) expect(mockStoreState.setBucket).toHaveBeenCalledWith('my-bucket') }) it('should not navigate when opening a file', () => { - // Arrange const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('file-list-open-file')) // Assert - No navigation functions should be called @@ -852,29 +723,23 @@ describe('OnlineDrive', () => { describe('Credential Change', () => { it('should call onCredentialChange prop', () => { - // Arrange const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) }) describe('Configuration', () => { it('should open account setting modal on configuration click', () => { - // Arrange const props = createDefaultProps() render(<OnlineDrive {...props} />) - // Act fireEvent.click(screen.getByTestId('header-config-btn')) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) @@ -882,12 +747,9 @@ describe('OnlineDrive', () => { }) }) - // ========================================== // Side Effects and Cleanup Tests - // ========================================== describe('Side Effects and Cleanup', () => { it('should fetch files when nextPageParameters changes after initial mount', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.onlineDriveFileList = [createMockOnlineDriveFile()] const props = createDefaultProps() @@ -897,14 +759,12 @@ describe('OnlineDrive', () => { mockStoreState.nextPageParameters = { page: 2 } rerender(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalled() }) }) it('should fetch files when prefix changes after initial mount', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.onlineDriveFileList = [createMockOnlineDriveFile()] const props = createDefaultProps() @@ -914,14 +774,12 @@ describe('OnlineDrive', () => { mockStoreState.prefix = ['folder1'] rerender(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalled() }) }) it('should fetch files when bucket changes after initial mount', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.onlineDriveFileList = [createMockOnlineDriveFile()] const props = createDefaultProps() @@ -931,14 +789,12 @@ describe('OnlineDrive', () => { mockStoreState.bucket = 'new-bucket' rerender(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalled() }) }) it('should fetch files when currentCredentialId changes after initial mount', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.onlineDriveFileList = [createMockOnlineDriveFile()] const props = createDefaultProps() @@ -948,14 +804,12 @@ describe('OnlineDrive', () => { mockStoreState.currentCredentialId = 'cred-2' rerender(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalled() }) }) it('should not fetch files concurrently (debounce)', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' let resolveFirst: () => void const firstPromise = new Promise<void>((resolve) => { @@ -971,7 +825,6 @@ describe('OnlineDrive', () => { }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) // Try to trigger another fetch while first is loading @@ -980,27 +833,21 @@ describe('OnlineDrive', () => { // Assert - Only one call should be made initially due to isLoadingRef guard expect(mockSsePost).toHaveBeenCalledTimes(1) - // Cleanup resolveFirst!() }) }) - // ========================================== // API Calls Mocking Tests - // ========================================== describe('API Calls', () => { it('should call ssePost with correct parameters', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.prefix = ['folder1'] mockStoreState.bucket = 'my-bucket' mockStoreState.nextPageParameters = { cursor: 'abc' } const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalledWith( expect.any(String), @@ -1025,7 +872,6 @@ describe('OnlineDrive', () => { }) it('should handle completed response and update store', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.breadcrumbs = ['folder1'] mockStoreState.bucket = 'my-bucket' @@ -1046,10 +892,8 @@ describe('OnlineDrive', () => { }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalled() expect(mockStoreState.setHasBucket).toHaveBeenCalledWith(true) @@ -1059,7 +903,6 @@ describe('OnlineDrive', () => { }) it('should handle error response and show toast', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const errorMessage = 'Access denied' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -1069,10 +912,8 @@ describe('OnlineDrive', () => { }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith({ type: 'error', @@ -1082,45 +923,34 @@ describe('OnlineDrive', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle empty credentials list', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: { result: [] }, }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle undefined credentials data', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: undefined, }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle undefined pipelineId', async () => { - // Arrange mockPipelineId = undefined mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) // Assert - Should still attempt to call ssePost with undefined in URL @@ -1134,43 +964,33 @@ describe('OnlineDrive', () => { }) it('should handle empty file list', () => { - // Arrange mockStoreState.onlineDriveFileList = [] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-count')).toHaveTextContent('0') }) it('should handle empty breadcrumbs', () => { - // Arrange mockStoreState.breadcrumbs = [] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-breadcrumbs')).toHaveTextContent('') }) it('should handle empty bucket', () => { - // Arrange mockStoreState.bucket = '' const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-bucket')).toHaveTextContent('') }) it('should handle special characters in keywords', () => { - // Arrange mockStoreState.keywords = 'test.file[1]' mockStoreState.onlineDriveFileList = [ createMockOnlineDriveFile({ id: '1', name: 'test.file[1].txt' }), @@ -1178,7 +998,6 @@ describe('OnlineDrive', () => { ] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) // Assert - Should find file with special characters @@ -1186,22 +1005,18 @@ describe('OnlineDrive', () => { }) it('should handle very long file names', () => { - // Arrange const longName = `${'a'.repeat(500)}.txt` mockStoreState.onlineDriveFileList = [ createMockOnlineDriveFile({ id: '1', name: longName }), ] const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('file-list-count')).toHaveTextContent('1') }) it('should handle bucket list initiation response', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.bucket = '' mockStoreState.prefix = [] @@ -1217,19 +1032,14 @@ describe('OnlineDrive', () => { }) const props = createDefaultProps() - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockStoreState.setHasBucket).toHaveBeenCalledWith(true) }) }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('Prop Variations', () => { it.each([ { isInPipeline: true, supportBatchUpload: true }, @@ -1237,13 +1047,10 @@ describe('OnlineDrive', () => { { isInPipeline: false, supportBatchUpload: true }, { isInPipeline: false, supportBatchUpload: false }, ])('should render correctly with isInPipeline=%s and supportBatchUpload=%s', (propVariation) => { - // Arrange const props = createDefaultProps(propVariation) - // Act render(<OnlineDrive {...props} />) - // Assert expect(screen.getByTestId('header')).toBeInTheDocument() expect(screen.getByTestId('file-list')).toBeInTheDocument() expect(screen.getByTestId('file-list-is-in-pipeline')).toHaveTextContent(String(propVariation.isInPipeline)) @@ -1255,14 +1062,11 @@ describe('OnlineDrive', () => { { nodeId: 'node-b', expectedUrlPart: 'nodes/node-b/run' }, { nodeId: '123-456', expectedUrlPart: 'nodes/123-456/run' }, ])('should use correct URL for nodeId=%s', async ({ nodeId, expectedUrlPart }) => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ nodeId }) - // Act render(<OnlineDrive {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalledWith( expect.stringContaining(expectedUrlPart), @@ -1277,7 +1081,6 @@ describe('OnlineDrive', () => { { pluginId: 'plugin-b', providerName: 'provider-b' }, { pluginId: '', providerName: '' }, ])('should call useGetDataSourceAuth with pluginId=%s and providerName=%s', ({ pluginId, providerName }) => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ plugin_id: pluginId, @@ -1285,10 +1088,8 @@ describe('OnlineDrive', () => { }), }) - // Act render(<OnlineDrive {...props} />) - // Assert expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ pluginId, provider: providerName, @@ -1297,9 +1098,7 @@ describe('OnlineDrive', () => { }) }) -// ========================================== // Header Component Tests -// ========================================== describe('Header', () => { const createHeaderProps = (overrides?: Partial<React.ComponentProps<typeof Header>>) => ({ onClickConfiguration: vi.fn(), @@ -1314,27 +1113,21 @@ describe('Header', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createHeaderProps() - // Act render(<Header {...props} />) - // Assert expect(screen.getByText('Documentation')).toBeInTheDocument() }) it('should render doc link with correct href', () => { - // Arrange const props = createHeaderProps({ docLink: 'https://custom-docs.com/path', docTitle: 'Custom Docs', }) - // Act render(<Header {...props} />) - // Assert const link = screen.getByRole('link') expect(link).toHaveAttribute('href', 'https://custom-docs.com/path') expect(link).toHaveAttribute('target', '_blank') @@ -1342,24 +1135,18 @@ describe('Header', () => { }) it('should render doc title text', () => { - // Arrange const props = createHeaderProps({ docTitle: 'My Documentation Title' }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByText('My Documentation Title')).toBeInTheDocument() }) it('should render configuration button', () => { - // Arrange const props = createHeaderProps() - // Act render(<Header {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) }) @@ -1372,13 +1159,10 @@ describe('Header', () => { 'Installation Guide', '', ])('should render docTitle="%s"', (docTitle) => { - // Arrange const props = createHeaderProps({ docTitle }) - // Act render(<Header {...props} />) - // Assert if (docTitle) expect(screen.getByText(docTitle)).toBeInTheDocument() }) @@ -1390,37 +1174,29 @@ describe('Header', () => { 'https://docs.example.com/path/to/page', '/relative/path', ])('should set href to "%s"', (docLink) => { - // Arrange const props = createHeaderProps({ docLink }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByRole('link')).toHaveAttribute('href', docLink) }) }) describe('onClickConfiguration prop', () => { it('should call onClickConfiguration when configuration icon is clicked', () => { - // Arrange const mockOnClickConfiguration = vi.fn() const props = createHeaderProps({ onClickConfiguration: mockOnClickConfiguration }) - // Act render(<Header {...props} />) const configIcon = screen.getByRole('button').querySelector('svg') fireEvent.click(configIcon!) - // Assert expect(mockOnClickConfiguration).toHaveBeenCalledTimes(1) }) it('should not throw when onClickConfiguration is undefined', () => { - // Arrange const props = createHeaderProps({ onClickConfiguration: undefined }) - // Act & Assert expect(() => render(<Header {...props} />)).not.toThrow() }) }) @@ -1428,34 +1204,25 @@ describe('Header', () => { describe('Accessibility', () => { it('should have accessible link with title attribute', () => { - // Arrange const props = createHeaderProps({ docTitle: 'Accessible Title' }) - // Act render(<Header {...props} />) - // Assert const titleSpan = screen.getByTitle('Accessible Title') expect(titleSpan).toBeInTheDocument() }) }) }) -// ========================================== // Utils Tests -// ========================================== describe('utils', () => { - // ========================================== // isFile Tests - // ========================================== describe('isFile', () => { it('should return true for file type', () => { - // Act & Assert expect(isFile('file')).toBe(true) }) it('should return false for folder type', () => { - // Act & Assert expect(isFile('folder')).toBe(false) }) @@ -1463,98 +1230,76 @@ describe('utils', () => { ['file', true], ['folder', false], ] as const)('isFile(%s) should return %s', (type, expected) => { - // Act & Assert expect(isFile(type)).toBe(expected) }) }) - // ========================================== // isBucketListInitiation Tests - // ========================================== describe('isBucketListInitiation', () => { it('should return false when bucket is not empty', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', files: [], is_truncated: false, next_page_parameters: {} }, ] - // Act & Assert expect(isBucketListInitiation(data, [], 'existing-bucket')).toBe(false) }) it('should return false when prefix is not empty', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', files: [], is_truncated: false, next_page_parameters: {} }, ] - // Act & Assert expect(isBucketListInitiation(data, ['folder1'], '')).toBe(false) }) it('should return false when data items have no bucket', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: '', files: [{ id: '1', name: 'file.txt', size: 1024, type: 'file' }], is_truncated: false, next_page_parameters: {} }, ] - // Act & Assert expect(isBucketListInitiation(data, [], '')).toBe(false) }) it('should return true for multiple buckets with no prefix and bucket', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} }, { bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} }, ] - // Act & Assert expect(isBucketListInitiation(data, [], '')).toBe(true) }) it('should return true for single bucket with no files, no prefix, and no bucket', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', files: [], is_truncated: false, next_page_parameters: {} }, ] - // Act & Assert expect(isBucketListInitiation(data, [], '')).toBe(true) }) it('should return false for single bucket with files', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', files: [{ id: '1', name: 'file.txt', size: 1024, type: 'file' }], is_truncated: false, next_page_parameters: {} }, ] - // Act & Assert expect(isBucketListInitiation(data, [], '')).toBe(false) }) it('should return false for empty data array', () => { - // Arrange const data: OnlineDriveData[] = [] - // Act & Assert expect(isBucketListInitiation(data, [], '')).toBe(false) }) }) - // ========================================== // convertOnlineDriveData Tests - // ========================================== describe('convertOnlineDriveData', () => { describe('Empty data handling', () => { it('should return empty result for empty data array', () => { - // Arrange const data: OnlineDriveData[] = [] - // Act const result = convertOnlineDriveData(data, [], '') - // Assert expect(result).toEqual({ fileList: [], isTruncated: false, @@ -1566,17 +1311,14 @@ describe('utils', () => { describe('Bucket list initiation', () => { it('should convert multiple buckets to bucket file list', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} }, { bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} }, { bucket: 'bucket-3', files: [], is_truncated: false, next_page_parameters: {} }, ] - // Act const result = convertOnlineDriveData(data, [], '') - // Assert expect(result.fileList).toHaveLength(3) expect(result.fileList[0]).toEqual({ id: 'bucket-1', @@ -1599,15 +1341,12 @@ describe('utils', () => { }) it('should convert single bucket with no files to bucket list', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', files: [], is_truncated: false, next_page_parameters: {} }, ] - // Act const result = convertOnlineDriveData(data, [], '') - // Assert expect(result.fileList).toHaveLength(1) expect(result.fileList[0]).toEqual({ id: 'my-bucket', @@ -1620,7 +1359,6 @@ describe('utils', () => { describe('File list conversion', () => { it('should convert files correctly', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1633,10 +1371,8 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, ['folder1'], 'my-bucket') - // Assert expect(result.fileList).toHaveLength(2) expect(result.fileList[0]).toEqual({ id: 'file-1', @@ -1654,7 +1390,6 @@ describe('utils', () => { }) it('should convert folders correctly without size', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1667,10 +1402,8 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.fileList).toHaveLength(2) expect(result.fileList[0]).toEqual({ id: 'folder-1', @@ -1687,7 +1420,6 @@ describe('utils', () => { }) it('should handle mixed files and folders', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1702,10 +1434,8 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.fileList).toHaveLength(4) expect(result.fileList[0].type).toBe(OnlineDriveFileType.folder) expect(result.fileList[1].type).toBe(OnlineDriveFileType.file) @@ -1716,7 +1446,6 @@ describe('utils', () => { describe('Truncation and pagination', () => { it('should return isTruncated true when data is truncated', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1726,16 +1455,13 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.isTruncated).toBe(true) expect(result.nextPageParameters).toEqual({ cursor: 'next-cursor' }) }) it('should return isTruncated false when not truncated', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1745,29 +1471,24 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.isTruncated).toBe(false) expect(result.nextPageParameters).toEqual({}) }) it('should handle undefined is_truncated', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', files: [{ id: 'file-1', name: 'file.txt', size: 1024, type: 'file' }], - is_truncated: undefined as any, - next_page_parameters: undefined as any, + is_truncated: undefined as unknown as boolean, + next_page_parameters: undefined as unknown as Record<string, unknown>, }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.isTruncated).toBe(false) expect(result.nextPageParameters).toEqual({}) }) @@ -1775,7 +1496,6 @@ describe('utils', () => { describe('hasBucket flag', () => { it('should return hasBucket true when bucket exists in data', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1785,15 +1505,12 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.hasBucket).toBe(true) }) it('should return hasBucket false when bucket is empty in data', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: '', @@ -1803,17 +1520,14 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], '') - // Assert expect(result.hasBucket).toBe(false) }) }) describe('Edge cases', () => { it('should handle files with zero size', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1823,15 +1537,12 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.fileList[0].size).toBe(0) }) it('should handle files with very large size', () => { - // Arrange const largeSize = Number.MAX_SAFE_INTEGER const data: OnlineDriveData[] = [ { @@ -1842,15 +1553,12 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.fileList[0].size).toBe(largeSize) }) it('should handle files with special characters in name', () => { - // Arrange const data: OnlineDriveData[] = [ { bucket: 'my-bucket', @@ -1864,17 +1572,14 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.fileList[0].name).toBe('file[1] (copy).txt') expect(result.fileList[1].name).toBe('doc-with-dash_and_underscore.pdf') expect(result.fileList[2].name).toBe('file with spaces.txt') }) it('should handle complex next_page_parameters', () => { - // Arrange const complexParams = { cursor: 'abc123', page: 2, @@ -1890,10 +1595,8 @@ describe('utils', () => { }, ] - // Act const result = convertOnlineDriveData(data, [], 'my-bucket') - // Assert expect(result.nextPageParameters).toEqual(complexParams) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/utils.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/utils.spec.ts new file mode 100644 index 0000000000..7c5761be8a --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/utils.spec.ts @@ -0,0 +1,105 @@ +import type { OnlineDriveData } from '@/types/pipeline' +import { describe, expect, it } from 'vitest' +import { OnlineDriveFileType } from '@/models/pipeline' +import { convertOnlineDriveData, isBucketListInitiation, isFile } from '../utils' + +describe('online-drive utils', () => { + describe('isFile', () => { + it('should return true for file type', () => { + expect(isFile('file')).toBe(true) + }) + + it('should return false for folder type', () => { + expect(isFile('folder')).toBe(false) + }) + }) + + describe('isBucketListInitiation', () => { + it('should return true when data has buckets and no prefix/bucket set', () => { + const data = [ + { bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} }, + { bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} }, + ] as OnlineDriveData[] + + expect(isBucketListInitiation(data, [], '')).toBe(true) + }) + + it('should return false when bucket is already set', () => { + const data = [ + { bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} }, + ] as OnlineDriveData[] + + expect(isBucketListInitiation(data, [], 'bucket-1')).toBe(false) + }) + + it('should return false when prefix is set', () => { + const data = [ + { bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} }, + ] as OnlineDriveData[] + + expect(isBucketListInitiation(data, ['folder/'], '')).toBe(false) + }) + + it('should return false when single bucket has files', () => { + const data = [ + { + bucket: 'bucket-1', + files: [{ id: 'f1', name: 'test.txt', size: 100, type: 'file' as const }], + is_truncated: false, + next_page_parameters: {}, + }, + ] as OnlineDriveData[] + + expect(isBucketListInitiation(data, [], '')).toBe(false) + }) + }) + + describe('convertOnlineDriveData', () => { + it('should return empty result for empty data', () => { + const result = convertOnlineDriveData([], [], '') + expect(result.fileList).toEqual([]) + expect(result.isTruncated).toBe(false) + expect(result.hasBucket).toBe(false) + }) + + it('should convert bucket list initiation to bucket items', () => { + const data = [ + { bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} }, + { bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} }, + ] as OnlineDriveData[] + + const result = convertOnlineDriveData(data, [], '') + expect(result.fileList).toHaveLength(2) + expect(result.fileList[0]).toEqual({ + id: 'bucket-1', + name: 'bucket-1', + type: OnlineDriveFileType.bucket, + }) + expect(result.hasBucket).toBe(true) + }) + + it('should convert files when not bucket list', () => { + const data = [ + { + bucket: 'bucket-1', + files: [ + { id: 'f1', name: 'test.txt', size: 100, type: 'file' as const }, + { id: 'f2', name: 'folder', size: 0, type: 'folder' as const }, + ], + is_truncated: true, + next_page_parameters: { token: 'next' }, + }, + ] as OnlineDriveData[] + + const result = convertOnlineDriveData(data, [], 'bucket-1') + expect(result.fileList).toHaveLength(2) + expect(result.fileList[0].type).toBe(OnlineDriveFileType.file) + expect(result.fileList[0].size).toBe(100) + expect(result.fileList[1].type).toBe(OnlineDriveFileType.folder) + expect(result.fileList[1].size).toBeUndefined() + expect(result.isTruncated).toBe(true) + expect(result.nextPageParameters).toEqual({ token: 'next' }) + expect(result.hasBucket).toBe(true) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/__tests__/index.spec.tsx similarity index 84% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/__tests__/index.spec.tsx index 174c626243..ce644a8a54 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/__tests__/index.spec.tsx @@ -1,22 +1,13 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' import { fireEvent, render, screen } from '@testing-library/react' -import Connect from './index' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import Connect from '../index' // Mock useToolIcon - hook has complex dependencies (API calls, stores) const mockUseToolIcon = vi.fn() vi.mock('@/app/components/workflow/hooks', () => ({ - useToolIcon: (data: any) => mockUseToolIcon(data), + useToolIcon: (data: DataSourceNodeType) => mockUseToolIcon(data), })) -// ========================================== -// Test Data Builders -// ========================================== const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({ title: 'Test Node', plugin_id: 'plugin-123', @@ -37,9 +28,6 @@ const createDefaultProps = (overrides?: Partial<ConnectProps>): ConnectProps => ...overrides, }) -// ========================================== -// Test Suites -// ========================================== describe('Connect', () => { beforeEach(() => { vi.clearAllMocks() @@ -48,15 +36,10 @@ describe('Connect', () => { mockUseToolIcon.mockReturnValue('https://example.com/icon.png') }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Component should render with connect button @@ -64,10 +47,8 @@ describe('Connect', () => { }) it('should render the BlockIcon component', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Connect {...props} />) // Assert - BlockIcon container should exist @@ -76,12 +57,10 @@ describe('Connect', () => { }) it('should render the not connected message with node title', () => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title: 'My Google Drive' }), }) - // Act render(<Connect {...props} />) // Assert - Should show translation key with interpolated name (use getAllBy since both messages contain similar text) @@ -90,10 +69,8 @@ describe('Connect', () => { }) it('should render the not connected tip message', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Should show tip translation key @@ -101,10 +78,8 @@ describe('Connect', () => { }) it('should render the connect button with correct text', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Button should have connect text @@ -113,10 +88,8 @@ describe('Connect', () => { }) it('should render with primary button variant', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Button should be primary variant @@ -125,10 +98,8 @@ describe('Connect', () => { }) it('should render Icon3Dots component', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Connect {...props} />) // Assert - Icon3Dots should be rendered (it's an SVG element) @@ -137,10 +108,8 @@ describe('Connect', () => { }) it('should apply correct container styling', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Connect {...props} />) // Assert - Container should have expected classes @@ -149,30 +118,22 @@ describe('Connect', () => { }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('nodeData prop', () => { it('should pass nodeData to useToolIcon hook', () => { - // Arrange const nodeData = createMockNodeData({ plugin_id: 'my-plugin' }) const props = createDefaultProps({ nodeData }) - // Act render(<Connect {...props} />) - // Assert expect(mockUseToolIcon).toHaveBeenCalledWith(nodeData) }) it('should display node title in not connected message', () => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title: 'Dropbox Storage' }), }) - // Act render(<Connect {...props} />) // Assert - Translation key should be in document (mock returns key) @@ -181,12 +142,10 @@ describe('Connect', () => { }) it('should display node title in tip message', () => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title: 'OneDrive Connector' }), }) - // Act render(<Connect {...props} />) // Assert - Translation key should be in document @@ -200,12 +159,10 @@ describe('Connect', () => { { title: 'Amazon S3' }, { title: '' }, ])('should handle nodeData with title=$title', ({ title }) => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title }), }) - // Act render(<Connect {...props} />) // Assert - Should render without error @@ -215,24 +172,19 @@ describe('Connect', () => { describe('onSetting prop', () => { it('should call onSetting when connect button is clicked', () => { - // Arrange const mockOnSetting = vi.fn() const props = createDefaultProps({ onSetting: mockOnSetting }) - // Act render(<Connect {...props} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSetting).toHaveBeenCalledTimes(1) }) it('should call onSetting when button clicked', () => { - // Arrange const mockOnSetting = vi.fn() const props = createDefaultProps({ onSetting: mockOnSetting }) - // Act render(<Connect {...props} />) fireEvent.click(screen.getByRole('button')) @@ -242,60 +194,47 @@ describe('Connect', () => { }) it('should call onSetting on each button click', () => { - // Arrange const mockOnSetting = vi.fn() const props = createDefaultProps({ onSetting: mockOnSetting }) - // Act render(<Connect {...props} />) const button = screen.getByRole('button') fireEvent.click(button) fireEvent.click(button) fireEvent.click(button) - // Assert expect(mockOnSetting).toHaveBeenCalledTimes(3) }) }) }) - // ========================================== // User Interactions and Event Handlers - // ========================================== describe('User Interactions', () => { describe('Connect Button', () => { it('should trigger onSetting callback on click', () => { - // Arrange const mockOnSetting = vi.fn() const props = createDefaultProps({ onSetting: mockOnSetting }) render(<Connect {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSetting).toHaveBeenCalled() }) it('should be interactive and focusable', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) const button = screen.getByRole('button') - // Assert expect(button).not.toBeDisabled() }) it('should handle keyboard interaction (Enter key)', () => { - // Arrange const mockOnSetting = vi.fn() const props = createDefaultProps({ onSetting: mockOnSetting }) render(<Connect {...props} />) - // Act const button = screen.getByRole('button') fireEvent.keyDown(button, { key: 'Enter' }) @@ -305,29 +244,22 @@ describe('Connect', () => { }) }) - // ========================================== // Hook Integration Tests - // ========================================== describe('Hook Integration', () => { describe('useToolIcon', () => { it('should call useToolIcon with nodeData', () => { - // Arrange const nodeData = createMockNodeData() const props = createDefaultProps({ nodeData }) - // Act render(<Connect {...props} />) - // Assert expect(mockUseToolIcon).toHaveBeenCalledWith(nodeData) }) it('should use toolIcon result from useToolIcon', () => { - // Arrange mockUseToolIcon.mockReturnValue('custom-icon-url') const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - The hook should be called and its return value used @@ -335,11 +267,9 @@ describe('Connect', () => { }) it('should handle empty string icon', () => { - // Arrange mockUseToolIcon.mockReturnValue('') const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Should still render without crashing @@ -347,11 +277,9 @@ describe('Connect', () => { }) it('should handle undefined icon', () => { - // Arrange mockUseToolIcon.mockReturnValue(undefined) const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Should still render without crashing @@ -361,10 +289,8 @@ describe('Connect', () => { describe('useTranslation', () => { it('should use correct translation keys for not connected message', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Should use the correct translation key (both notConnected and notConnectedTip contain similar pattern) @@ -373,49 +299,36 @@ describe('Connect', () => { }) it('should use correct translation key for tip message', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) - // Assert expect(screen.getByText(/datasetPipeline\.onlineDrive\.notConnectedTip/)).toBeInTheDocument() }) it('should use correct translation key for connect button', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toHaveTextContent('datasetCreation.stepOne.connect') }) }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { describe('Empty/Null Values', () => { it('should handle empty title in nodeData', () => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title: '' }), }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should handle undefined optional fields in nodeData', () => { - // Arrange const minimalNodeData = { title: 'Test', plugin_id: 'test', @@ -428,35 +341,28 @@ describe('Connect', () => { } as DataSourceNodeType const props = createDefaultProps({ nodeData: minimalNodeData }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should handle empty plugin_id', () => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ plugin_id: '' }), }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) }) describe('Special Characters', () => { it('should handle special characters in title', () => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title: 'Drive <script>alert("xss")</script>' }), }) - // Act render(<Connect {...props} />) // Assert - Should render safely without executing script @@ -464,75 +370,57 @@ describe('Connect', () => { }) it('should handle unicode characters in title', () => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title: '云盘存储 🌐' }), }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should handle very long title', () => { - // Arrange const longTitle = 'A'.repeat(500) const props = createDefaultProps({ nodeData: createMockNodeData({ title: longTitle }), }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) }) describe('Icon Variations', () => { it('should handle string icon URL', () => { - // Arrange mockUseToolIcon.mockReturnValue('https://cdn.example.com/icon.png') const props = createDefaultProps() - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should handle object icon with url property', () => { - // Arrange mockUseToolIcon.mockReturnValue({ url: 'https://cdn.example.com/icon.png' }) const props = createDefaultProps() - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should handle null icon', () => { - // Arrange mockUseToolIcon.mockReturnValue(null) const props = createDefaultProps() - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('Prop Variations', () => { it.each([ { title: 'Google Drive', plugin_id: 'google-drive' }, @@ -541,15 +429,12 @@ describe('Connect', () => { { title: 'Amazon S3', plugin_id: 's3' }, { title: 'Box', plugin_id: 'box' }, ])('should render correctly with title=$title and plugin_id=$plugin_id', ({ title, plugin_id }) => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ title, plugin_id }), }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() expect(mockUseToolIcon).toHaveBeenCalledWith( expect.objectContaining({ title, plugin_id }), @@ -561,15 +446,12 @@ describe('Connect', () => { { provider_type: 'cloud_storage' }, { provider_type: 'file_system' }, ])('should render correctly with provider_type=$provider_type', ({ provider_type }) => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ provider_type }), }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) @@ -579,28 +461,20 @@ describe('Connect', () => { { datasource_label: '' }, { datasource_label: 'S3 Bucket' }, ])('should render correctly with datasource_label=$datasource_label', ({ datasource_label }) => { - // Arrange const props = createDefaultProps({ nodeData: createMockNodeData({ datasource_label }), }) - // Act render(<Connect {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) }) - // ========================================== - // Accessibility Tests - // ========================================== describe('Accessibility', () => { it('should have an accessible button', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Button should be accessible by role @@ -608,10 +482,8 @@ describe('Connect', () => { }) it('should have proper text content for screen readers', () => { - // Arrange const props = createDefaultProps() - // Act render(<Connect {...props} />) // Assert - Text content should be present diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx index 2ad62aae8e..c441709ec2 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/__tests__/index.spec.tsx @@ -2,18 +2,13 @@ import type { OnlineDriveFile } from '@/models/pipeline' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { OnlineDriveFileType } from '@/models/pipeline' -import FileList from './index' +import FileList from '../index' -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts - -// Mock ahooks useDebounceFn - third-party library requires mocking +// Mock ahooks useDebounceFn: required because tests verify the debounced +// callback is invoked with specific arguments (mockDebounceFnRun assertions). const mockDebounceFnRun = vi.fn() vi.mock('ahooks', () => ({ - useDebounceFn: (fn: (...args: any[]) => void) => { + useDebounceFn: (fn: (...args: unknown[]) => void) => { mockDebounceFnRun.mockImplementation(fn) return { run: mockDebounceFnRun } }, @@ -35,14 +30,11 @@ const mockStoreState = { const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } -vi.mock('../../store', () => ({ +vi.mock('../../../store', () => ({ useDataSourceStore: () => mockDataSourceStore, - useDataSourceStoreWithSelector: (selector: (s: any) => any) => selector(mockStoreState), + useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState), })) -// ========================================== -// Test Data Builders -// ========================================== const createMockOnlineDriveFile = (overrides?: Partial<OnlineDriveFile>): OnlineDriveFile => ({ id: 'file-1', name: 'test-file.txt', @@ -70,9 +62,6 @@ const createDefaultProps = (overrides?: Partial<FileListProps>): FileListProps = ...overrides, }) -// ========================================== -// Helper Functions -// ========================================== const resetMockStoreState = () => { mockStoreState.setNextPageParameters = vi.fn() mockStoreState.currentNextPageParametersRef = { current: {} } @@ -85,9 +74,6 @@ const resetMockStoreState = () => { mockStoreState.setBucket = vi.fn() } -// ========================================== -// Test Suites -// ========================================== describe('FileList', () => { beforeEach(() => { vi.clearAllMocks() @@ -95,15 +81,10 @@ describe('FileList', () => { mockDebounceFnRun.mockClear() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<FileList {...props} />) // Assert - search input should be visible @@ -111,13 +92,10 @@ describe('FileList', () => { }) it('should render with correct container styles', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<FileList {...props} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('h-[400px]') @@ -127,38 +105,30 @@ describe('FileList', () => { }) it('should render Header component with search input', () => { - // Arrange const props = createDefaultProps() - // Act render(<FileList {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toBeInTheDocument() }) it('should render files when fileList has items', () => { - // Arrange const fileList = [ createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' }), createMockOnlineDriveFile({ id: 'file-2', name: 'file2.txt' }), ] const props = createDefaultProps({ fileList }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText('file1.txt')).toBeInTheDocument() expect(screen.getByText('file2.txt')).toBeInTheDocument() }) it('should show loading state when isLoading is true and fileList is empty', () => { - // Arrange const props = createDefaultProps({ isLoading: true, fileList: [] }) - // Act const { container } = render(<FileList {...props} />) // Assert - Loading component should be rendered with spin-animation class @@ -166,35 +136,25 @@ describe('FileList', () => { }) it('should show empty folder state when not loading and fileList is empty', () => { - // Arrange const props = createDefaultProps({ isLoading: false, fileList: [], keywords: '' }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText('datasetPipeline.onlineDrive.emptyFolder')).toBeInTheDocument() }) it('should show empty search result when not loading, fileList is empty, and keywords exist', () => { - // Arrange const props = createDefaultProps({ isLoading: false, fileList: [], keywords: 'search-term' }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText('datasetPipeline.onlineDrive.emptySearchResult')).toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('fileList prop', () => { it('should render all files from fileList', () => { - // Arrange const fileList = [ createMockOnlineDriveFile({ id: '1', name: 'a.txt' }), createMockOnlineDriveFile({ id: '2', name: 'b.txt' }), @@ -202,20 +162,16 @@ describe('FileList', () => { ] const props = createDefaultProps({ fileList }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText('a.txt')).toBeInTheDocument() expect(screen.getByText('b.txt')).toBeInTheDocument() expect(screen.getByText('c.txt')).toBeInTheDocument() }) it('should handle empty fileList', () => { - // Arrange const props = createDefaultProps({ fileList: [] }) - // Act render(<FileList {...props} />) // Assert - Should show empty folder state @@ -225,14 +181,12 @@ describe('FileList', () => { describe('selectedFileIds prop', () => { it('should mark files as selected based on selectedFileIds', () => { - // Arrange const fileList = [ createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' }), createMockOnlineDriveFile({ id: 'file-2', name: 'file2.txt' }), ] const props = createDefaultProps({ fileList, selectedFileIds: ['file-1'] }) - // Act render(<FileList {...props} />) // Assert - The checkbox for file-1 should be checked (check icon present) @@ -245,13 +199,10 @@ describe('FileList', () => { describe('keywords prop', () => { it('should initialize input with keywords value', () => { - // Arrange const props = createDefaultProps({ keywords: 'my-search' }) - // Act render(<FileList {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue('my-search') }) @@ -259,10 +210,8 @@ describe('FileList', () => { describe('isLoading prop', () => { it('should show loading when isLoading is true with empty list', () => { - // Arrange const props = createDefaultProps({ isLoading: true, fileList: [] }) - // Act const { container } = render(<FileList {...props} />) // Assert - Loading component with spin-animation class @@ -270,11 +219,9 @@ describe('FileList', () => { }) it('should show loading indicator at bottom when isLoading is true with files', () => { - // Arrange const fileList = [createMockOnlineDriveFile()] const props = createDefaultProps({ isLoading: true, fileList }) - // Act const { container } = render(<FileList {...props} />) // Assert - Should show spinner icon at the bottom @@ -284,11 +231,9 @@ describe('FileList', () => { describe('supportBatchUpload prop', () => { it('should render checkboxes when supportBatchUpload is true', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' })] const props = createDefaultProps({ fileList, supportBatchUpload: true }) - // Act render(<FileList {...props} />) // Assert - Checkbox component has data-testid="checkbox-{id}" @@ -296,11 +241,9 @@ describe('FileList', () => { }) it('should render radio buttons when supportBatchUpload is false', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' })] const props = createDefaultProps({ fileList, supportBatchUpload: false }) - // Act const { container } = render(<FileList {...props} />) // Assert - Radio is rendered as a div with rounded-full class @@ -311,99 +254,76 @@ describe('FileList', () => { }) }) - // ========================================== - // State Management Tests - // ========================================== describe('State Management', () => { describe('inputValue state', () => { it('should initialize inputValue with keywords prop', () => { - // Arrange const props = createDefaultProps({ keywords: 'initial-keyword' }) - // Act render(<FileList {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue('initial-keyword') }) it('should update inputValue when input changes', () => { - // Arrange const props = createDefaultProps({ keywords: '' }) render(<FileList {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'new-value' } }) - // Assert expect(input).toHaveValue('new-value') }) }) describe('debounced keywords update', () => { it('should call updateKeywords with debounce when input changes', () => { - // Arrange const mockUpdateKeywords = vi.fn() const props = createDefaultProps({ updateKeywords: mockUpdateKeywords }) render(<FileList {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'debounced-value' } }) - // Assert expect(mockDebounceFnRun).toHaveBeenCalledWith('debounced-value') }) }) }) - // ========================================== // Event Handlers Tests - // ========================================== describe('Event Handlers', () => { describe('handleInputChange', () => { it('should update inputValue on input change', () => { - // Arrange const props = createDefaultProps() render(<FileList {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'typed-text' } }) - // Assert expect(input).toHaveValue('typed-text') }) it('should trigger debounced updateKeywords on input change', () => { - // Arrange const mockUpdateKeywords = vi.fn() const props = createDefaultProps({ updateKeywords: mockUpdateKeywords }) render(<FileList {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'search-term' } }) - // Assert expect(mockDebounceFnRun).toHaveBeenCalledWith('search-term') }) it('should handle multiple sequential input changes', () => { - // Arrange const mockUpdateKeywords = vi.fn() const props = createDefaultProps({ updateKeywords: mockUpdateKeywords }) render(<FileList {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'a' } }) fireEvent.change(input, { target: { value: 'ab' } }) fireEvent.change(input, { target: { value: 'abc' } }) - // Assert expect(mockDebounceFnRun).toHaveBeenCalledTimes(3) expect(mockDebounceFnRun).toHaveBeenLastCalledWith('abc') expect(input).toHaveValue('abc') @@ -412,7 +332,6 @@ describe('FileList', () => { describe('handleResetKeywords', () => { it('should call resetKeywords prop when clear button is clicked', () => { - // Arrange const mockResetKeywords = vi.fn() const props = createDefaultProps({ resetKeywords: mockResetKeywords, keywords: 'to-reset' }) const { container } = render(<FileList {...props} />) @@ -422,12 +341,10 @@ describe('FileList', () => { expect(clearButton).toBeInTheDocument() fireEvent.click(clearButton!) - // Assert expect(mockResetKeywords).toHaveBeenCalledTimes(1) }) it('should reset inputValue to empty string when clear is clicked', () => { - // Arrange const props = createDefaultProps({ keywords: 'to-be-reset' }) const { container } = render(<FileList {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') @@ -438,14 +355,12 @@ describe('FileList', () => { expect(clearButton).toBeInTheDocument() fireEvent.click(clearButton!) - // Assert expect(input).toHaveValue('') }) }) describe('handleSelectFile', () => { it('should call handleSelectFile when file item is clicked', () => { - // Arrange const mockHandleSelectFile = vi.fn() const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt' })] const props = createDefaultProps({ handleSelectFile: mockHandleSelectFile, fileList }) @@ -455,7 +370,6 @@ describe('FileList', () => { const fileItem = screen.getByText('test.txt') fireEvent.click(fileItem.closest('[class*="cursor-pointer"]')!) - // Assert expect(mockHandleSelectFile).toHaveBeenCalledWith(expect.objectContaining({ id: 'file-1', name: 'test.txt', @@ -466,7 +380,6 @@ describe('FileList', () => { describe('handleOpenFolder', () => { it('should call handleOpenFolder when folder item is clicked', () => { - // Arrange const mockHandleOpenFolder = vi.fn() const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })] const props = createDefaultProps({ handleOpenFolder: mockHandleOpenFolder, fileList }) @@ -476,7 +389,6 @@ describe('FileList', () => { const folderItem = screen.getByText('my-folder') fireEvent.click(folderItem.closest('[class*="cursor-pointer"]')!) - // Assert expect(mockHandleOpenFolder).toHaveBeenCalledWith(expect.objectContaining({ id: 'folder-1', name: 'my-folder', @@ -486,68 +398,51 @@ describe('FileList', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle empty string keywords', () => { - // Arrange const props = createDefaultProps({ keywords: '' }) - // Act render(<FileList {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue('') }) it('should handle special characters in keywords', () => { - // Arrange const specialChars = 'test[file].txt (copy)' const props = createDefaultProps({ keywords: specialChars }) - // Act render(<FileList {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue(specialChars) }) it('should handle unicode characters in keywords', () => { - // Arrange const unicodeKeywords = '文件搜索 日本語' const props = createDefaultProps({ keywords: unicodeKeywords }) - // Act render(<FileList {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue(unicodeKeywords) }) it('should handle very long file names in fileList', () => { - // Arrange const longName = `${'a'.repeat(100)}.txt` const fileList = [createMockOnlineDriveFile({ id: '1', name: longName })] const props = createDefaultProps({ fileList }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText(longName)).toBeInTheDocument() }) it('should handle large number of files', () => { - // Arrange const fileList = Array.from({ length: 50 }, (_, i) => createMockOnlineDriveFile({ id: `file-${i}`, name: `file-${i}.txt` })) const props = createDefaultProps({ fileList }) - // Act render(<FileList {...props} />) // Assert - Check a few files exist @@ -556,23 +451,17 @@ describe('FileList', () => { }) it('should handle whitespace-only keywords input', () => { - // Arrange const props = createDefaultProps() render(<FileList {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: ' ' } }) - // Assert expect(input).toHaveValue(' ') expect(mockDebounceFnRun).toHaveBeenCalledWith(' ') }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('Prop Variations', () => { it.each([ { isInPipeline: true, supportBatchUpload: true }, @@ -580,10 +469,8 @@ describe('FileList', () => { { isInPipeline: false, supportBatchUpload: true }, { isInPipeline: false, supportBatchUpload: false }, ])('should render correctly with isInPipeline=$isInPipeline and supportBatchUpload=$supportBatchUpload', (propVariation) => { - // Arrange const props = createDefaultProps(propVariation) - // Act render(<FileList {...props} />) // Assert - Component should render without crashing @@ -595,15 +482,12 @@ describe('FileList', () => { { isLoading: false, fileCount: 0, description: 'not loading with no files' }, { isLoading: false, fileCount: 3, description: 'not loading with files' }, ])('should handle $description correctly', ({ isLoading, fileCount }) => { - // Arrange const fileList = Array.from({ length: fileCount }, (_, i) => createMockOnlineDriveFile({ id: `file-${i}`, name: `file-${i}.txt` })) const props = createDefaultProps({ isLoading, fileList }) - // Act const { container } = render(<FileList {...props} />) - // Assert if (isLoading && fileCount === 0) expect(container.querySelector('.spin-animation')).toBeInTheDocument() @@ -619,66 +503,50 @@ describe('FileList', () => { { keywords: 'test', searchResultsLength: 5 }, { keywords: 'not-found', searchResultsLength: 0 }, ])('should render correctly with keywords="$keywords" and searchResultsLength=$searchResultsLength', ({ keywords, searchResultsLength }) => { - // Arrange const props = createDefaultProps({ keywords, searchResultsLength }) - // Act render(<FileList {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue(keywords) }) }) - // ========================================== // File Type Variations - // ========================================== describe('File Type Variations', () => { it('should render folder type correctly', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })] const props = createDefaultProps({ fileList }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText('my-folder')).toBeInTheDocument() }) it('should render bucket type correctly', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket })] const props = createDefaultProps({ fileList }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText('my-bucket')).toBeInTheDocument() }) it('should render file with size', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt', size: 1024 })] const props = createDefaultProps({ fileList }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText('test.txt')).toBeInTheDocument() // formatFileSize returns '1.00 KB' for 1024 bytes expect(screen.getByText('1.00 KB')).toBeInTheDocument() }) it('should not show checkbox for bucket type', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket })] const props = createDefaultProps({ fileList, supportBatchUpload: true }) - // Act render(<FileList {...props} />) // Assert - No checkbox should be rendered for bucket @@ -686,32 +554,24 @@ describe('FileList', () => { }) }) - // ========================================== // Search Results Display - // ========================================== describe('Search Results Display', () => { it('should show search results count when keywords and results exist', () => { - // Arrange const props = createDefaultProps({ keywords: 'test', searchResultsLength: 5, breadcrumbs: ['folder1'], }) - // Act render(<FileList {...props} />) - // Assert expect(screen.getByText(/datasetPipeline\.onlineDrive\.breadcrumbs\.searchResult/)).toBeInTheDocument() }) }) - // ========================================== // Callback Stability - // ========================================== describe('Callback Stability', () => { it('should maintain stable handleSelectFile callback', () => { - // Arrange const mockHandleSelectFile = vi.fn() const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt' })] const props = createDefaultProps({ handleSelectFile: mockHandleSelectFile, fileList }) @@ -724,15 +584,12 @@ describe('FileList', () => { // Rerender with same props rerender(<FileList {...props} />) - // Click again fireEvent.click(fileItem.closest('[class*="cursor-pointer"]')!) - // Assert expect(mockHandleSelectFile).toHaveBeenCalledTimes(2) }) it('should maintain stable handleOpenFolder callback', () => { - // Arrange const mockHandleOpenFolder = vi.fn() const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })] const props = createDefaultProps({ handleOpenFolder: mockHandleOpenFolder, fileList }) @@ -745,10 +602,8 @@ describe('FileList', () => { // Rerender with same props rerender(<FileList {...props} />) - // Click again fireEvent.click(folderItem.closest('[class*="cursor-pointer"]')!) - // Assert expect(mockHandleOpenFolder).toHaveBeenCalledTimes(2) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx similarity index 87% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx index 3c836465b8..ef94fd3dc8 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/__tests__/index.spec.tsx @@ -1,12 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import Header from './index' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import Header from '../index' // Mock store - required by Breadcrumbs component const mockStoreState = { @@ -23,14 +17,11 @@ const mockStoreState = { const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } -vi.mock('../../../store', () => ({ +vi.mock('../../../../store', () => ({ useDataSourceStore: () => mockDataSourceStore, useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState), })) -// ========================================== -// Test Data Builders -// ========================================== type HeaderProps = React.ComponentProps<typeof Header> const createDefaultProps = (overrides?: Partial<HeaderProps>): HeaderProps => ({ @@ -45,9 +36,6 @@ const createDefaultProps = (overrides?: Partial<HeaderProps>): HeaderProps => ({ ...overrides, }) -// ========================================== -// Helper Functions -// ========================================== const resetMockStoreState = () => { mockStoreState.hasBucket = false mockStoreState.setOnlineDriveFileList = vi.fn() @@ -59,24 +47,16 @@ const resetMockStoreState = () => { mockStoreState.prefix = [] } -// ========================================== -// Test Suites -// ========================================== describe('Header', () => { beforeEach(() => { vi.clearAllMocks() resetMockStoreState() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<Header {...props} />) // Assert - search input should be visible @@ -84,10 +64,8 @@ describe('Header', () => { }) it('should render with correct container styles', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Header {...props} />) // Assert - container should have correct class names @@ -101,23 +79,18 @@ describe('Header', () => { }) it('should render Input component with correct props', () => { - // Arrange const props = createDefaultProps({ inputValue: 'test-value' }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toBeInTheDocument() expect(input).toHaveValue('test-value') }) it('should render Input with search icon', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Header {...props} />) // Assert - Input should have search icon (RiSearchLine is rendered as svg) @@ -126,10 +99,8 @@ describe('Header', () => { }) it('should render Input with correct wrapper width', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Header {...props} />) // Assert - Input wrapper should have w-[200px] class @@ -138,57 +109,42 @@ describe('Header', () => { }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('inputValue prop', () => { it('should display empty input when inputValue is empty string', () => { - // Arrange const props = createDefaultProps({ inputValue: '' }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue('') }) it('should display input value correctly', () => { - // Arrange const props = createDefaultProps({ inputValue: 'search-query' }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue('search-query') }) it('should handle special characters in inputValue', () => { - // Arrange const specialChars = 'test[file].txt (copy)' const props = createDefaultProps({ inputValue: specialChars }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue(specialChars) }) it('should handle unicode characters in inputValue', () => { - // Arrange const unicodeValue = '文件搜索 日本語' const props = createDefaultProps({ inputValue: unicodeValue }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue(unicodeValue) }) @@ -196,10 +152,8 @@ describe('Header', () => { describe('breadcrumbs prop', () => { it('should render with empty breadcrumbs', () => { - // Arrange const props = createDefaultProps({ breadcrumbs: [] }) - // Act render(<Header {...props} />) // Assert - Component should render without errors @@ -207,34 +161,26 @@ describe('Header', () => { }) it('should render with single breadcrumb', () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder1'] }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should render with multiple breadcrumbs', () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3'] }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) }) describe('keywords prop', () => { it('should pass keywords to Breadcrumbs', () => { - // Arrange const props = createDefaultProps({ keywords: 'search-keyword' }) - // Act render(<Header {...props} />) // Assert - keywords are passed through, component renders @@ -244,45 +190,34 @@ describe('Header', () => { describe('bucket prop', () => { it('should render with empty bucket', () => { - // Arrange const props = createDefaultProps({ bucket: '' }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should render with bucket value', () => { - // Arrange const props = createDefaultProps({ bucket: 'my-bucket' }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) }) describe('searchResultsLength prop', () => { it('should handle zero search results', () => { - // Arrange const props = createDefaultProps({ searchResultsLength: 0 }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should handle positive search results', () => { - // Arrange const props = createDefaultProps({ searchResultsLength: 10, keywords: 'test' }) - // Act render(<Header {...props} />) // Assert - Breadcrumbs will show search results text when keywords exist and results > 0 @@ -290,105 +225,82 @@ describe('Header', () => { }) it('should handle large search results count', () => { - // Arrange const props = createDefaultProps({ searchResultsLength: 1000, keywords: 'test' }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) }) describe('isInPipeline prop', () => { it('should render correctly when isInPipeline is false', () => { - // Arrange const props = createDefaultProps({ isInPipeline: false }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should render correctly when isInPipeline is true', () => { - // Arrange const props = createDefaultProps({ isInPipeline: true }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) }) }) - // ========================================== // Event Handlers Tests - // ========================================== describe('Event Handlers', () => { describe('handleInputChange', () => { it('should call handleInputChange when input value changes', () => { - // Arrange const mockHandleInputChange = vi.fn() const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) render(<Header {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'new-value' } }) - // Assert expect(mockHandleInputChange).toHaveBeenCalledTimes(1) // Verify that onChange event was triggered (React's synthetic event structure) expect(mockHandleInputChange.mock.calls[0][0]).toHaveProperty('type', 'change') }) it('should call handleInputChange on each keystroke', () => { - // Arrange const mockHandleInputChange = vi.fn() const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) render(<Header {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'a' } }) fireEvent.change(input, { target: { value: 'ab' } }) fireEvent.change(input, { target: { value: 'abc' } }) - // Assert expect(mockHandleInputChange).toHaveBeenCalledTimes(3) }) it('should handle empty string input', () => { - // Arrange const mockHandleInputChange = vi.fn() const props = createDefaultProps({ inputValue: 'existing', handleInputChange: mockHandleInputChange }) render(<Header {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: '' } }) - // Assert expect(mockHandleInputChange).toHaveBeenCalledTimes(1) expect(mockHandleInputChange.mock.calls[0][0]).toHaveProperty('type', 'change') }) it('should handle whitespace-only input', () => { - // Arrange const mockHandleInputChange = vi.fn() const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) render(<Header {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: ' ' } }) - // Assert expect(mockHandleInputChange).toHaveBeenCalledTimes(1) expect(mockHandleInputChange.mock.calls[0][0]).toHaveProperty('type', 'change') }) @@ -396,7 +308,6 @@ describe('Header', () => { describe('handleResetKeywords', () => { it('should call handleResetKeywords when clear icon is clicked', () => { - // Arrange const mockHandleResetKeywords = vi.fn() const props = createDefaultProps({ inputValue: 'to-clear', @@ -409,12 +320,10 @@ describe('Header', () => { expect(clearButton).toBeInTheDocument() fireEvent.click(clearButton!) - // Assert expect(mockHandleResetKeywords).toHaveBeenCalledTimes(1) }) it('should not show clear icon when inputValue is empty', () => { - // Arrange const props = createDefaultProps({ inputValue: '' }) const { container } = render(<Header {...props} />) @@ -424,7 +333,6 @@ describe('Header', () => { }) it('should show clear icon when inputValue is not empty', () => { - // Arrange const props = createDefaultProps({ inputValue: 'some-value' }) const { container } = render(<Header {...props} />) @@ -435,9 +343,7 @@ describe('Header', () => { }) }) - // ========================================== // Component Memoization Tests - // ========================================== describe('Memoization', () => { it('should be wrapped with React.memo', () => { // Assert - Header component should be memoized @@ -445,7 +351,6 @@ describe('Header', () => { }) it('should not re-render when props are the same', () => { - // Arrange const mockHandleInputChange = vi.fn() const mockHandleResetKeywords = vi.fn() const props = createDefaultProps({ @@ -464,7 +369,6 @@ describe('Header', () => { }) it('should re-render when inputValue changes', () => { - // Arrange const props = createDefaultProps({ inputValue: 'initial' }) const { rerender } = render(<Header {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') @@ -479,7 +383,6 @@ describe('Header', () => { }) it('should re-render when breadcrumbs change', () => { - // Arrange const props = createDefaultProps({ breadcrumbs: [] }) const { rerender } = render(<Header {...props} />) @@ -492,7 +395,6 @@ describe('Header', () => { }) it('should re-render when keywords change', () => { - // Arrange const props = createDefaultProps({ keywords: '' }) const { rerender } = render(<Header {...props} />) @@ -505,78 +407,58 @@ describe('Header', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle very long inputValue', () => { - // Arrange const longValue = 'a'.repeat(500) const props = createDefaultProps({ inputValue: longValue }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue(longValue) }) it('should handle very long breadcrumb paths', () => { - // Arrange const longBreadcrumbs = Array.from({ length: 20 }, (_, i) => `folder-${i}`) const props = createDefaultProps({ breadcrumbs: longBreadcrumbs }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should handle breadcrumbs with special characters', () => { - // Arrange const specialBreadcrumbs = ['folder [1]', 'folder (2)', 'folder-3.backup'] const props = createDefaultProps({ breadcrumbs: specialBreadcrumbs }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should handle breadcrumbs with unicode names', () => { - // Arrange const unicodeBreadcrumbs = ['文件夹', 'フォルダ', 'Папка'] const props = createDefaultProps({ breadcrumbs: unicodeBreadcrumbs }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should handle bucket with special characters', () => { - // Arrange const props = createDefaultProps({ bucket: 'my-bucket_2024.backup' }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) it('should pass the event object to handleInputChange callback', () => { - // Arrange const mockHandleInputChange = vi.fn() const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) render(<Header {...props} />) const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') - // Act fireEvent.change(input, { target: { value: 'test-value' } }) // Assert - Verify the event object is passed correctly @@ -587,9 +469,6 @@ describe('Header', () => { }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('Prop Variations', () => { it.each([ { isInPipeline: true, bucket: '' }, @@ -597,13 +476,10 @@ describe('Header', () => { { isInPipeline: false, bucket: '' }, { isInPipeline: false, bucket: 'my-bucket' }, ])('should render correctly with isInPipeline=$isInPipeline and bucket=$bucket', (propVariation) => { - // Arrange const props = createDefaultProps(propVariation) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) @@ -613,13 +489,10 @@ describe('Header', () => { { keywords: 'test', searchResultsLength: 5, description: 'search with results' }, { keywords: '', searchResultsLength: 5, description: 'no keywords but has results count' }, ])('should render correctly with $description', ({ keywords, searchResultsLength }) => { - // Arrange const props = createDefaultProps({ keywords, searchResultsLength }) - // Act render(<Header {...props} />) - // Assert expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() }) @@ -629,24 +502,18 @@ describe('Header', () => { { breadcrumbs: ['a', 'b', 'c'], inputValue: '', expected: 'multiple breadcrumbs no search' }, { breadcrumbs: ['a', 'b', 'c', 'd', 'e'], inputValue: 'query', expected: 'many breadcrumbs with search' }, ])('should handle $expected correctly', ({ breadcrumbs, inputValue }) => { - // Arrange const props = createDefaultProps({ breadcrumbs, inputValue }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue(inputValue) }) }) - // ========================================== // Integration with Child Components - // ========================================== describe('Integration with Child Components', () => { it('should pass all required props to Breadcrumbs', () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2'], keywords: 'test-keyword', @@ -655,7 +522,6 @@ describe('Header', () => { isInPipeline: true, }) - // Act render(<Header {...props} />) // Assert - Component should render successfully, meaning props are passed correctly @@ -663,7 +529,6 @@ describe('Header', () => { }) it('should pass correct props to Input component', () => { - // Arrange const mockHandleInputChange = vi.fn() const mockHandleResetKeywords = vi.fn() const props = createDefaultProps({ @@ -672,10 +537,8 @@ describe('Header', () => { handleResetKeywords: mockHandleResetKeywords, }) - // Act render(<Header {...props} />) - // Assert const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') expect(input).toHaveValue('test-input') @@ -685,12 +548,9 @@ describe('Header', () => { }) }) - // ========================================== // Callback Stability Tests - // ========================================== describe('Callback Stability', () => { it('should maintain stable handleInputChange callback after rerender', () => { - // Arrange const mockHandleInputChange = vi.fn() const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) const { rerender } = render(<Header {...props} />) @@ -701,12 +561,10 @@ describe('Header', () => { rerender(<Header {...props} />) fireEvent.change(input, { target: { value: 'second' } }) - // Assert expect(mockHandleInputChange).toHaveBeenCalledTimes(2) }) it('should maintain stable handleResetKeywords callback after rerender', () => { - // Arrange const mockHandleResetKeywords = vi.fn() const props = createDefaultProps({ inputValue: 'to-clear', @@ -720,7 +578,6 @@ describe('Header', () => { rerender(<Header {...props} />) fireEvent.click(clearButton!) - // Assert expect(mockHandleResetKeywords).toHaveBeenCalledTimes(2) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx new file mode 100644 index 0000000000..c407be51ac --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Bucket from '../bucket' + +vi.mock('@/app/components/base/icons/src/public/knowledge/online-drive', () => ({ + BucketsGray: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="buckets-gray" {...props} />, +})) +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>, +})) + +describe('Bucket', () => { + const defaultProps = { + bucketName: 'my-bucket', + handleBackToBucketList: vi.fn(), + handleClickBucketName: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render bucket name', () => { + render(<Bucket {...defaultProps} />) + expect(screen.getByText('my-bucket')).toBeInTheDocument() + }) + + it('should render bucket icon', () => { + render(<Bucket {...defaultProps} />) + expect(screen.getByTestId('buckets-gray')).toBeInTheDocument() + }) + + it('should call handleBackToBucketList on icon button click', () => { + render(<Bucket {...defaultProps} />) + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) + expect(defaultProps.handleBackToBucketList).toHaveBeenCalledOnce() + }) + + it('should call handleClickBucketName on name click', () => { + render(<Bucket {...defaultProps} />) + fireEvent.click(screen.getByText('my-bucket')) + expect(defaultProps.handleClickBucketName).toHaveBeenCalledOnce() + }) + + it('should not call handleClickBucketName when disabled', () => { + render(<Bucket {...defaultProps} disabled={true} />) + fireEvent.click(screen.getByText('my-bucket')) + expect(defaultProps.handleClickBucketName).not.toHaveBeenCalled() + }) + + it('should show separator by default', () => { + render(<Bucket {...defaultProps} />) + const separators = screen.getAllByText('/') + expect(separators.length).toBeGreaterThanOrEqual(2) // One after icon, one after name + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/drive.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/drive.spec.tsx new file mode 100644 index 0000000000..ce3bab6d01 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/drive.spec.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Drive from '../drive' + +describe('Drive', () => { + const defaultProps = { + breadcrumbs: [] as string[], + handleBackToRoot: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: button text and separator visibility + describe('Rendering', () => { + it('should render "All Files" button text', () => { + render(<Drive {...defaultProps} />) + + expect(screen.getByRole('button')).toHaveTextContent('datasetPipeline.onlineDrive.breadcrumbs.allFiles') + }) + + it('should show separator "/" when breadcrumbs has items', () => { + render(<Drive {...defaultProps} breadcrumbs={['Folder A']} />) + + expect(screen.getByText('/')).toBeInTheDocument() + }) + + it('should hide separator when breadcrumbs is empty', () => { + render(<Drive {...defaultProps} breadcrumbs={[]} />) + + expect(screen.queryByText('/')).not.toBeInTheDocument() + }) + }) + + // Props: disabled state depends on breadcrumbs length + describe('Props', () => { + it('should disable button when breadcrumbs is empty', () => { + render(<Drive {...defaultProps} breadcrumbs={[]} />) + + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should enable button when breadcrumbs has items', () => { + render(<Drive {...defaultProps} breadcrumbs={['Folder A', 'Folder B']} />) + + expect(screen.getByRole('button')).not.toBeDisabled() + }) + }) + + // User interactions: clicking the root button + describe('User Interactions', () => { + it('should call handleBackToRoot on click when enabled', () => { + render(<Drive {...defaultProps} breadcrumbs={['Folder A']} />) + + fireEvent.click(screen.getByRole('button')) + + expect(defaultProps.handleBackToRoot).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/index.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/index.spec.tsx index b7e53ed1be..a6aaf3a50b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/index.spec.tsx @@ -1,12 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import Breadcrumbs from './index' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import Breadcrumbs from '../index' // Mock store - context provider requires mocking const mockStoreState = { @@ -23,14 +17,11 @@ const mockStoreState = { const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } -vi.mock('../../../../store', () => ({ +vi.mock('../../../../../store', () => ({ useDataSourceStore: () => mockDataSourceStore, useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState), })) -// ========================================== -// Test Data Builders -// ========================================== type BreadcrumbsProps = React.ComponentProps<typeof Breadcrumbs> const createDefaultProps = (overrides?: Partial<BreadcrumbsProps>): BreadcrumbsProps => ({ @@ -42,9 +33,6 @@ const createDefaultProps = (overrides?: Partial<BreadcrumbsProps>): BreadcrumbsP ...overrides, }) -// ========================================== -// Helper Functions -// ========================================== const resetMockStoreState = () => { mockStoreState.hasBucket = false mockStoreState.breadcrumbs = [] @@ -56,24 +44,16 @@ const resetMockStoreState = () => { mockStoreState.setBucket = vi.fn() } -// ========================================== -// Test Suites -// ========================================== describe('Breadcrumbs', () => { beforeEach(() => { vi.clearAllMocks() resetMockStoreState() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<Breadcrumbs {...props} />) // Assert - Container should be in the document @@ -82,13 +62,10 @@ describe('Breadcrumbs', () => { }) it('should render with correct container styles', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Breadcrumbs {...props} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('grow') @@ -98,14 +75,12 @@ describe('Breadcrumbs', () => { describe('Search Results Display', () => { it('should show search results when keywords and searchResultsLength > 0', () => { - // Arrange const props = createDefaultProps({ keywords: 'test', searchResultsLength: 5, breadcrumbs: ['folder1'], }) - // Act render(<Breadcrumbs {...props} />) // Assert - Search result text should be displayed @@ -113,36 +88,29 @@ describe('Breadcrumbs', () => { }) it('should not show search results when keywords is empty', () => { - // Arrange const props = createDefaultProps({ keywords: '', searchResultsLength: 5, breadcrumbs: ['folder1'], }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.queryByText(/searchResult/)).not.toBeInTheDocument() }) it('should not show search results when searchResultsLength is 0', () => { - // Arrange const props = createDefaultProps({ keywords: 'test', searchResultsLength: 0, }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.queryByText(/searchResult/)).not.toBeInTheDocument() }) it('should use bucket as folderName when breadcrumbs is empty', () => { - // Arrange const props = createDefaultProps({ keywords: 'test', searchResultsLength: 5, @@ -150,7 +118,6 @@ describe('Breadcrumbs', () => { bucket: 'my-bucket', }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should use bucket name in search result @@ -158,7 +125,6 @@ describe('Breadcrumbs', () => { }) it('should use last breadcrumb as folderName when breadcrumbs exist', () => { - // Arrange const props = createDefaultProps({ keywords: 'test', searchResultsLength: 5, @@ -166,7 +132,6 @@ describe('Breadcrumbs', () => { bucket: 'my-bucket', }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should use last breadcrumb in search result @@ -176,7 +141,6 @@ describe('Breadcrumbs', () => { describe('All Buckets Title Display', () => { it('should show all buckets title when hasBucket=true, bucket is empty, and no breadcrumbs', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ breadcrumbs: [], @@ -184,37 +148,30 @@ describe('Breadcrumbs', () => { keywords: '', }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')).toBeInTheDocument() }) it('should not show all buckets title when breadcrumbs exist', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ breadcrumbs: ['folder1'], bucket: '', }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.queryByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')).not.toBeInTheDocument() }) it('should not show all buckets title when bucket is set', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ breadcrumbs: [], bucket: 'my-bucket', }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should show bucket name instead @@ -224,14 +181,12 @@ describe('Breadcrumbs', () => { describe('Bucket Component Display', () => { it('should render Bucket component when hasBucket and bucket are set', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: 'test-bucket', breadcrumbs: [], }) - // Act render(<Breadcrumbs {...props} />) // Assert - Bucket name should be displayed @@ -239,14 +194,12 @@ describe('Breadcrumbs', () => { }) it('should not render Bucket when hasBucket is false', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ bucket: 'test-bucket', breadcrumbs: [], }) - // Act render(<Breadcrumbs {...props} />) // Assert - Bucket should not be displayed, Drive should be shown instead @@ -256,13 +209,11 @@ describe('Breadcrumbs', () => { describe('Drive Component Display', () => { it('should render Drive component when hasBucket is false', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: [], }) - // Act render(<Breadcrumbs {...props} />) // Assert - "All Files" should be displayed @@ -270,46 +221,38 @@ describe('Breadcrumbs', () => { }) it('should not render Drive component when hasBucket is true', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: 'test-bucket', breadcrumbs: [], }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.queryByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles')).not.toBeInTheDocument() }) }) describe('BreadcrumbItem Display', () => { it('should render all breadcrumbs when not collapsed', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2'], isInPipeline: false, }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText('folder1')).toBeInTheDocument() expect(screen.getByText('folder2')).toBeInTheDocument() }) it('should render last breadcrumb as active', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2'], }) - // Act render(<Breadcrumbs {...props} />) // Assert - Last breadcrumb should have active styles @@ -319,13 +262,11 @@ describe('Breadcrumbs', () => { }) it('should render non-last breadcrumbs with tertiary styles', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2'], }) - // Act render(<Breadcrumbs {...props} />) // Assert - First breadcrumb should have tertiary styles @@ -337,14 +278,12 @@ describe('Breadcrumbs', () => { describe('Collapsed Breadcrumbs (Dropdown)', () => { it('should show dropdown when breadcrumbs exceed displayBreadcrumbNum', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3', 'folder4'], isInPipeline: false, // displayBreadcrumbNum = 3 }) - // Act render(<Breadcrumbs {...props} />) // Assert - Dropdown trigger (more button) should be present @@ -352,14 +291,12 @@ describe('Breadcrumbs', () => { }) it('should not show dropdown when breadcrumbs do not exceed displayBreadcrumbNum', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2'], isInPipeline: false, // displayBreadcrumbNum = 3 }) - // Act const { container } = render(<Breadcrumbs {...props} />) // Assert - Should not have dropdown, just regular breadcrumbs @@ -372,14 +309,12 @@ describe('Breadcrumbs', () => { }) it('should show prefix breadcrumbs and last breadcrumb when collapsed', async () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3', 'folder4', 'folder5'], isInPipeline: false, // displayBreadcrumbNum = 3 }) - // Act render(<Breadcrumbs {...props} />) // Assert - First breadcrumb and last breadcrumb should be visible @@ -392,7 +327,6 @@ describe('Breadcrumbs', () => { }) it('should show collapsed breadcrumbs in dropdown when clicked', async () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3', 'folder4', 'folder5'], @@ -414,17 +348,12 @@ describe('Breadcrumbs', () => { }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('breadcrumbs prop', () => { it('should handle empty breadcrumbs array', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: [] }) - // Act render(<Breadcrumbs {...props} />) // Assert - Only Drive should be visible @@ -432,43 +361,34 @@ describe('Breadcrumbs', () => { }) it('should handle single breadcrumb', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['single-folder'] }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText('single-folder')).toBeInTheDocument() }) it('should handle breadcrumbs with special characters', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder [1]', 'folder (copy)'], }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText('folder [1]')).toBeInTheDocument() expect(screen.getByText('folder (copy)')).toBeInTheDocument() }) it('should handle breadcrumbs with unicode characters', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['文件夹', 'フォルダ'], }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText('文件夹')).toBeInTheDocument() expect(screen.getByText('フォルダ')).toBeInTheDocument() }) @@ -476,27 +396,22 @@ describe('Breadcrumbs', () => { describe('keywords prop', () => { it('should show search results when keywords is non-empty with results', () => { - // Arrange const props = createDefaultProps({ keywords: 'search-term', searchResultsLength: 10, }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText(/searchResult/)).toBeInTheDocument() }) it('should handle whitespace keywords', () => { - // Arrange const props = createDefaultProps({ keywords: ' ', searchResultsLength: 5, }) - // Act render(<Breadcrumbs {...props} />) // Assert - Whitespace is truthy, so should show search results @@ -506,43 +421,35 @@ describe('Breadcrumbs', () => { describe('bucket prop', () => { it('should display bucket name when hasBucket and bucket are set', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: 'production-bucket', }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText('production-bucket')).toBeInTheDocument() }) it('should handle bucket with special characters', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: 'bucket-v2.0_backup', }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText('bucket-v2.0_backup')).toBeInTheDocument() }) }) describe('searchResultsLength prop', () => { it('should handle zero searchResultsLength', () => { - // Arrange const props = createDefaultProps({ keywords: 'test', searchResultsLength: 0, }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should not show search results @@ -550,30 +457,25 @@ describe('Breadcrumbs', () => { }) it('should handle large searchResultsLength', () => { - // Arrange const props = createDefaultProps({ keywords: 'test', searchResultsLength: 10000, }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText(/searchResult.*10000/)).toBeInTheDocument() }) }) describe('isInPipeline prop', () => { it('should use displayBreadcrumbNum=2 when isInPipeline is true', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3'], isInPipeline: true, // displayBreadcrumbNum = 2 }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should collapse because 3 > 2 @@ -584,14 +486,12 @@ describe('Breadcrumbs', () => { }) it('should use displayBreadcrumbNum=3 when isInPipeline is false', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3'], isInPipeline: false, // displayBreadcrumbNum = 3 }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should NOT collapse because 3 <= 3 @@ -601,7 +501,6 @@ describe('Breadcrumbs', () => { }) it('should reduce displayBreadcrumbNum by 1 when bucket is set', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3'], @@ -609,7 +508,6 @@ describe('Breadcrumbs', () => { isInPipeline: false, // displayBreadcrumbNum = 3 - 1 = 2 }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should collapse because 3 > 2 @@ -620,13 +518,10 @@ describe('Breadcrumbs', () => { }) }) - // ========================================== // Memoization Logic and Dependencies Tests - // ========================================== describe('Memoization Logic and Dependencies', () => { describe('displayBreadcrumbNum useMemo', () => { it('should calculate correct value when isInPipeline=false and no bucket', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['a', 'b', 'c', 'd'], @@ -634,7 +529,6 @@ describe('Breadcrumbs', () => { bucket: '', }) - // Act render(<Breadcrumbs {...props} />) // Assert - displayBreadcrumbNum = 3, so 4 breadcrumbs should collapse @@ -646,7 +540,6 @@ describe('Breadcrumbs', () => { }) it('should calculate correct value when isInPipeline=true and no bucket', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['a', 'b', 'c'], @@ -654,7 +547,6 @@ describe('Breadcrumbs', () => { bucket: '', }) - // Act render(<Breadcrumbs {...props} />) // Assert - displayBreadcrumbNum = 2, so 3 breadcrumbs should collapse @@ -664,7 +556,6 @@ describe('Breadcrumbs', () => { }) it('should calculate correct value when isInPipeline=false and bucket exists', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ breadcrumbs: ['a', 'b', 'c'], @@ -672,7 +563,6 @@ describe('Breadcrumbs', () => { bucket: 'my-bucket', }) - // Act render(<Breadcrumbs {...props} />) // Assert - displayBreadcrumbNum = 3 - 1 = 2, so 3 breadcrumbs should collapse @@ -684,7 +574,6 @@ describe('Breadcrumbs', () => { describe('breadcrumbsConfig useMemo', () => { it('should correctly split breadcrumbs when collapsed', async () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['f1', 'f2', 'f3', 'f4', 'f5'], @@ -697,7 +586,6 @@ describe('Breadcrumbs', () => { if (dropdownTrigger) fireEvent.click(dropdownTrigger) - // Assert // prefixBreadcrumbs = ['f1', 'f2'] // collapsedBreadcrumbs = ['f3', 'f4'] // lastBreadcrumb = 'f5' @@ -711,14 +599,12 @@ describe('Breadcrumbs', () => { }) it('should not collapse when breadcrumbs.length <= displayBreadcrumbNum', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['f1', 'f2'], isInPipeline: false, // displayBreadcrumbNum = 3 }) - // Act render(<Breadcrumbs {...props} />) // Assert - All breadcrumbs should be visible @@ -728,13 +614,10 @@ describe('Breadcrumbs', () => { }) }) - // ========================================== // Callback Stability and Event Handlers Tests - // ========================================== describe('Callback Stability and Event Handlers', () => { describe('handleBackToBucketList', () => { it('should reset store state when called', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: 'my-bucket', @@ -746,7 +629,6 @@ describe('Breadcrumbs', () => { const buttons = screen.getAllByRole('button') fireEvent.click(buttons[0]) // Bucket icon button - // Assert expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) expect(mockStoreState.setBucket).toHaveBeenCalledWith('') @@ -757,7 +639,6 @@ describe('Breadcrumbs', () => { describe('handleClickBucketName', () => { it('should reset breadcrumbs and prefix when bucket name is clicked', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: 'my-bucket', @@ -769,7 +650,6 @@ describe('Breadcrumbs', () => { const bucketButton = screen.getByText('my-bucket') fireEvent.click(bucketButton) - // Assert expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith([]) @@ -777,7 +657,6 @@ describe('Breadcrumbs', () => { }) it('should not call handler when bucket is disabled (no breadcrumbs)', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: 'my-bucket', @@ -796,7 +675,6 @@ describe('Breadcrumbs', () => { describe('handleBackToRoot', () => { it('should reset state when Drive button is clicked', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1'], @@ -807,7 +685,6 @@ describe('Breadcrumbs', () => { const driveButton = screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles') fireEvent.click(driveButton) - // Assert expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith([]) @@ -817,7 +694,6 @@ describe('Breadcrumbs', () => { describe('handleClickBreadcrumb', () => { it('should slice breadcrumbs and prefix when breadcrumb is clicked', () => { - // Arrange mockStoreState.hasBucket = false mockStoreState.breadcrumbs = ['folder1', 'folder2', 'folder3'] mockStoreState.prefix = ['prefix1', 'prefix2', 'prefix3'] @@ -838,7 +714,6 @@ describe('Breadcrumbs', () => { }) it('should not call handler when last breadcrumb is clicked (disabled)', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2'], @@ -854,7 +729,6 @@ describe('Breadcrumbs', () => { }) it('should handle click on collapsed breadcrumb from dropdown', async () => { - // Arrange mockStoreState.hasBucket = false mockStoreState.breadcrumbs = ['f1', 'f2', 'f3', 'f4', 'f5'] mockStoreState.prefix = ['p1', 'p2', 'p3', 'p4', 'p5'] @@ -882,17 +756,13 @@ describe('Breadcrumbs', () => { }) }) - // ========================================== // Component Memoization Tests - // ========================================== describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Assert expect(Breadcrumbs).toHaveProperty('$$typeof', Symbol.for('react.memo')) }) it('should not re-render when props are the same', () => { - // Arrange const props = createDefaultProps() const { rerender } = render(<Breadcrumbs {...props} />) @@ -905,7 +775,6 @@ describe('Breadcrumbs', () => { }) it('should re-render when breadcrumbs change', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: ['folder1'] }) const { rerender } = render(<Breadcrumbs {...props} />) @@ -914,32 +783,25 @@ describe('Breadcrumbs', () => { // Act - Rerender with different breadcrumbs rerender(<Breadcrumbs {...createDefaultProps({ breadcrumbs: ['folder2'] })} />) - // Assert expect(screen.getByText('folder2')).toBeInTheDocument() }) }) - // ========================================== // Edge Cases and Error Handling Tests - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle very long breadcrumb names', () => { - // Arrange mockStoreState.hasBucket = false const longName = 'a'.repeat(100) const props = createDefaultProps({ breadcrumbs: [longName], }) - // Act render(<Breadcrumbs {...props} />) - // Assert expect(screen.getByText(longName)).toBeInTheDocument() }) it('should handle many breadcrumbs', async () => { - // Arrange mockStoreState.hasBucket = false const manyBreadcrumbs = Array.from({ length: 20 }, (_, i) => `folder-${i}`) const props = createDefaultProps({ @@ -962,14 +824,12 @@ describe('Breadcrumbs', () => { }) it('should handle empty bucket string', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ bucket: '', breadcrumbs: [], }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should show all buckets title @@ -977,13 +837,11 @@ describe('Breadcrumbs', () => { }) it('should handle breadcrumb with only whitespace', () => { - // Arrange mockStoreState.hasBucket = false const props = createDefaultProps({ breadcrumbs: [' ', 'normal-folder'], }) - // Act render(<Breadcrumbs {...props} />) // Assert - Both should be rendered @@ -991,9 +849,6 @@ describe('Breadcrumbs', () => { }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('Prop Variations', () => { it.each([ { hasBucket: true, bucket: 'b1', breadcrumbs: [], expected: 'bucket visible' }, @@ -1001,11 +856,9 @@ describe('Breadcrumbs', () => { { hasBucket: false, bucket: '', breadcrumbs: [], expected: 'all files' }, { hasBucket: false, bucket: '', breadcrumbs: ['f1'], expected: 'drive with breadcrumb' }, ])('should render correctly for $expected', ({ hasBucket, bucket, breadcrumbs }) => { - // Arrange mockStoreState.hasBucket = hasBucket const props = createDefaultProps({ bucket, breadcrumbs }) - // Act render(<Breadcrumbs {...props} />) // Assert - Component should render without errors @@ -1019,12 +872,10 @@ describe('Breadcrumbs', () => { { isInPipeline: true, bucket: 'b', expectedNum: 1 }, { isInPipeline: false, bucket: 'b', expectedNum: 2 }, ])('should calculate displayBreadcrumbNum=$expectedNum when isInPipeline=$isInPipeline and bucket=$bucket', ({ isInPipeline, bucket, expectedNum }) => { - // Arrange mockStoreState.hasBucket = !!bucket const breadcrumbs = Array.from({ length: expectedNum + 2 }, (_, i) => `f${i}`) const props = createDefaultProps({ isInPipeline, bucket, breadcrumbs }) - // Act render(<Breadcrumbs {...props} />) // Assert - Should collapse because breadcrumbs.length > expectedNum @@ -1034,12 +885,8 @@ describe('Breadcrumbs', () => { }) }) - // ========================================== - // Integration Tests - // ========================================== describe('Integration', () => { it('should handle full navigation flow: bucket -> folders -> navigation back', () => { - // Arrange mockStoreState.hasBucket = true mockStoreState.breadcrumbs = ['folder1', 'folder2'] mockStoreState.prefix = ['prefix1', 'prefix2'] @@ -1053,13 +900,11 @@ describe('Breadcrumbs', () => { const firstFolder = screen.getByText('folder1') fireEvent.click(firstFolder) - // Assert expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith(['folder1']) expect(mockStoreState.setPrefix).toHaveBeenCalledWith(['prefix1']) }) it('should handle search result display with navigation elements hidden', () => { - // Arrange mockStoreState.hasBucket = true const props = createDefaultProps({ keywords: 'test', @@ -1068,7 +913,6 @@ describe('Breadcrumbs', () => { breadcrumbs: ['folder1'], }) - // Act render(<Breadcrumbs {...props} />) // Assert - Search result should be shown, navigation elements should be hidden diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/item.spec.tsx new file mode 100644 index 0000000000..f4a63f22b3 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/item.spec.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import BreadcrumbItem from '../item' + +describe('BreadcrumbItem', () => { + const defaultProps = { + name: 'Documents', + index: 2, + handleClick: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render name', () => { + render(<BreadcrumbItem {...defaultProps} />) + expect(screen.getByText('Documents')).toBeInTheDocument() + }) + + it('should show separator by default', () => { + render(<BreadcrumbItem {...defaultProps} />) + expect(screen.getByText('/')).toBeInTheDocument() + }) + + it('should hide separator when showSeparator is false', () => { + render(<BreadcrumbItem {...defaultProps} showSeparator={false} />) + expect(screen.queryByText('/')).not.toBeInTheDocument() + }) + + it('should call handleClick with index on click', () => { + render(<BreadcrumbItem {...defaultProps} />) + fireEvent.click(screen.getByText('Documents')) + expect(defaultProps.handleClick).toHaveBeenCalledWith(2) + }) + + it('should not call handleClick when disabled', () => { + render(<BreadcrumbItem {...defaultProps} disabled={true} />) + fireEvent.click(screen.getByText('Documents')) + expect(defaultProps.handleClick).not.toHaveBeenCalled() + }) + + it('should apply active styling', () => { + render(<BreadcrumbItem {...defaultProps} isActive={true} />) + const btn = screen.getByRole('button') + expect(btn.className).toContain('system-sm-medium') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx similarity index 89% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx index 13abce1c81..0157d3cf79 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/index.spec.tsx @@ -1,14 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import Dropdown from './index' +import Dropdown from '../index' -// ========================================== -// Note: react-i18next uses global mock from web/vitest.setup.ts -// ========================================== - -// ========================================== -// Test Data Builders -// ========================================== type DropdownProps = React.ComponentProps<typeof Dropdown> const createDefaultProps = (overrides?: Partial<DropdownProps>): DropdownProps => ({ @@ -18,23 +11,15 @@ const createDefaultProps = (overrides?: Partial<DropdownProps>): DropdownProps = ...overrides, }) -// ========================================== -// Test Suites -// ========================================== describe('Dropdown', () => { beforeEach(() => { vi.clearAllMocks() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<Dropdown {...props} />) // Assert - Trigger button should be visible @@ -42,10 +27,8 @@ describe('Dropdown', () => { }) it('should render trigger button with more icon', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Dropdown {...props} />) // Assert - Button should have RiMoreFill icon (rendered as svg) @@ -55,10 +38,8 @@ describe('Dropdown', () => { }) it('should render separator after dropdown', () => { - // Arrange const props = createDefaultProps() - // Act render(<Dropdown {...props} />) // Assert - Separator "/" should be visible @@ -66,13 +47,10 @@ describe('Dropdown', () => { }) it('should render trigger button with correct default styles', () => { - // Arrange const props = createDefaultProps() - // Act render(<Dropdown {...props} />) - // Assert const button = screen.getByRole('button') expect(button).toHaveClass('flex') expect(button).toHaveClass('size-6') @@ -82,10 +60,8 @@ describe('Dropdown', () => { }) it('should not render menu content when closed', () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['visible-folder'] }) - // Act render(<Dropdown {...props} />) // Assert - Menu content should not be visible when dropdown is closed @@ -93,7 +69,6 @@ describe('Dropdown', () => { }) it('should render menu content when opened', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['test-folder1', 'test-folder2'] }) render(<Dropdown {...props} />) @@ -108,13 +83,9 @@ describe('Dropdown', () => { }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('startIndex prop', () => { it('should pass startIndex to Menu component', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 5, @@ -137,7 +108,6 @@ describe('Dropdown', () => { }) it('should calculate correct index for second item', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 3, @@ -162,16 +132,13 @@ describe('Dropdown', () => { describe('breadcrumbs prop', () => { it('should render all breadcrumbs in menu', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder-a', 'folder-b', 'folder-c'], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('folder-a')).toBeInTheDocument() expect(screen.getByText('folder-b')).toBeInTheDocument() @@ -180,29 +147,24 @@ describe('Dropdown', () => { }) it('should handle single breadcrumb', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['single-folder'], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('single-folder')).toBeInTheDocument() }) }) it('should handle empty breadcrumbs array', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: [], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) // Assert - Menu should be rendered but with no items @@ -213,16 +175,13 @@ describe('Dropdown', () => { }) it('should handle breadcrumbs with special characters', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder [1]', 'folder (copy)', 'folder-v2.0'], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('folder [1]')).toBeInTheDocument() expect(screen.getByText('folder (copy)')).toBeInTheDocument() @@ -231,16 +190,13 @@ describe('Dropdown', () => { }) it('should handle breadcrumbs with unicode characters', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['文件夹', 'フォルダ', 'Папка'], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('文件夹')).toBeInTheDocument() expect(screen.getByText('フォルダ')).toBeInTheDocument() @@ -251,7 +207,6 @@ describe('Dropdown', () => { describe('onBreadcrumbClick prop', () => { it('should call onBreadcrumbClick with correct index when item clicked', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 0, @@ -260,7 +215,6 @@ describe('Dropdown', () => { }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -269,23 +223,17 @@ describe('Dropdown', () => { fireEvent.click(screen.getByText('folder1')) - // Assert expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(0) expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(1) }) }) }) - // ========================================== - // State Management Tests - // ========================================== describe('State Management', () => { describe('open state', () => { it('should initialize with closed state', () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['test-folder'] }) - // Act render(<Dropdown {...props} />) // Assert - Menu content should not be visible @@ -293,21 +241,17 @@ describe('Dropdown', () => { }) it('should toggle to open state when trigger is clicked', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['test-folder'] }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('test-folder')).toBeInTheDocument() }) }) it('should toggle to closed state when trigger is clicked again', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['test-folder'] }) render(<Dropdown {...props} />) @@ -319,14 +263,12 @@ describe('Dropdown', () => { fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.queryByText('test-folder')).not.toBeInTheDocument() }) }) it('should close when breadcrumb item is clicked', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ breadcrumbs: ['test-folder'], @@ -341,7 +283,6 @@ describe('Dropdown', () => { expect(screen.getByText('test-folder')).toBeInTheDocument() }) - // Click on breadcrumb item fireEvent.click(screen.getByText('test-folder')) // Assert - Menu should close @@ -351,7 +292,6 @@ describe('Dropdown', () => { }) it('should apply correct button styles based on open state', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['test-folder'] }) render(<Dropdown {...props} />) const button = screen.getByRole('button') @@ -370,13 +310,10 @@ describe('Dropdown', () => { }) }) - // ========================================== // Event Handlers Tests - // ========================================== describe('Event Handlers', () => { describe('handleTrigger', () => { it('should toggle open state when trigger is clicked', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder'] }) render(<Dropdown {...props} />) @@ -393,7 +330,6 @@ describe('Dropdown', () => { }) it('should toggle multiple times correctly', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder'] }) render(<Dropdown {...props} />) const button = screen.getByRole('button') @@ -421,7 +357,6 @@ describe('Dropdown', () => { describe('handleBreadCrumbClick', () => { it('should call onBreadcrumbClick and close menu', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ breadcrumbs: ['folder1'], @@ -436,10 +371,8 @@ describe('Dropdown', () => { expect(screen.getByText('folder1')).toBeInTheDocument() }) - // Click on breadcrumb fireEvent.click(screen.getByText('folder1')) - // Assert expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(1) // Menu should close @@ -449,7 +382,6 @@ describe('Dropdown', () => { }) it('should pass correct index to onBreadcrumbClick for each item', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 2, @@ -473,9 +405,7 @@ describe('Dropdown', () => { }) }) - // ========================================== // Callback Stability and Memoization Tests - // ========================================== describe('Callback Stability and Memoization', () => { it('should be wrapped with React.memo', () => { // Assert - Dropdown component should be memoized @@ -483,7 +413,6 @@ describe('Dropdown', () => { }) it('should maintain stable callback after rerender with same props', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ breadcrumbs: ['folder'], @@ -506,12 +435,10 @@ describe('Dropdown', () => { }) fireEvent.click(screen.getByText('folder')) - // Assert expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(2) }) it('should update callback when onBreadcrumbClick prop changes', async () => { - // Arrange const mockOnBreadcrumbClick1 = vi.fn() const mockOnBreadcrumbClick2 = vi.fn() const props = createDefaultProps({ @@ -543,13 +470,11 @@ describe('Dropdown', () => { }) fireEvent.click(screen.getByText('folder')) - // Assert expect(mockOnBreadcrumbClick1).toHaveBeenCalledTimes(1) expect(mockOnBreadcrumbClick2).toHaveBeenCalledTimes(1) }) it('should not re-render when props are the same', () => { - // Arrange const props = createDefaultProps() const { rerender } = render(<Dropdown {...props} />) @@ -561,12 +486,8 @@ describe('Dropdown', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle rapid toggle clicks', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['folder'] }) render(<Dropdown {...props} />) const button = screen.getByRole('button') @@ -583,31 +504,26 @@ describe('Dropdown', () => { }) it('should handle very long folder names', async () => { - // Arrange const longName = 'a'.repeat(100) const props = createDefaultProps({ breadcrumbs: [longName], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText(longName)).toBeInTheDocument() }) }) it('should handle many breadcrumbs', async () => { - // Arrange const manyBreadcrumbs = Array.from({ length: 20 }, (_, i) => `folder-${i}`) const props = createDefaultProps({ breadcrumbs: manyBreadcrumbs, }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) // Assert - First and last items should be visible @@ -618,7 +534,6 @@ describe('Dropdown', () => { }) it('should handle startIndex of 0', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 0, @@ -627,19 +542,16 @@ describe('Dropdown', () => { }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) await waitFor(() => { expect(screen.getByText('folder')).toBeInTheDocument() }) fireEvent.click(screen.getByText('folder')) - // Assert expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(0) }) it('should handle large startIndex values', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 999, @@ -648,53 +560,42 @@ describe('Dropdown', () => { }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) await waitFor(() => { expect(screen.getByText('folder')).toBeInTheDocument() }) fireEvent.click(screen.getByText('folder')) - // Assert expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(999) }) it('should handle breadcrumbs with whitespace-only names', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: [' ', 'normal-folder'], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('normal-folder')).toBeInTheDocument() }) }) it('should handle breadcrumbs with empty string', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['', 'folder'], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('folder')).toBeInTheDocument() }) }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('Prop Variations', () => { it.each([ { startIndex: 0, breadcrumbs: ['a'], expectedIndex: 0 }, @@ -702,7 +603,6 @@ describe('Dropdown', () => { { startIndex: 5, breadcrumbs: ['a'], expectedIndex: 5 }, { startIndex: 10, breadcrumbs: ['a', 'b'], expectedIndex: 10 }, ])('should handle startIndex=$startIndex correctly', async ({ startIndex, breadcrumbs, expectedIndex }) => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex, @@ -711,14 +611,12 @@ describe('Dropdown', () => { }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) await waitFor(() => { expect(screen.getByText(breadcrumbs[0])).toBeInTheDocument() }) fireEvent.click(screen.getByText(breadcrumbs[0])) - // Assert expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(expectedIndex) }) @@ -728,10 +626,8 @@ describe('Dropdown', () => { { breadcrumbs: ['a', 'b'], description: 'two items' }, { breadcrumbs: ['a', 'b', 'c', 'd', 'e'], description: 'five items' }, ])('should render correctly with $description breadcrumbs', async ({ breadcrumbs }) => { - // Arrange const props = createDefaultProps({ breadcrumbs }) - // Act render(<Dropdown {...props} />) fireEvent.click(screen.getByRole('button')) @@ -743,21 +639,16 @@ describe('Dropdown', () => { }) }) - // ========================================== // Integration Tests (Menu and Item) - // ========================================== describe('Integration with Menu and Item', () => { it('should render all menu items with correct content', async () => { - // Arrange const props = createDefaultProps({ breadcrumbs: ['Documents', 'Projects', 'Archive'], }) render(<Dropdown {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert await waitFor(() => { expect(screen.getByText('Documents')).toBeInTheDocument() expect(screen.getByText('Projects')).toBeInTheDocument() @@ -766,7 +657,6 @@ describe('Dropdown', () => { }) it('should handle click on any menu item', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 0, @@ -787,7 +677,6 @@ describe('Dropdown', () => { }) it('should close menu after any item click', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ breadcrumbs: ['item1', 'item2', 'item3'], @@ -811,7 +700,6 @@ describe('Dropdown', () => { }) it('should correctly calculate index for each item based on startIndex', async () => { - // Arrange const mockOnBreadcrumbClick = vi.fn() const props = createDefaultProps({ startIndex: 3, @@ -836,31 +724,22 @@ describe('Dropdown', () => { }) }) - // ========================================== - // Accessibility Tests - // ========================================== describe('Accessibility', () => { it('should render trigger as button element', () => { - // Arrange const props = createDefaultProps() - // Act render(<Dropdown {...props} />) - // Assert const button = screen.getByRole('button') expect(button).toBeInTheDocument() expect(button.tagName).toBe('BUTTON') }) it('should have type="button" attribute', () => { - // Arrange const props = createDefaultProps() - // Act render(<Dropdown {...props} />) - // Assert const button = screen.getByRole('button') expect(button).toHaveAttribute('type', 'button') }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx new file mode 100644 index 0000000000..4437305ad4 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/item.spec.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Item from '../item' + +describe('Item', () => { + const defaultProps = { + name: 'Documents', + index: 2, + onBreadcrumbClick: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verify the breadcrumb name is displayed + describe('Rendering', () => { + it('should render breadcrumb name', () => { + render(<Item {...defaultProps} />) + + expect(screen.getByText('Documents')).toBeInTheDocument() + }) + }) + + // User interactions: clicking triggers callback with correct index + describe('User Interactions', () => { + it('should call onBreadcrumbClick with correct index on click', () => { + render(<Item {...defaultProps} />) + + fireEvent.click(screen.getByText('Documents')) + + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledOnce() + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2) + }) + + it('should pass different index values correctly', () => { + render(<Item {...defaultProps} index={5} />) + + fireEvent.click(screen.getByText('Documents')) + + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(5) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx new file mode 100644 index 0000000000..c8c6b8fec3 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/__tests__/menu.spec.tsx @@ -0,0 +1,79 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Menu from '../menu' + +describe('Menu', () => { + const defaultProps = { + breadcrumbs: ['Folder A', 'Folder B', 'Folder C'], + startIndex: 1, + onBreadcrumbClick: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verify all breadcrumb items are displayed + describe('Rendering', () => { + it('should render all breadcrumb items', () => { + render(<Menu {...defaultProps} />) + + expect(screen.getByText('Folder A')).toBeInTheDocument() + expect(screen.getByText('Folder B')).toBeInTheDocument() + expect(screen.getByText('Folder C')).toBeInTheDocument() + }) + + it('should render empty list when no breadcrumbs provided', () => { + const { container } = render( + <Menu breadcrumbs={[]} startIndex={0} onBreadcrumbClick={vi.fn()} />, + ) + + const menuContainer = container.firstElementChild + expect(menuContainer?.children).toHaveLength(0) + }) + }) + + // Index mapping: startIndex offsets are applied correctly + describe('Index Mapping', () => { + it('should pass correct index (startIndex + offset) to each item', () => { + render(<Menu {...defaultProps} />) + + fireEvent.click(screen.getByText('Folder A')) + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(1) + + fireEvent.click(screen.getByText('Folder B')) + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2) + + fireEvent.click(screen.getByText('Folder C')) + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(3) + }) + + it('should offset from startIndex of zero', () => { + render( + <Menu + breadcrumbs={['First', 'Second']} + startIndex={0} + onBreadcrumbClick={defaultProps.onBreadcrumbClick} + />, + ) + + fireEvent.click(screen.getByText('First')) + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(0) + + fireEvent.click(screen.getByText('Second')) + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(1) + }) + }) + + // User interactions: clicking items triggers the callback + describe('User Interactions', () => { + it('should call onBreadcrumbClick with correct index when item clicked', () => { + render(<Menu {...defaultProps} />) + + fireEvent.click(screen.getByText('Folder B')) + + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledOnce() + expect(defaultProps.onBreadcrumbClick).toHaveBeenCalledWith(2) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-folder.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-folder.spec.tsx new file mode 100644 index 0000000000..8d026d5589 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-folder.spec.tsx @@ -0,0 +1,10 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import EmptyFolder from '../empty-folder' + +describe('EmptyFolder', () => { + it('should render empty folder message', () => { + render(<EmptyFolder />) + expect(screen.getByText('datasetPipeline.onlineDrive.emptyFolder')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-search-result.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-search-result.spec.tsx new file mode 100644 index 0000000000..8b88a939e8 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/empty-search-result.spec.tsx @@ -0,0 +1,31 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import EmptySearchResult from '../empty-search-result' + +vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({ + SearchMenu: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="search-icon" {...props} />, +})) + +describe('EmptySearchResult', () => { + const onResetKeywords = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render empty state message', () => { + render(<EmptySearchResult onResetKeywords={onResetKeywords} />) + expect(screen.getByText('datasetPipeline.onlineDrive.emptySearchResult')).toBeInTheDocument() + }) + + it('should render reset button', () => { + render(<EmptySearchResult onResetKeywords={onResetKeywords} />) + expect(screen.getByText('datasetPipeline.onlineDrive.resetKeywords')).toBeInTheDocument() + }) + + it('should call onResetKeywords when reset button clicked', () => { + render(<EmptySearchResult onResetKeywords={onResetKeywords} />) + fireEvent.click(screen.getByText('datasetPipeline.onlineDrive.resetKeywords')) + expect(onResetKeywords).toHaveBeenCalledOnce() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/file-icon.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/file-icon.spec.tsx new file mode 100644 index 0000000000..3377d4099d --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/file-icon.spec.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { OnlineDriveFileType } from '@/models/pipeline' +import FileIcon from '../file-icon' + +vi.mock('@/app/components/base/file-uploader/file-type-icon', () => ({ + default: ({ type }: { type: string }) => <span data-testid="file-type-icon">{type}</span>, +})) +vi.mock('@/app/components/base/icons/src/public/knowledge/online-drive', () => ({ + BucketsBlue: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="bucket-icon" {...props} />, + Folder: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="folder-icon" {...props} />, +})) + +describe('FileIcon', () => { + it('should render bucket icon for bucket type', () => { + render(<FileIcon type={OnlineDriveFileType.bucket} fileName="" />) + expect(screen.getByTestId('bucket-icon')).toBeInTheDocument() + }) + + it('should render folder icon for folder type', () => { + render(<FileIcon type={OnlineDriveFileType.folder} fileName="" />) + expect(screen.getByTestId('folder-icon')).toBeInTheDocument() + }) + + it('should render file type icon for file type', () => { + render(<FileIcon type={OnlineDriveFileType.file} fileName="doc.pdf" />) + expect(screen.getByTestId('file-type-icon')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/index.spec.tsx similarity index 92% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/index.spec.tsx index 0a8066bdc7..921bf7e207 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/index.spec.tsx @@ -3,16 +3,10 @@ import type { OnlineDriveFile } from '@/models/pipeline' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { OnlineDriveFileType } from '@/models/pipeline' -import List from './index' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import List from '../index' // Mock Item component for List tests - child component with complex behavior -vi.mock('./item', () => ({ +vi.mock('../item', () => ({ default: ({ file, isSelected, onSelect, onOpen, isMultipleChoice }: { file: OnlineDriveFile isSelected: boolean @@ -35,14 +29,14 @@ vi.mock('./item', () => ({ })) // Mock EmptyFolder component for List tests -vi.mock('./empty-folder', () => ({ +vi.mock('../empty-folder', () => ({ default: () => ( <div data-testid="empty-folder">Empty Folder</div> ), })) // Mock EmptySearchResult component for List tests -vi.mock('./empty-search-result', () => ({ +vi.mock('../empty-search-result', () => ({ default: ({ onResetKeywords }: { onResetKeywords: () => void }) => ( <div data-testid="empty-search-result"> <span>No results</span> @@ -53,7 +47,7 @@ vi.mock('./empty-search-result', () => ({ // Mock store state and refs const mockIsTruncated = { current: false } -const mockCurrentNextPageParametersRef = { current: {} as Record<string, any> } +const mockCurrentNextPageParametersRef = { current: {} as Record<string, unknown> } const mockSetNextPageParameters = vi.fn() const mockStoreState = { @@ -65,13 +59,10 @@ const mockStoreState = { const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } -vi.mock('../../../store', () => ({ +vi.mock('../../../../store', () => ({ useDataSourceStore: () => mockDataSourceStore, })) -// ========================================== -// Test Data Builders -// ========================================== const createMockOnlineDriveFile = (overrides?: Partial<OnlineDriveFile>): OnlineDriveFile => ({ id: 'file-1', name: 'test-file.txt', @@ -102,9 +93,7 @@ const createDefaultProps = (overrides?: Partial<ListProps>): ListProps => ({ ...overrides, }) -// ========================================== // Mock IntersectionObserver -// ========================================== let mockIntersectionObserverCallback: IntersectionObserverCallback | null = null let mockIntersectionObserverInstance: { observe: Mock @@ -136,9 +125,6 @@ const createMockIntersectionObserver = () => { } } -// ========================================== -// Helper Functions -// ========================================== const triggerIntersection = (isIntersecting: boolean) => { if (mockIntersectionObserverCallback) { const entries = [{ @@ -161,9 +147,6 @@ const resetMockStoreState = () => { mockGetState.mockClear() } -// ========================================== -// Test Suites -// ========================================== describe('List', () => { const originalIntersectionObserver = window.IntersectionObserver @@ -181,89 +164,69 @@ describe('List', () => { window.IntersectionObserver = originalIntersectionObserver }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<List {...props} />) - // Assert expect(document.body).toBeInTheDocument() }) it('should render Loading component when isAllLoading is true', () => { - // Arrange const props = createDefaultProps({ isLoading: true, fileList: [], keywords: '', }) - // Act render(<List {...props} />) - // Assert expect(screen.getByRole('status')).toBeInTheDocument() }) it('should render EmptyFolder when folder is empty and not loading', () => { - // Arrange const props = createDefaultProps({ isLoading: false, fileList: [], keywords: '', }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('empty-folder')).toBeInTheDocument() }) it('should render EmptySearchResult when search has no results', () => { - // Arrange const props = createDefaultProps({ isLoading: false, fileList: [], keywords: 'non-existent-file', }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('empty-search-result')).toBeInTheDocument() }) it('should render file list when files exist', () => { - // Arrange const fileList = createMockFileList(3) const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toBeInTheDocument() expect(screen.getByTestId('item-file-2')).toBeInTheDocument() expect(screen.getByTestId('item-file-3')).toBeInTheDocument() }) it('should render partial loading spinner when loading more files', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList, isLoading: true, }) - // Act render(<List {...props} />) // Assert - Should show files AND loading indicator @@ -272,20 +235,14 @@ describe('List', () => { }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('fileList prop', () => { it('should render all files from fileList', () => { - // Arrange const fileList = createMockFileList(5) const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert fileList.forEach((file) => { expect(screen.getByTestId(`item-${file.id}`)).toBeInTheDocument() expect(screen.getByTestId(`item-name-${file.id}`)).toHaveTextContent(file.name) @@ -293,37 +250,28 @@ describe('List', () => { }) it('should handle empty fileList', () => { - // Arrange const props = createDefaultProps({ fileList: [] }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('empty-folder')).toBeInTheDocument() }) it('should handle single file in fileList', () => { - // Arrange const fileList = [createMockOnlineDriveFile()] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toBeInTheDocument() }) it('should handle large fileList', () => { - // Arrange const fileList = createMockFileList(100) const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toBeInTheDocument() expect(screen.getByTestId('item-file-100')).toBeInTheDocument() }) @@ -331,51 +279,42 @@ describe('List', () => { describe('selectedFileIds prop', () => { it('should mark selected files as selected', () => { - // Arrange const fileList = createMockFileList(3) const props = createDefaultProps({ fileList, selectedFileIds: ['file-1', 'file-3'], }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'true') expect(screen.getByTestId('item-file-2')).toHaveAttribute('data-selected', 'false') expect(screen.getByTestId('item-file-3')).toHaveAttribute('data-selected', 'true') }) it('should handle empty selectedFileIds', () => { - // Arrange const fileList = createMockFileList(3) const props = createDefaultProps({ fileList, selectedFileIds: [], }) - // Act render(<List {...props} />) - // Assert fileList.forEach((file) => { expect(screen.getByTestId(`item-${file.id}`)).toHaveAttribute('data-selected', 'false') }) }) it('should handle all files selected', () => { - // Arrange const fileList = createMockFileList(3) const props = createDefaultProps({ fileList, selectedFileIds: ['file-1', 'file-2', 'file-3'], }) - // Act render(<List {...props} />) - // Assert fileList.forEach((file) => { expect(screen.getByTestId(`item-${file.id}`)).toHaveAttribute('data-selected', 'true') }) @@ -384,30 +323,24 @@ describe('List', () => { describe('keywords prop', () => { it('should show EmptySearchResult when keywords exist but no results', () => { - // Arrange const props = createDefaultProps({ fileList: [], keywords: 'search-term', }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('empty-search-result')).toBeInTheDocument() }) it('should show EmptyFolder when keywords is empty and no files', () => { - // Arrange const props = createDefaultProps({ fileList: [], keywords: '', }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('empty-folder')).toBeInTheDocument() }) }) @@ -419,13 +352,10 @@ describe('List', () => { { isLoading: false, fileList: [], keywords: '', expected: 'isEmpty' }, { isLoading: false, fileList: createMockFileList(2), keywords: '', expected: 'hasFiles' }, ])('should render correctly when isLoading=$isLoading with fileList.length=$fileList.length', ({ isLoading, fileList, expected }) => { - // Arrange const props = createDefaultProps({ isLoading, fileList }) - // Act render(<List {...props} />) - // Assert switch (expected) { case 'isAllLoading': expect(screen.getByRole('status')).toBeInTheDocument() @@ -446,44 +376,35 @@ describe('List', () => { describe('supportBatchUpload prop', () => { it('should pass supportBatchUpload true to Item components', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList, supportBatchUpload: true, }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-multiple-choice', 'true') }) it('should pass supportBatchUpload false to Item components', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList, supportBatchUpload: false, }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-multiple-choice', 'false') }) }) }) - // ========================================== // User Interactions and Event Handlers - // ========================================== describe('User Interactions', () => { describe('File Selection', () => { it('should call handleSelectFile when selecting a file', () => { - // Arrange const handleSelectFile = vi.fn() const fileList = createMockFileList(2) const props = createDefaultProps({ @@ -492,15 +413,12 @@ describe('List', () => { }) render(<List {...props} />) - // Act fireEvent.click(screen.getByTestId('item-select-file-1')) - // Assert expect(handleSelectFile).toHaveBeenCalledWith(fileList[0]) }) it('should call handleSelectFile with correct file data', () => { - // Arrange const handleSelectFile = vi.fn() const fileList = [ createMockOnlineDriveFile({ id: 'unique-id', name: 'special-file.pdf', size: 5000 }), @@ -511,10 +429,8 @@ describe('List', () => { }) render(<List {...props} />) - // Act fireEvent.click(screen.getByTestId('item-select-unique-id')) - // Assert expect(handleSelectFile).toHaveBeenCalledWith( expect.objectContaining({ id: 'unique-id', @@ -527,7 +443,6 @@ describe('List', () => { describe('Folder Navigation', () => { it('should call handleOpenFolder when opening a folder', () => { - // Arrange const handleOpenFolder = vi.fn() const fileList = [ createMockOnlineDriveFile({ id: 'folder-1', name: 'Documents', type: OnlineDriveFileType.folder }), @@ -538,17 +453,14 @@ describe('List', () => { }) render(<List {...props} />) - // Act fireEvent.click(screen.getByTestId('item-open-folder-1')) - // Assert expect(handleOpenFolder).toHaveBeenCalledWith(fileList[0]) }) }) describe('Reset Keywords', () => { it('should call handleResetKeywords when reset button is clicked', () => { - // Arrange const handleResetKeywords = vi.fn() const props = createDefaultProps({ fileList: [], @@ -557,38 +469,29 @@ describe('List', () => { }) render(<List {...props} />) - // Act fireEvent.click(screen.getByTestId('reset-keywords-btn')) - // Assert expect(handleResetKeywords).toHaveBeenCalledTimes(1) }) }) }) - // ========================================== // Side Effects and Cleanup Tests (IntersectionObserver) - // ========================================== describe('Side Effects and Cleanup', () => { describe('IntersectionObserver Setup', () => { it('should create IntersectionObserver on mount', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(mockIntersectionObserverInstance?.observe).toHaveBeenCalled() }) it('should create IntersectionObserver with correct rootMargin', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) // Assert - Callback should be set @@ -596,14 +499,11 @@ describe('List', () => { }) it('should observe the anchor element', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(mockIntersectionObserverInstance?.observe).toHaveBeenCalled() const observedElement = mockIntersectionObserverInstance?.observe.mock.calls[0]?.[0] expect(observedElement).toBeInstanceOf(HTMLElement) @@ -613,7 +513,6 @@ describe('List', () => { describe('IntersectionObserver Callback', () => { it('should call setNextPageParameters when intersecting and truncated', async () => { - // Arrange mockIsTruncated.current = true mockCurrentNextPageParametersRef.current = { cursor: 'next-cursor' } const fileList = createMockFileList(2) @@ -623,17 +522,14 @@ describe('List', () => { }) render(<List {...props} />) - // Act triggerIntersection(true) - // Assert await waitFor(() => { expect(mockSetNextPageParameters).toHaveBeenCalledWith({ cursor: 'next-cursor' }) }) }) it('should not call setNextPageParameters when not intersecting', () => { - // Arrange mockIsTruncated.current = true mockCurrentNextPageParametersRef.current = { cursor: 'next-cursor' } const fileList = createMockFileList(2) @@ -643,15 +539,12 @@ describe('List', () => { }) render(<List {...props} />) - // Act triggerIntersection(false) - // Assert expect(mockSetNextPageParameters).not.toHaveBeenCalled() }) it('should not call setNextPageParameters when not truncated', () => { - // Arrange mockIsTruncated.current = false const fileList = createMockFileList(2) const props = createDefaultProps({ @@ -660,15 +553,12 @@ describe('List', () => { }) render(<List {...props} />) - // Act triggerIntersection(true) - // Assert expect(mockSetNextPageParameters).not.toHaveBeenCalled() }) it('should not call setNextPageParameters when loading', () => { - // Arrange mockIsTruncated.current = true mockCurrentNextPageParametersRef.current = { cursor: 'next-cursor' } const fileList = createMockFileList(2) @@ -678,30 +568,24 @@ describe('List', () => { }) render(<List {...props} />) - // Act triggerIntersection(true) - // Assert expect(mockSetNextPageParameters).not.toHaveBeenCalled() }) }) describe('IntersectionObserver Cleanup', () => { it('should disconnect IntersectionObserver on unmount', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList }) const { unmount } = render(<List {...props} />) - // Act unmount() - // Assert expect(mockIntersectionObserverInstance?.disconnect).toHaveBeenCalled() }) it('should cleanup previous observer when dependencies change', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList, @@ -718,18 +602,14 @@ describe('List', () => { }) }) - // ========================================== // Component Memoization Tests - // ========================================== describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Arrange & Assert // List component should have $$typeof symbol indicating memo wrapper expect(List).toHaveProperty('$$typeof', Symbol.for('react.memo')) }) it('should not re-render when props are equal', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList }) const renderSpy = vi.fn() @@ -751,7 +631,6 @@ describe('List', () => { }) it('should re-render when fileList changes', () => { - // Arrange const fileList1 = createMockFileList(2) const fileList2 = createMockFileList(3) const props1 = createDefaultProps({ fileList: fileList1 }) @@ -772,7 +651,6 @@ describe('List', () => { }) it('should re-render when selectedFileIds changes', () => { - // Arrange const fileList = createMockFileList(2) const props1 = createDefaultProps({ fileList, selectedFileIds: [] }) const props2 = createDefaultProps({ fileList, selectedFileIds: ['file-1'] }) @@ -782,15 +660,12 @@ describe('List', () => { // Assert initial state expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'false') - // Act rerender(<List {...props2} />) - // Assert expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'true') }) it('should re-render when isLoading changes', () => { - // Arrange const fileList = createMockFileList(2) const props1 = createDefaultProps({ fileList, isLoading: false }) const props2 = createDefaultProps({ fileList, isLoading: true }) @@ -800,7 +675,6 @@ describe('List', () => { // Assert initial state - no loading spinner expect(screen.queryByRole('status')).not.toBeInTheDocument() - // Act rerender(<List {...props2} />) // Assert - loading spinner should appear @@ -808,45 +682,34 @@ describe('List', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { describe('Empty/Null Values', () => { it('should handle empty fileList array', () => { - // Arrange const props = createDefaultProps({ fileList: [] }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('empty-folder')).toBeInTheDocument() }) it('should handle empty selectedFileIds array', () => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList, selectedFileIds: [], }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'false') }) it('should handle empty keywords string', () => { - // Arrange const props = createDefaultProps({ fileList: [], keywords: '', }) - // Act render(<List {...props} />) // Assert - Shows empty folder, not empty search result @@ -857,65 +720,50 @@ describe('List', () => { describe('Boundary Conditions', () => { it('should handle very long file names', () => { - // Arrange const longName = `${'a'.repeat(500)}.txt` const fileList = [createMockOnlineDriveFile({ name: longName })] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-name-file-1')).toHaveTextContent(longName) }) it('should handle special characters in file names', () => { - // Arrange const specialName = 'test<script>alert("xss")</script>.txt' const fileList = [createMockOnlineDriveFile({ name: specialName })] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-name-file-1')).toHaveTextContent(specialName) }) it('should handle unicode characters in file names', () => { - // Arrange const unicodeName = '文件_📁_ファイル.txt' const fileList = [createMockOnlineDriveFile({ name: unicodeName })] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-name-file-1')).toHaveTextContent(unicodeName) }) it('should handle file with zero size', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ size: 0 })] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toBeInTheDocument() }) it('should handle file with undefined size', () => { - // Arrange const fileList = [createMockOnlineDriveFile({ size: undefined })] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toBeInTheDocument() }) }) @@ -926,20 +774,16 @@ describe('List', () => { { type: OnlineDriveFileType.folder, name: 'Documents' }, { type: OnlineDriveFileType.bucket, name: 'my-bucket' }, ])('should render $type type correctly', ({ type, name }) => { - // Arrange const fileList = [createMockOnlineDriveFile({ id: `item-${type}`, type, name })] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId(`item-item-${type}`)).toBeInTheDocument() expect(screen.getByTestId(`item-name-item-${type}`)).toHaveTextContent(name) }) it('should handle mixed file types in list', () => { - // Arrange const fileList = [ createMockOnlineDriveFile({ id: 'file-1', type: OnlineDriveFileType.file, name: 'doc.pdf' }), createMockOnlineDriveFile({ id: 'folder-1', type: OnlineDriveFileType.folder, name: 'Documents' }), @@ -947,10 +791,8 @@ describe('List', () => { ] const props = createDefaultProps({ fileList }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toBeInTheDocument() expect(screen.getByTestId('item-folder-1')).toBeInTheDocument() expect(screen.getByTestId('item-bucket-1')).toBeInTheDocument() @@ -959,7 +801,6 @@ describe('List', () => { describe('Loading States Transitions', () => { it('should transition from loading to empty folder', () => { - // Arrange const props1 = createDefaultProps({ isLoading: true, fileList: [] }) const props2 = createDefaultProps({ isLoading: false, fileList: [] }) @@ -968,16 +809,13 @@ describe('List', () => { // Assert initial loading state expect(screen.getByRole('status')).toBeInTheDocument() - // Act rerender(<List {...props2} />) - // Assert expect(screen.queryByRole('status')).not.toBeInTheDocument() expect(screen.getByTestId('empty-folder')).toBeInTheDocument() }) it('should transition from loading to file list', () => { - // Arrange const fileList = createMockFileList(2) const props1 = createDefaultProps({ isLoading: true, fileList: [] }) const props2 = createDefaultProps({ isLoading: false, fileList }) @@ -987,16 +825,13 @@ describe('List', () => { // Assert initial loading state expect(screen.getByRole('status')).toBeInTheDocument() - // Act rerender(<List {...props2} />) - // Assert expect(screen.queryByRole('status')).not.toBeInTheDocument() expect(screen.getByTestId('item-file-1')).toBeInTheDocument() }) it('should transition from partial loading to loaded', () => { - // Arrange const fileList = createMockFileList(2) const props1 = createDefaultProps({ isLoading: true, fileList }) const props2 = createDefaultProps({ isLoading: false, fileList }) @@ -1006,17 +841,14 @@ describe('List', () => { // Assert initial partial loading state expect(screen.getByRole('status')).toBeInTheDocument() - // Act rerender(<List {...props2} />) - // Assert expect(screen.queryByRole('status')).not.toBeInTheDocument() }) }) describe('Store State Edge Cases', () => { it('should handle store state with empty next page parameters', () => { - // Arrange mockIsTruncated.current = true mockCurrentNextPageParametersRef.current = {} const fileList = createMockFileList(2) @@ -1026,15 +858,12 @@ describe('List', () => { }) render(<List {...props} />) - // Act triggerIntersection(true) - // Assert expect(mockSetNextPageParameters).toHaveBeenCalledWith({}) }) it('should handle store state with complex next page parameters', () => { - // Arrange const complexParams = { cursor: 'abc123', page: 2, @@ -1049,31 +878,23 @@ describe('List', () => { }) render(<List {...props} />) - // Act triggerIntersection(true) - // Assert expect(mockSetNextPageParameters).toHaveBeenCalledWith(complexParams) }) }) }) - // ========================================== - // All Prop Variations Tests - // ========================================== describe('Prop Variations', () => { it.each([ { supportBatchUpload: true }, { supportBatchUpload: false }, ])('should render correctly with supportBatchUpload=$supportBatchUpload', ({ supportBatchUpload }) => { - // Arrange const fileList = createMockFileList(2) const props = createDefaultProps({ fileList, supportBatchUpload }) - // Act render(<List {...props} />) - // Assert expect(screen.getByTestId('item-file-1')).toHaveAttribute( 'data-multiple-choice', String(supportBatchUpload), @@ -1087,14 +908,11 @@ describe('List', () => { { isLoading: false, fileCount: 0, keywords: 'search', expectedState: 'empty-search' }, { isLoading: false, fileCount: 5, keywords: '', expectedState: 'file-list' }, ])('should render $expectedState when isLoading=$isLoading, fileCount=$fileCount, keywords=$keywords', ({ isLoading, fileCount, keywords, expectedState }) => { - // Arrange const fileList = createMockFileList(fileCount) const props = createDefaultProps({ fileList, isLoading, keywords }) - // Act render(<List {...props} />) - // Assert switch (expectedState) { case 'all-loading': expect(screen.getByRole('status')).toBeInTheDocument() @@ -1120,17 +938,14 @@ describe('List', () => { { selectedCount: 1, expectedSelected: ['file-1'] }, { selectedCount: 3, expectedSelected: ['file-1', 'file-2', 'file-3'] }, ])('should handle $selectedCount selected files', ({ expectedSelected }) => { - // Arrange const fileList = createMockFileList(3) const props = createDefaultProps({ fileList, selectedFileIds: expectedSelected, }) - // Act render(<List {...props} />) - // Assert fileList.forEach((file) => { const isSelected = expectedSelected.includes(file.id) expect(screen.getByTestId(`item-${file.id}`)).toHaveAttribute('data-selected', String(isSelected)) @@ -1138,12 +953,8 @@ describe('List', () => { }) }) - // ========================================== - // Accessibility Tests - // ========================================== describe('Accessibility', () => { it('should allow interaction with reset keywords button in empty search state', () => { - // Arrange const handleResetKeywords = vi.fn() const props = createDefaultProps({ fileList: [], @@ -1151,11 +962,9 @@ describe('List', () => { handleResetKeywords, }) - // Act render(<List {...props} />) const resetButton = screen.getByTestId('reset-keywords-btn') - // Assert expect(resetButton).toBeInTheDocument() fireEvent.click(resetButton) expect(handleResetKeywords).toHaveBeenCalled() @@ -1163,15 +972,13 @@ describe('List', () => { }) }) -// ========================================== // EmptyFolder Component Tests (using actual component) -// ========================================== describe('EmptyFolder', () => { // Get real component for testing let ActualEmptyFolder: React.ComponentType beforeAll(async () => { - const mod = await vi.importActual<{ default: React.ComponentType }>('./empty-folder') + const mod = await vi.importActual<{ default: React.ComponentType }>('../empty-folder') ActualEmptyFolder = mod.default }) @@ -1206,15 +1013,13 @@ describe('EmptyFolder', () => { }) }) -// ========================================== // EmptySearchResult Component Tests (using actual component) -// ========================================== describe('EmptySearchResult', () => { // Get real component for testing let ActualEmptySearchResult: React.ComponentType<{ onResetKeywords: () => void }> beforeAll(async () => { - const mod = await vi.importActual<{ default: React.ComponentType<{ onResetKeywords: () => void }> }>('./empty-search-result') + const mod = await vi.importActual<{ default: React.ComponentType<{ onResetKeywords: () => void }> }>('../empty-search-result') ActualEmptySearchResult = mod.default }) @@ -1292,16 +1097,14 @@ describe('EmptySearchResult', () => { }) }) -// ========================================== // FileIcon Component Tests (using actual component) -// ========================================== describe('FileIcon', () => { // Get real component for testing type FileIconProps = { type: OnlineDriveFileType, fileName: string, size?: 'sm' | 'md' | 'lg' | 'xl', className?: string } let ActualFileIcon: React.ComponentType<FileIconProps> beforeAll(async () => { - const mod = await vi.importActual<{ default: React.ComponentType<FileIconProps> }>('./file-icon') + const mod = await vi.importActual<{ default: React.ComponentType<FileIconProps> }>('../file-icon') ActualFileIcon = mod.default }) @@ -1455,9 +1258,7 @@ describe('FileIcon', () => { }) }) -// ========================================== // Item Component Tests (using actual component) -// ========================================== describe('Item', () => { // Get real component for testing let ActualItem: React.ComponentType<ItemProps> @@ -1472,7 +1273,7 @@ describe('Item', () => { } beforeAll(async () => { - const mod = await vi.importActual<{ default: React.ComponentType<ItemProps> }>('./item') + const mod = await vi.importActual<{ default: React.ComponentType<ItemProps> }>('../item') ActualItem = mod.default }) @@ -1746,9 +1547,7 @@ describe('Item', () => { }) }) -// ========================================== // Utils Tests -// ========================================== describe('utils', () => { // Import actual utils functions let getFileExtension: (filename: string) => string @@ -1756,7 +1555,7 @@ describe('utils', () => { let FileAppearanceTypeEnum: Record<string, string> beforeAll(async () => { - const utils = await vi.importActual<{ getFileExtension: typeof getFileExtension, getFileType: typeof getFileType }>('./utils') + const utils = await vi.importActual<{ getFileExtension: typeof getFileExtension, getFileType: typeof getFileType }>('../utils') const types = await vi.importActual<{ FileAppearanceTypeEnum: typeof FileAppearanceTypeEnum }>('@/app/components/base/file-uploader/types') getFileExtension = utils.getFileExtension getFileType = utils.getFileType diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/item.spec.tsx new file mode 100644 index 0000000000..5da25e5cb0 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/item.spec.tsx @@ -0,0 +1,90 @@ +import type { OnlineDriveFile } from '@/models/pipeline' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Item from '../item' + +vi.mock('@/app/components/base/checkbox', () => ({ + default: ({ checked, onCheck, disabled }: { checked: boolean, onCheck: () => void, disabled?: boolean }) => ( + <input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} disabled={disabled} /> + ), +})) + +vi.mock('@/app/components/base/radio/ui', () => ({ + default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => ( + <input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} /> + ), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( + <div data-testid="tooltip" title={popupContent}>{children}</div> + ), +})) + +vi.mock('../file-icon', () => ({ + default: () => <span data-testid="file-icon" />, +})) + +describe('Item', () => { + const makeFile = (type: string, name = 'test.pdf', size = 1024): OnlineDriveFile => ({ + id: 'f-1', + name, + type: type as OnlineDriveFile['type'], + size, + }) + + const defaultProps = { + file: makeFile('file'), + isSelected: false, + onSelect: vi.fn(), + onOpen: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render file name', () => { + render(<Item {...defaultProps} />) + expect(screen.getByText('test.pdf')).toBeInTheDocument() + }) + + it('should render checkbox for file type in multiple choice mode', () => { + render(<Item {...defaultProps} />) + expect(screen.getByTestId('checkbox')).toBeInTheDocument() + }) + + it('should render radio for file type in single choice mode', () => { + render(<Item {...defaultProps} isMultipleChoice={false} />) + expect(screen.getByTestId('radio')).toBeInTheDocument() + }) + + it('should not render checkbox for bucket type', () => { + render(<Item {...defaultProps} file={makeFile('bucket', 'my-bucket')} />) + expect(screen.queryByTestId('checkbox')).not.toBeInTheDocument() + }) + + it('should call onOpen for folder click', () => { + const file = makeFile('folder', 'my-folder') + render(<Item {...defaultProps} file={file} />) + fireEvent.click(screen.getByText('my-folder')) + expect(defaultProps.onOpen).toHaveBeenCalledWith(file) + }) + + it('should call onSelect for file click', () => { + render(<Item {...defaultProps} />) + fireEvent.click(screen.getByText('test.pdf')) + expect(defaultProps.onSelect).toHaveBeenCalledWith(defaultProps.file) + }) + + it('should not call handlers when disabled', () => { + render(<Item {...defaultProps} disabled={true} />) + fireEvent.click(screen.getByText('test.pdf')) + expect(defaultProps.onSelect).not.toHaveBeenCalled() + }) + + it('should render file icon', () => { + render(<Item {...defaultProps} />) + expect(screen.getByTestId('file-icon')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/utils.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/utils.spec.ts new file mode 100644 index 0000000000..982e57a1d0 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/__tests__/utils.spec.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types' +import { getFileExtension, getFileType } from '../utils' + +describe('getFileExtension', () => { + it('should return extension for normal file', () => { + expect(getFileExtension('test.pdf')).toBe('pdf') + }) + + it('should return lowercase extension', () => { + expect(getFileExtension('test.PDF')).toBe('pdf') + }) + + it('should return last extension for multiple dots', () => { + expect(getFileExtension('my.file.name.txt')).toBe('txt') + }) + + it('should return empty string for no extension', () => { + expect(getFileExtension('noext')).toBe('') + }) + + it('should return empty string for empty string', () => { + expect(getFileExtension('')).toBe('') + }) + + it('should return empty string for dotfile with no extension', () => { + expect(getFileExtension('.gitignore')).toBe('') + }) +}) + +describe('getFileType', () => { + it('should return pdf for .pdf files', () => { + expect(getFileType('doc.pdf')).toBe(FileAppearanceTypeEnum.pdf) + }) + + it('should return markdown for .md files', () => { + expect(getFileType('readme.md')).toBe(FileAppearanceTypeEnum.markdown) + }) + + it('should return markdown for .mdx files', () => { + expect(getFileType('page.mdx')).toBe(FileAppearanceTypeEnum.markdown) + }) + + it('should return excel for .xlsx files', () => { + expect(getFileType('data.xlsx')).toBe(FileAppearanceTypeEnum.excel) + }) + + it('should return excel for .csv files', () => { + expect(getFileType('data.csv')).toBe(FileAppearanceTypeEnum.excel) + }) + + it('should return word for .docx files', () => { + expect(getFileType('doc.docx')).toBe(FileAppearanceTypeEnum.word) + }) + + it('should return ppt for .pptx files', () => { + expect(getFileType('slides.pptx')).toBe(FileAppearanceTypeEnum.ppt) + }) + + it('should return code for .html files', () => { + expect(getFileType('page.html')).toBe(FileAppearanceTypeEnum.code) + }) + + it('should return code for .json files', () => { + expect(getFileType('config.json')).toBe(FileAppearanceTypeEnum.code) + }) + + it('should return gif for .gif files', () => { + expect(getFileType('animation.gif')).toBe(FileAppearanceTypeEnum.gif) + }) + + it('should return custom for unknown extension', () => { + expect(getFileType('file.xyz')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom for no extension', () => { + expect(getFileType('noext')).toBe(FileAppearanceTypeEnum.custom) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-folder.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-folder.spec.tsx deleted file mode 100644 index cfbd2a7d56..0000000000 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-folder.spec.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import EmptyFolder from './empty-folder' - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - -afterEach(() => { - cleanup() -}) - -describe('EmptyFolder', () => { - it('should render without crashing', () => { - render(<EmptyFolder />) - expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument() - }) - - it('should render the empty folder text', () => { - render(<EmptyFolder />) - expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument() - }) - - it('should have proper styling classes', () => { - const { container } = render(<EmptyFolder />) - const wrapper = container.firstChild as HTMLElement - expect(wrapper).toHaveClass('flex') - expect(wrapper).toHaveClass('items-center') - expect(wrapper).toHaveClass('justify-center') - }) - - it('should be wrapped with React.memo', () => { - expect((EmptyFolder as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo')) - }) -}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/index.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/index.spec.ts new file mode 100644 index 0000000000..231cdcdfc2 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/index.spec.ts @@ -0,0 +1,96 @@ +import type { FileItem } from '@/models/datasets' +import { render, renderHook } from '@testing-library/react' +import * as React from 'react' +import { describe, expect, it } from 'vitest' +import { createDataSourceStore, useDataSourceStore, useDataSourceStoreWithSelector } from '../' +import DataSourceProvider from '../provider' + +describe('createDataSourceStore', () => { + it('should create a store with all slices combined', () => { + const store = createDataSourceStore() + const state = store.getState() + + // Common slice + expect(state.currentCredentialId).toBe('') + expect(typeof state.setCurrentCredentialId).toBe('function') + + // LocalFile slice + expect(state.localFileList).toEqual([]) + expect(typeof state.setLocalFileList).toBe('function') + + // OnlineDocument slice + expect(state.documentsData).toEqual([]) + expect(typeof state.setDocumentsData).toBe('function') + + // WebsiteCrawl slice + expect(state.websitePages).toEqual([]) + expect(typeof state.setWebsitePages).toBe('function') + + // OnlineDrive slice + expect(state.breadcrumbs).toEqual([]) + expect(typeof state.setBreadcrumbs).toBe('function') + }) + + it('should allow cross-slice state updates', () => { + const store = createDataSourceStore() + + store.getState().setCurrentCredentialId('cred-1') + store.getState().setLocalFileList([{ file: { id: 'f1' } }] as unknown as FileItem[]) + + expect(store.getState().currentCredentialId).toBe('cred-1') + expect(store.getState().localFileList).toHaveLength(1) + }) + + it('should create independent store instances', () => { + const store1 = createDataSourceStore() + const store2 = createDataSourceStore() + + store1.getState().setCurrentCredentialId('cred-1') + expect(store2.getState().currentCredentialId).toBe('') + }) +}) + +describe('useDataSourceStoreWithSelector', () => { + it('should throw when used outside provider', () => { + expect(() => { + renderHook(() => useDataSourceStoreWithSelector(s => s.currentCredentialId)) + }).toThrow('Missing DataSourceContext.Provider in the tree') + }) + + it('should return selected state when used inside provider', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(DataSourceProvider, null, children) + const { result } = renderHook( + () => useDataSourceStoreWithSelector(s => s.currentCredentialId), + { wrapper }, + ) + expect(result.current).toBe('') + }) +}) + +describe('useDataSourceStore', () => { + it('should throw when used outside provider', () => { + expect(() => { + renderHook(() => useDataSourceStore()) + }).toThrow('Missing DataSourceContext.Provider in the tree') + }) + + it('should return store when used inside provider', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(DataSourceProvider, null, children) + const { result } = renderHook( + () => useDataSourceStore(), + { wrapper }, + ) + expect(result.current).toBeDefined() + expect(typeof result.current.getState).toBe('function') + }) +}) + +describe('DataSourceProvider', () => { + it('should render children', () => { + const child = React.createElement('div', null, 'Child Content') + const { getByText } = render(React.createElement(DataSourceProvider, null, child)) + expect(getByText('Child Content')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/provider.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/provider.spec.tsx new file mode 100644 index 0000000000..7796c83e17 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/__tests__/provider.spec.tsx @@ -0,0 +1,89 @@ +import { render, screen } from '@testing-library/react' +import { useContext } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import DataSourceProvider, { DataSourceContext } from '../provider' + +const mockStore = { getState: vi.fn(), setState: vi.fn(), subscribe: vi.fn() } + +vi.mock('../', () => ({ + createDataSourceStore: () => mockStore, +})) + +// Test consumer component that reads from context +function ContextConsumer() { + const store = useContext(DataSourceContext) + return ( + <div data-testid="context-value" data-has-store={store !== null}> + {store ? 'has-store' : 'no-store'} + </div> + ) +} + +describe('DataSourceProvider', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verifies children are passed through + describe('Rendering', () => { + it('should render children', () => { + render( + <DataSourceProvider> + <span data-testid="child">Hello</span> + </DataSourceProvider>, + ) + + expect(screen.getByTestId('child')).toBeInTheDocument() + expect(screen.getByText('Hello')).toBeInTheDocument() + }) + }) + + // Context: verifies the store is provided to consumers + describe('Context', () => { + it('should provide store value to context consumers', () => { + render( + <DataSourceProvider> + <ContextConsumer /> + </DataSourceProvider>, + ) + + expect(screen.getByTestId('context-value')).toHaveTextContent('has-store') + expect(screen.getByTestId('context-value')).toHaveAttribute('data-has-store', 'true') + }) + + it('should provide null when no provider wraps the consumer', () => { + render(<ContextConsumer />) + + expect(screen.getByTestId('context-value')).toHaveTextContent('no-store') + expect(screen.getByTestId('context-value')).toHaveAttribute('data-has-store', 'false') + }) + }) + + // Stability: verifies the store reference is stable across re-renders + describe('Store Stability', () => { + it('should reuse same store on re-render (stable reference)', () => { + const storeValues: Array<typeof mockStore | null> = [] + + function StoreCapture() { + const store = useContext(DataSourceContext) + storeValues.push(store as typeof mockStore | null) + return null + } + + const { rerender } = render( + <DataSourceProvider> + <StoreCapture /> + </DataSourceProvider>, + ) + + rerender( + <DataSourceProvider> + <StoreCapture /> + </DataSourceProvider>, + ) + + expect(storeValues).toHaveLength(2) + expect(storeValues[0]).toBe(storeValues[1]) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/common.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/common.spec.ts new file mode 100644 index 0000000000..b18b7925f2 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/common.spec.ts @@ -0,0 +1,29 @@ +import type { CommonShape } from '../common' +import { describe, expect, it } from 'vitest' +import { createStore } from 'zustand' +import { createCommonSlice } from '../common' + +const createTestStore = () => createStore<CommonShape>((...args) => createCommonSlice(...args)) + +describe('createCommonSlice', () => { + it('should initialize with default values', () => { + const state = createTestStore().getState() + + expect(state.currentCredentialId).toBe('') + expect(state.currentNodeIdRef.current).toBe('') + expect(state.currentCredentialIdRef.current).toBe('') + }) + + it('should update currentCredentialId', () => { + const store = createTestStore() + store.getState().setCurrentCredentialId('cred-123') + expect(store.getState().currentCredentialId).toBe('cred-123') + }) + + it('should update currentCredentialId multiple times', () => { + const store = createTestStore() + store.getState().setCurrentCredentialId('cred-1') + store.getState().setCurrentCredentialId('cred-2') + expect(store.getState().currentCredentialId).toBe('cred-2') + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/local-file.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/local-file.spec.ts new file mode 100644 index 0000000000..f3ae03acde --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/local-file.spec.ts @@ -0,0 +1,49 @@ +import type { LocalFileSliceShape } from '../local-file' +import type { CustomFile as File, FileItem } from '@/models/datasets' +import { describe, expect, it } from 'vitest' +import { createStore } from 'zustand' +import { createLocalFileSlice } from '../local-file' + +const createTestStore = () => createStore<LocalFileSliceShape>((...args) => createLocalFileSlice(...args)) + +describe('createLocalFileSlice', () => { + it('should initialize with default values', () => { + const state = createTestStore().getState() + + expect(state.localFileList).toEqual([]) + expect(state.currentLocalFile).toBeUndefined() + expect(state.previewLocalFileRef.current).toBeUndefined() + }) + + it('should set local file list and update preview ref to first file', () => { + const store = createTestStore() + const files = [ + { file: { id: 'f1', name: 'a.pdf' } }, + { file: { id: 'f2', name: 'b.pdf' } }, + ] as unknown as FileItem[] + + store.getState().setLocalFileList(files) + expect(store.getState().localFileList).toEqual(files) + expect(store.getState().previewLocalFileRef.current).toEqual({ id: 'f1', name: 'a.pdf' }) + }) + + it('should set preview ref to undefined for empty file list', () => { + const store = createTestStore() + store.getState().setLocalFileList([]) + expect(store.getState().previewLocalFileRef.current).toBeUndefined() + }) + + it('should set current local file', () => { + const store = createTestStore() + const file = { id: 'f1', name: 'test.pdf' } as unknown as File + store.getState().setCurrentLocalFile(file) + expect(store.getState().currentLocalFile).toEqual(file) + }) + + it('should clear current local file with undefined', () => { + const store = createTestStore() + store.getState().setCurrentLocalFile({ id: 'f1' } as unknown as File) + store.getState().setCurrentLocalFile(undefined) + expect(store.getState().currentLocalFile).toBeUndefined() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-document.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-document.spec.ts new file mode 100644 index 0000000000..a98f56c19c --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-document.spec.ts @@ -0,0 +1,55 @@ +import type { OnlineDocumentSliceShape } from '../online-document' +import type { DataSourceNotionWorkspace, NotionPage } from '@/models/common' +import { describe, expect, it } from 'vitest' +import { createStore } from 'zustand' +import { createOnlineDocumentSlice } from '../online-document' + +const createTestStore = () => createStore<OnlineDocumentSliceShape>((...args) => createOnlineDocumentSlice(...args)) + +describe('createOnlineDocumentSlice', () => { + it('should initialize with default values', () => { + const state = createTestStore().getState() + + expect(state.documentsData).toEqual([]) + expect(state.searchValue).toBe('') + expect(state.onlineDocuments).toEqual([]) + expect(state.currentDocument).toBeUndefined() + expect(state.selectedPagesId).toEqual(new Set()) + expect(state.previewOnlineDocumentRef.current).toBeUndefined() + }) + + it('should set documents data', () => { + const store = createTestStore() + const data = [{ workspace_id: 'w1', pages: [] }] as unknown as DataSourceNotionWorkspace[] + store.getState().setDocumentsData(data) + expect(store.getState().documentsData).toEqual(data) + }) + + it('should set search value', () => { + const store = createTestStore() + store.getState().setSearchValue('hello') + expect(store.getState().searchValue).toBe('hello') + }) + + it('should set online documents and update preview ref', () => { + const store = createTestStore() + const pages = [{ page_id: 'p1' }, { page_id: 'p2' }] as unknown as NotionPage[] + store.getState().setOnlineDocuments(pages) + expect(store.getState().onlineDocuments).toEqual(pages) + expect(store.getState().previewOnlineDocumentRef.current).toEqual({ page_id: 'p1' }) + }) + + it('should set current document', () => { + const store = createTestStore() + const doc = { page_id: 'p1' } as unknown as NotionPage + store.getState().setCurrentDocument(doc) + expect(store.getState().currentDocument).toEqual(doc) + }) + + it('should set selected pages id', () => { + const store = createTestStore() + const ids = new Set(['p1', 'p2']) + store.getState().setSelectedPagesId(ids) + expect(store.getState().selectedPagesId).toEqual(ids) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-drive.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-drive.spec.ts new file mode 100644 index 0000000000..f0b61a62a2 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/online-drive.spec.ts @@ -0,0 +1,79 @@ +import type { OnlineDriveSliceShape } from '../online-drive' +import type { OnlineDriveFile } from '@/models/pipeline' +import { describe, expect, it } from 'vitest' +import { createStore } from 'zustand' +import { createOnlineDriveSlice } from '../online-drive' + +const createTestStore = () => createStore<OnlineDriveSliceShape>((...args) => createOnlineDriveSlice(...args)) + +describe('createOnlineDriveSlice', () => { + it('should initialize with default values', () => { + const state = createTestStore().getState() + + expect(state.breadcrumbs).toEqual([]) + expect(state.prefix).toEqual([]) + expect(state.keywords).toBe('') + expect(state.selectedFileIds).toEqual([]) + expect(state.onlineDriveFileList).toEqual([]) + expect(state.bucket).toBe('') + expect(state.nextPageParameters).toEqual({}) + expect(state.isTruncated.current).toBe(false) + expect(state.previewOnlineDriveFileRef.current).toBeUndefined() + expect(state.hasBucket).toBe(false) + }) + + it('should set breadcrumbs', () => { + const store = createTestStore() + store.getState().setBreadcrumbs(['root', 'folder']) + expect(store.getState().breadcrumbs).toEqual(['root', 'folder']) + }) + + it('should set prefix', () => { + const store = createTestStore() + store.getState().setPrefix(['a', 'b']) + expect(store.getState().prefix).toEqual(['a', 'b']) + }) + + it('should set keywords', () => { + const store = createTestStore() + store.getState().setKeywords('search term') + expect(store.getState().keywords).toBe('search term') + }) + + it('should set selected file ids and update preview ref', () => { + const store = createTestStore() + const files = [ + { id: 'file-1', name: 'a.pdf', type: 'file' }, + { id: 'file-2', name: 'b.pdf', type: 'file' }, + ] as unknown as OnlineDriveFile[] + store.getState().setOnlineDriveFileList(files) + store.getState().setSelectedFileIds(['file-1']) + + expect(store.getState().selectedFileIds).toEqual(['file-1']) + expect(store.getState().previewOnlineDriveFileRef.current).toEqual(files[0]) + }) + + it('should set preview ref to undefined when selected id not found', () => { + const store = createTestStore() + store.getState().setSelectedFileIds(['non-existent']) + expect(store.getState().previewOnlineDriveFileRef.current).toBeUndefined() + }) + + it('should set bucket', () => { + const store = createTestStore() + store.getState().setBucket('my-bucket') + expect(store.getState().bucket).toBe('my-bucket') + }) + + it('should set next page parameters', () => { + const store = createTestStore() + store.getState().setNextPageParameters({ cursor: 'abc' }) + expect(store.getState().nextPageParameters).toEqual({ cursor: 'abc' }) + }) + + it('should set hasBucket', () => { + const store = createTestStore() + store.getState().setHasBucket(true) + expect(store.getState().hasBucket).toBe(true) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/website-crawl.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/website-crawl.spec.ts new file mode 100644 index 0000000000..a81ef61c03 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/store/slices/__tests__/website-crawl.spec.ts @@ -0,0 +1,65 @@ +import type { WebsiteCrawlSliceShape } from '../website-crawl' +import type { CrawlResult, CrawlResultItem } from '@/models/datasets' +import { describe, expect, it } from 'vitest' +import { createStore } from 'zustand' +import { CrawlStep } from '@/models/datasets' +import { createWebsiteCrawlSlice } from '../website-crawl' + +const createTestStore = () => createStore<WebsiteCrawlSliceShape>((...args) => createWebsiteCrawlSlice(...args)) + +describe('createWebsiteCrawlSlice', () => { + it('should initialize with default values', () => { + const state = createTestStore().getState() + + expect(state.websitePages).toEqual([]) + expect(state.currentWebsite).toBeUndefined() + expect(state.crawlResult).toBeUndefined() + expect(state.step).toBe(CrawlStep.init) + expect(state.previewIndex).toBe(-1) + expect(state.previewWebsitePageRef.current).toBeUndefined() + }) + + it('should set website pages and update preview ref', () => { + const store = createTestStore() + const pages = [ + { title: 'Page 1', source_url: 'https://a.com' }, + { title: 'Page 2', source_url: 'https://b.com' }, + ] as unknown as CrawlResultItem[] + store.getState().setWebsitePages(pages) + expect(store.getState().websitePages).toEqual(pages) + expect(store.getState().previewWebsitePageRef.current).toEqual(pages[0]) + }) + + it('should set current website', () => { + const store = createTestStore() + const website = { title: 'Page 1' } as unknown as CrawlResultItem + store.getState().setCurrentWebsite(website) + expect(store.getState().currentWebsite).toEqual(website) + }) + + it('should set crawl result', () => { + const store = createTestStore() + const result = { data: { count: 5 } } as unknown as CrawlResult + store.getState().setCrawlResult(result) + expect(store.getState().crawlResult).toEqual(result) + }) + + it('should set step', () => { + const store = createTestStore() + store.getState().setStep(CrawlStep.running) + expect(store.getState().step).toBe(CrawlStep.running) + }) + + it('should set preview index', () => { + const store = createTestStore() + store.getState().setPreviewIndex(3) + expect(store.getState().previewIndex).toBe(3) + }) + + it('should clear current website with undefined', () => { + const store = createTestStore() + store.getState().setCurrentWebsite({ title: 'X' } as unknown as CrawlResultItem) + store.getState().setCurrentWebsite(undefined) + expect(store.getState().currentWebsite).toBeUndefined() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx similarity index 86% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx index 493dd25730..576edbaf96 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/__tests__/index.spec.tsx @@ -4,13 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { CrawlStep } from '@/models/datasets' -import WebsiteCrawl from './index' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import WebsiteCrawl from '../index' // Mock useDocLink - context hook requires mocking const mockDocLink = vi.fn((path?: string) => `https://docs.example.com${path || ''}`) @@ -21,13 +15,13 @@ vi.mock('@/context/i18n', () => ({ // Mock dataset-detail context - context provider requires mocking let mockPipelineId: string | undefined = 'pipeline-123' vi.mock('@/context/dataset-detail', () => ({ - useDatasetDetailContextWithSelector: (selector: (s: any) => any) => selector({ dataset: { pipeline_id: mockPipelineId } }), + useDatasetDetailContextWithSelector: (selector: (s: { dataset: { pipeline_id: string | undefined } }) => unknown) => selector({ dataset: { pipeline_id: mockPipelineId } }), })) // Mock modal context - context provider requires mocking const mockSetShowAccountSettingModal = vi.fn() vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: (selector: (s: any) => any) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), + useModalContextSelector: (selector: (s: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => unknown) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }), })) // Mock ssePost - API service requires mocking @@ -61,7 +55,6 @@ vi.mock('@/service/use-pipeline', () => ({ // Note: zustand/react/shallow useShallow is imported directly (simple utility function) -// Mock store const mockStoreState = { crawlResult: undefined as { data: CrawlResultItem[], time_consuming: number | string } | undefined, step: CrawlStep.init, @@ -78,39 +71,39 @@ const mockStoreState = { const mockGetState = vi.fn(() => mockStoreState) const mockDataSourceStore = { getState: mockGetState } -vi.mock('../store', () => ({ - useDataSourceStoreWithSelector: (selector: (s: any) => any) => selector(mockStoreState), +vi.mock('../../store', () => ({ + useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState), useDataSourceStore: () => mockDataSourceStore, })) // Mock Header component -vi.mock('../base/header', () => ({ - default: (props: any) => ( +vi.mock('../../base/header', () => ({ + default: (props: Record<string, unknown>) => ( <div data-testid="header"> - <span data-testid="header-doc-title">{props.docTitle}</span> - <span data-testid="header-doc-link">{props.docLink}</span> - <span data-testid="header-plugin-name">{props.pluginName}</span> - <span data-testid="header-credential-id">{props.currentCredentialId}</span> - <button data-testid="header-config-btn" onClick={props.onClickConfiguration}>Configure</button> - <button data-testid="header-credential-change" onClick={() => props.onCredentialChange('new-cred-id')}>Change Credential</button> - <span data-testid="header-credentials-count">{props.credentials?.length || 0}</span> + <span data-testid="header-doc-title">{props.docTitle as string}</span> + <span data-testid="header-doc-link">{props.docLink as string}</span> + <span data-testid="header-plugin-name">{props.pluginName as string}</span> + <span data-testid="header-credential-id">{props.currentCredentialId as string}</span> + <button data-testid="header-config-btn" onClick={props.onClickConfiguration as () => void}>Configure</button> + <button data-testid="header-credential-change" onClick={() => (props.onCredentialChange as (id: string) => void)('new-cred-id')}>Change Credential</button> + <span data-testid="header-credentials-count">{(props.credentials as unknown[] | undefined)?.length || 0}</span> </div> ), })) // Mock Options component const mockOptionsSubmit = vi.fn() -vi.mock('./base/options', () => ({ - default: (props: any) => ( +vi.mock('../base/options', () => ({ + default: (props: Record<string, unknown>) => ( <div data-testid="options"> - <span data-testid="options-step">{props.step}</span> + <span data-testid="options-step">{props.step as string}</span> <span data-testid="options-run-disabled">{String(props.runDisabled)}</span> - <span data-testid="options-variables-count">{props.variables?.length || 0}</span> + <span data-testid="options-variables-count">{(props.variables as unknown[] | undefined)?.length || 0}</span> <button data-testid="options-submit-btn" onClick={() => { mockOptionsSubmit() - props.onSubmit({ url: 'https://example.com', depth: 2 }) + ;(props.onSubmit as (v: Record<string, unknown>) => void)({ url: 'https://example.com', depth: 2 }) }} > Submit @@ -120,44 +113,44 @@ vi.mock('./base/options', () => ({ })) // Mock Crawling component -vi.mock('./base/crawling', () => ({ - default: (props: any) => ( +vi.mock('../base/crawling', () => ({ + default: (props: Record<string, unknown>) => ( <div data-testid="crawling"> - <span data-testid="crawling-crawled-num">{props.crawledNum}</span> - <span data-testid="crawling-total-num">{props.totalNum}</span> + <span data-testid="crawling-crawled-num">{props.crawledNum as number}</span> + <span data-testid="crawling-total-num">{props.totalNum as number}</span> </div> ), })) // Mock ErrorMessage component -vi.mock('./base/error-message', () => ({ - default: (props: any) => ( - <div data-testid="error-message" className={props.className}> - <span data-testid="error-title">{props.title}</span> - <span data-testid="error-msg">{props.errorMsg}</span> +vi.mock('../base/error-message', () => ({ + default: (props: Record<string, unknown>) => ( + <div data-testid="error-message" className={props.className as string}> + <span data-testid="error-title">{props.title as string}</span> + <span data-testid="error-msg">{props.errorMsg as string}</span> </div> ), })) // Mock CrawledResult component -vi.mock('./base/crawled-result', () => ({ - default: (props: any) => ( - <div data-testid="crawled-result" className={props.className}> - <span data-testid="crawled-result-count">{props.list?.length || 0}</span> - <span data-testid="crawled-result-checked-count">{props.checkedList?.length || 0}</span> - <span data-testid="crawled-result-used-time">{props.usedTime}</span> - <span data-testid="crawled-result-preview-index">{props.previewIndex}</span> +vi.mock('../base/crawled-result', () => ({ + default: (props: Record<string, unknown>) => ( + <div data-testid="crawled-result" className={props.className as string}> + <span data-testid="crawled-result-count">{(props.list as unknown[] | undefined)?.length || 0}</span> + <span data-testid="crawled-result-checked-count">{(props.checkedList as unknown[] | undefined)?.length || 0}</span> + <span data-testid="crawled-result-used-time">{props.usedTime as number}</span> + <span data-testid="crawled-result-preview-index">{props.previewIndex as number}</span> <span data-testid="crawled-result-show-preview">{String(props.showPreview)}</span> <span data-testid="crawled-result-multiple-choice">{String(props.isMultipleChoice)}</span> <button data-testid="crawled-result-select-change" - onClick={() => props.onSelectedChange([{ source_url: 'https://example.com', title: 'Test' }])} + onClick={() => (props.onSelectedChange as (v: { source_url: string, title: string }[]) => void)([{ source_url: 'https://example.com', title: 'Test' }])} > Change Selection </button> <button data-testid="crawled-result-preview" - onClick={() => props.onPreview?.({ source_url: 'https://example.com', title: 'Test' }, 0)} + onClick={() => (props.onPreview as ((item: { source_url: string, title: string }, idx: number) => void) | undefined)?.({ source_url: 'https://example.com', title: 'Test' }, 0)} > Preview </button> @@ -165,9 +158,6 @@ vi.mock('./base/crawled-result', () => ({ ), })) -// ========================================== -// Test Data Builders -// ========================================== const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({ title: 'Test Node', plugin_id: 'plugin-123', @@ -209,9 +199,6 @@ const createDefaultProps = (overrides?: Partial<WebsiteCrawlProps>): WebsiteCraw ...overrides, }) -// ========================================== -// Test Suites -// ========================================== describe('WebsiteCrawl', () => { beforeEach(() => { vi.clearAllMocks() @@ -250,81 +237,62 @@ describe('WebsiteCrawl', () => { mockGetState.mockReturnValue(mockStoreState) }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('header')).toBeInTheDocument() expect(screen.getByTestId('options')).toBeInTheDocument() }) it('should render Header with correct props', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-123' const props = createDefaultProps({ nodeData: createMockNodeData({ datasource_label: 'My Website Crawler' }), }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('header-doc-title')).toHaveTextContent('Docs') expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('My Website Crawler') expect(screen.getByTestId('header-credential-id')).toHaveTextContent('cred-123') }) it('should render Options with correct props', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('options')).toBeInTheDocument() expect(screen.getByTestId('options-step')).toHaveTextContent(CrawlStep.init) }) it('should not render Crawling or CrawledResult when step is init', () => { - // Arrange mockStoreState.step = CrawlStep.init const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.queryByTestId('crawling')).not.toBeInTheDocument() expect(screen.queryByTestId('crawled-result')).not.toBeInTheDocument() expect(screen.queryByTestId('error-message')).not.toBeInTheDocument() }) it('should render Crawling when step is running', () => { - // Arrange mockStoreState.step = CrawlStep.running const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawling')).toBeInTheDocument() expect(screen.queryByTestId('crawled-result')).not.toBeInTheDocument() expect(screen.queryByTestId('error-message')).not.toBeInTheDocument() }) it('should render CrawledResult when step is finished with no error', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -332,30 +300,23 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result')).toBeInTheDocument() expect(screen.queryByTestId('crawling')).not.toBeInTheDocument() expect(screen.queryByTestId('error-message')).not.toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('nodeId prop', () => { it('should use nodeId in datasourceNodeRunURL for non-pipeline mode', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps({ nodeId: 'custom-node-id', isInPipeline: false, }) - // Act render(<WebsiteCrawl {...props} />) // Assert - Options uses nodeId through usePreProcessingParams @@ -368,17 +329,14 @@ describe('WebsiteCrawl', () => { describe('nodeData prop', () => { it('should pass plugin_id and provider_name to useGetDataSourceAuth', () => { - // Arrange const nodeData = createMockNodeData({ plugin_id: 'my-plugin-id', provider_name: 'my-provider', }) const props = createDefaultProps({ nodeData }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ pluginId: 'my-plugin-id', provider: 'my-provider', @@ -386,47 +344,37 @@ describe('WebsiteCrawl', () => { }) it('should pass datasource_label to Header as pluginName', () => { - // Arrange const nodeData = createMockNodeData({ datasource_label: 'Custom Website Scraper', }) const props = createDefaultProps({ nodeData }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('Custom Website Scraper') }) }) describe('isInPipeline prop', () => { it('should use draft URL when isInPipeline is true', () => { - // Arrange const props = createDefaultProps({ isInPipeline: true }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(mockUseDraftPipelinePreProcessingParams).toHaveBeenCalled() expect(mockUsePublishedPipelinePreProcessingParams).not.toHaveBeenCalled() }) it('should use published URL when isInPipeline is false', () => { - // Arrange const props = createDefaultProps({ isInPipeline: false }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(mockUsePublishedPipelinePreProcessingParams).toHaveBeenCalled() expect(mockUseDraftPipelinePreProcessingParams).not.toHaveBeenCalled() }) it('should pass showPreview as false to CrawledResult when isInPipeline is true', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -434,15 +382,12 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps({ isInPipeline: true }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-show-preview')).toHaveTextContent('false') }) it('should pass showPreview as true to CrawledResult when isInPipeline is false', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -450,17 +395,14 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps({ isInPipeline: false }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-show-preview')).toHaveTextContent('true') }) }) describe('supportBatchUpload prop', () => { it('should pass isMultipleChoice as true to CrawledResult when supportBatchUpload is true', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -468,15 +410,12 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps({ supportBatchUpload: true }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent('true') }) it('should pass isMultipleChoice as false to CrawledResult when supportBatchUpload is false', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -484,10 +423,8 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps({ supportBatchUpload: false }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent('false') }) @@ -496,7 +433,6 @@ describe('WebsiteCrawl', () => { [false, 'false'], [undefined, 'true'], // Default value ])('should handle supportBatchUpload=%s correctly', (value, expected) => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -504,40 +440,30 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps({ supportBatchUpload: value }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent(expected) }) }) describe('onCredentialChange prop', () => { it('should call onCredentialChange with credential id and reset state', () => { - // Arrange const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) - // Act render(<WebsiteCrawl {...props} />) fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) }) }) - // ========================================== - // State Management Tests - // ========================================== describe('State Management', () => { it('should display correct crawledNum and totalNum when running', () => { - // Arrange mockStoreState.step = CrawlStep.running const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) // Assert - Initial state is 0/0 @@ -546,7 +472,6 @@ describe('WebsiteCrawl', () => { }) it('should update step and result via ssePost callbacks', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockCrawlData: CrawlResultItem[] = [ createMockCrawlResultItem({ source_url: 'https://example.com/1' }), @@ -572,7 +497,6 @@ describe('WebsiteCrawl', () => { // Act - Trigger submit fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.running) expect(mockStoreState.setCrawlResult).toHaveBeenCalledWith({ @@ -584,19 +508,15 @@ describe('WebsiteCrawl', () => { }) it('should pass runDisabled as true when no credential is selected', () => { - // Arrange mockStoreState.currentCredentialId = '' const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('options-run-disabled')).toHaveTextContent('true') }) it('should pass runDisabled as true when params are being fetched', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockUsePublishedPipelinePreProcessingParams.mockReturnValue({ data: { variables: [] }, @@ -604,15 +524,12 @@ describe('WebsiteCrawl', () => { }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('options-run-disabled')).toHaveTextContent('true') }) it('should pass runDisabled as false when credential is selected and params are loaded', () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockUsePublishedPipelinePreProcessingParams.mockReturnValue({ data: { variables: [] }, @@ -620,20 +537,15 @@ describe('WebsiteCrawl', () => { }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('options-run-disabled')).toHaveTextContent('false') }) }) - // ========================================== // Callback Stability and Memoization - // ========================================== describe('Callback Stability and Memoization', () => { it('should have stable handleCheckedCrawlResultChange that updates store', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -642,17 +554,14 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('crawled-result-select-change')) - // Assert expect(mockStoreState.setWebsitePages).toHaveBeenCalledWith([ { source_url: 'https://example.com', title: 'Test' }, ]) }) it('should have stable handlePreview that updates store', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -661,10 +570,8 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('crawled-result-preview')) - // Assert expect(mockStoreState.setCurrentWebsite).toHaveBeenCalledWith({ source_url: 'https://example.com', title: 'Test', @@ -673,47 +580,36 @@ describe('WebsiteCrawl', () => { }) it('should have stable handleSetting callback', () => { - // Arrange const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('header-config-btn')) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }) it('should have stable handleCredentialChange that resets state', () => { - // Arrange const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) }) - // ========================================== // User Interactions and Event Handlers - // ========================================== describe('User Interactions and Event Handlers', () => { it('should handle submit and trigger ssePost', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalled() expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.running) @@ -721,34 +617,27 @@ describe('WebsiteCrawl', () => { }) it('should handle configuration button click', () => { - // Arrange const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('header-config-btn')) - // Assert expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, }) }) it('should handle credential change', () => { - // Arrange const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) it('should handle selection change in CrawledResult', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -757,15 +646,12 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('crawled-result-select-change')) - // Assert expect(mockStoreState.setWebsitePages).toHaveBeenCalled() }) it('should handle preview in CrawledResult', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -774,21 +660,16 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('crawled-result-preview')) - // Assert expect(mockStoreState.setCurrentWebsite).toHaveBeenCalled() expect(mockStoreState.setPreviewIndex).toHaveBeenCalled() }) }) - // ========================================== // API Calls Mocking - // ========================================== describe('API Calls', () => { it('should call ssePost with correct parameters for published workflow', async () => { - // Arrange mockStoreState.currentCredentialId = 'test-cred' mockPipelineId = 'pipeline-456' const props = createDefaultProps({ @@ -797,10 +678,8 @@ describe('WebsiteCrawl', () => { }) render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalledWith( '/rag/pipelines/pipeline-456/workflows/published/datasource/nodes/node-789/run', @@ -818,7 +697,6 @@ describe('WebsiteCrawl', () => { }) it('should call ssePost with correct parameters for draft workflow', async () => { - // Arrange mockStoreState.currentCredentialId = 'test-cred' mockPipelineId = 'pipeline-456' const props = createDefaultProps({ @@ -827,10 +705,8 @@ describe('WebsiteCrawl', () => { }) render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalledWith( '/rag/pipelines/pipeline-456/workflows/draft/datasource/nodes/node-789/run', @@ -841,7 +717,6 @@ describe('WebsiteCrawl', () => { }) it('should handle onDataSourceNodeProcessing callback correctly', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockStoreState.step = CrawlStep.running @@ -855,21 +730,18 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() const { rerender } = render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) // Update store state to simulate running step mockStoreState.step = CrawlStep.running rerender(<WebsiteCrawl {...props} />) - // Assert await waitFor(() => { expect(mockSsePost).toHaveBeenCalled() }) }) it('should handle onDataSourceNodeCompleted callback correctly', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockCrawlData: CrawlResultItem[] = [ createMockCrawlResultItem({ source_url: 'https://example.com/1' }), @@ -886,10 +758,8 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockStoreState.setCrawlResult).toHaveBeenCalledWith({ data: mockCrawlData, @@ -901,7 +771,6 @@ describe('WebsiteCrawl', () => { }) it('should handle onDataSourceNodeCompleted with single result when supportBatchUpload is false', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockCrawlData: CrawlResultItem[] = [ createMockCrawlResultItem({ source_url: 'https://example.com/1' }), @@ -919,10 +788,8 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps({ supportBatchUpload: false }) render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { // Should only select first item when supportBatchUpload is false expect(mockStoreState.setWebsitePages).toHaveBeenCalledWith([mockCrawlData[0]]) @@ -930,7 +797,6 @@ describe('WebsiteCrawl', () => { }) it('should handle onDataSourceNodeError callback correctly', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -942,27 +808,22 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished) }) }) it('should use useGetDataSourceAuth with correct parameters', () => { - // Arrange const nodeData = createMockNodeData({ plugin_id: 'website-plugin', provider_name: 'website-provider', }) const props = createDefaultProps({ nodeData }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ pluginId: 'website-plugin', provider: 'website-provider', @@ -970,7 +831,6 @@ describe('WebsiteCrawl', () => { }) it('should pass credentials from useGetDataSourceAuth to Header', () => { - // Arrange const mockCredentials = [ createMockCredential({ id: 'cred-1', name: 'Credential 1' }), createMockCredential({ id: 'cred-2', name: 'Credential 2' }), @@ -980,62 +840,47 @@ describe('WebsiteCrawl', () => { }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('2') }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle empty credentials array', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: { result: [] }, }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle undefined dataSourceAuth result', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: { result: undefined }, }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle null dataSourceAuth data', () => { - // Arrange mockUseGetDataSourceAuth.mockReturnValue({ data: null, }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') }) it('should handle empty crawlResult data array', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [], @@ -1043,28 +888,22 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-count')).toHaveTextContent('0') }) it('should handle undefined crawlResult', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = undefined const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-count')).toHaveTextContent('0') }) it('should handle time_consuming as string', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -1072,15 +911,12 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result-used-time')).toHaveTextContent('2.5') }) it('should handle invalid time_consuming value', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -1088,7 +924,6 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) // Assert - NaN should become 0 @@ -1096,14 +931,11 @@ describe('WebsiteCrawl', () => { }) it('should handle undefined pipelineId gracefully', () => { - // Arrange mockPipelineId = undefined const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(mockUsePublishedPipelinePreProcessingParams).toHaveBeenCalledWith( { pipeline_id: undefined, node_id: 'node-1' }, false, // enabled should be false when pipelineId is undefined @@ -1111,13 +943,10 @@ describe('WebsiteCrawl', () => { }) it('should handle empty nodeId gracefully', () => { - // Arrange const props = createDefaultProps({ nodeId: '' }) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(mockUsePublishedPipelinePreProcessingParams).toHaveBeenCalledWith( { pipeline_id: 'pipeline-123', node_id: '' }, false, // enabled should be false when nodeId is empty @@ -1132,7 +961,6 @@ describe('WebsiteCrawl', () => { }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) // Assert - Options should receive empty array as variables @@ -1147,7 +975,6 @@ describe('WebsiteCrawl', () => { }) const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) // Assert - Options should receive empty array as variables @@ -1155,7 +982,6 @@ describe('WebsiteCrawl', () => { }) it('should handle error without error message', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -1167,7 +993,6 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) // Assert - Should use fallback error message @@ -1177,7 +1002,6 @@ describe('WebsiteCrawl', () => { }) it('should handle null total and completed in processing callback', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -1190,7 +1014,6 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) // Assert - Should handle null values gracefully (default to 0) @@ -1200,7 +1023,6 @@ describe('WebsiteCrawl', () => { }) it('should handle undefined time_consuming in completed callback', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -1213,10 +1035,8 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockStoreState.setCrawlResult).toHaveBeenCalledWith({ data: [expect.any(Object)], @@ -1226,9 +1046,7 @@ describe('WebsiteCrawl', () => { }) }) - // ========================================== // All Prop Variations - // ========================================== describe('Prop Variations', () => { it.each([ [{ isInPipeline: true, supportBatchUpload: true }], @@ -1236,7 +1054,6 @@ describe('WebsiteCrawl', () => { [{ isInPipeline: false, supportBatchUpload: true }], [{ isInPipeline: false, supportBatchUpload: false }], ])('should render correctly with props %o', (propVariation) => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -1244,10 +1061,8 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps(propVariation) - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.getByTestId('crawled-result')).toBeInTheDocument() expect(screen.getByTestId('crawled-result-show-preview')).toHaveTextContent( String(!propVariation.isInPipeline), @@ -1258,7 +1073,6 @@ describe('WebsiteCrawl', () => { }) it('should use default values for optional props', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -1271,7 +1085,6 @@ describe('WebsiteCrawl', () => { // isInPipeline and supportBatchUpload are not provided } - // Act render(<WebsiteCrawl {...props} />) // Assert - Default values: isInPipeline = false, supportBatchUpload = true @@ -1280,9 +1093,7 @@ describe('WebsiteCrawl', () => { }) }) - // ========================================== // Error Display - // ========================================== describe('Error Display', () => { it('should show ErrorMessage when crawl finishes with error', async () => { // Arrange - Need to create a scenario where error message is set @@ -1313,7 +1124,6 @@ describe('WebsiteCrawl', () => { }) it('should not show ErrorMessage when crawl finishes without error', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [createMockCrawlResultItem()], @@ -1321,21 +1131,15 @@ describe('WebsiteCrawl', () => { } const props = createDefaultProps() - // Act render(<WebsiteCrawl {...props} />) - // Assert expect(screen.queryByTestId('error-message')).not.toBeInTheDocument() expect(screen.getByTestId('crawled-result')).toBeInTheDocument() }) }) - // ========================================== - // Integration Tests - // ========================================== describe('Integration', () => { it('should complete full workflow: submit -> running -> completed', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' const mockCrawlData: CrawlResultItem[] = [ createMockCrawlResultItem({ source_url: 'https://example.com/1' }), @@ -1378,7 +1182,6 @@ describe('WebsiteCrawl', () => { }) it('should handle error flow correctly', async () => { - // Arrange mockStoreState.currentCredentialId = 'cred-1' mockSsePost.mockImplementation((url, options, callbacks) => { @@ -1390,10 +1193,8 @@ describe('WebsiteCrawl', () => { const props = createDefaultProps() render(<WebsiteCrawl {...props} />) - // Act fireEvent.click(screen.getByTestId('options-submit-btn')) - // Assert await waitFor(() => { expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.running) expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished) @@ -1401,23 +1202,19 @@ describe('WebsiteCrawl', () => { }) it('should handle credential change and allow new crawl', () => { - // Arrange mockStoreState.currentCredentialId = 'initial-cred' const mockOnCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) - // Act render(<WebsiteCrawl {...props} />) // Change credential fireEvent.click(screen.getByTestId('header-credential-change')) - // Assert expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') }) it('should handle preview selection after crawl completes', () => { - // Arrange mockStoreState.step = CrawlStep.finished mockStoreState.crawlResult = { data: [ @@ -1432,21 +1229,16 @@ describe('WebsiteCrawl', () => { // Act - Preview first item fireEvent.click(screen.getByTestId('crawled-result-preview')) - // Assert expect(mockStoreState.setCurrentWebsite).toHaveBeenCalled() expect(mockStoreState.setPreviewIndex).toHaveBeenCalledWith(0) }) }) - // ========================================== // Component Memoization - // ========================================== describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Arrange const props = createDefaultProps() - // Act const { rerender } = render(<WebsiteCrawl {...props} />) rerender(<WebsiteCrawl {...props} />) @@ -1456,11 +1248,9 @@ describe('WebsiteCrawl', () => { }) it('should not re-run callbacks when props are the same', () => { - // Arrange const onCredentialChange = vi.fn() const props = createDefaultProps({ onCredentialChange }) - // Act const { rerender } = render(<WebsiteCrawl {...props} />) rerender(<WebsiteCrawl {...props} />) @@ -1470,30 +1260,22 @@ describe('WebsiteCrawl', () => { }) }) - // ========================================== // Styling - // ========================================== describe('Styling', () => { it('should apply correct container classes', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<WebsiteCrawl {...props} />) - // Assert const rootDiv = container.firstChild as HTMLElement expect(rootDiv).toHaveClass('flex', 'flex-col') }) it('should apply correct classes to options container', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<WebsiteCrawl {...props} />) - // Assert const optionsContainer = container.querySelector('.rounded-xl') expect(optionsContainer).toBeInTheDocument() }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/checkbox-with-label.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/checkbox-with-label.spec.tsx new file mode 100644 index 0000000000..574d8ba174 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/checkbox-with-label.spec.tsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CheckboxWithLabel from '../checkbox-with-label' + +vi.mock('@/app/components/base/checkbox', () => ({ + default: ({ checked, onCheck }: { checked: boolean, onCheck: () => void }) => ( + <input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} /> + ), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ popupContent }: { popupContent: string }) => <div data-testid="tooltip">{popupContent}</div>, +})) + +describe('CheckboxWithLabel', () => { + const defaultProps = { + isChecked: false, + onChange: vi.fn(), + label: 'Test Label', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render label text', () => { + render(<CheckboxWithLabel {...defaultProps} />) + expect(screen.getByText('Test Label')).toBeInTheDocument() + }) + + it('should render checkbox', () => { + render(<CheckboxWithLabel {...defaultProps} />) + expect(screen.getByTestId('checkbox')).toBeInTheDocument() + }) + + it('should render tooltip when provided', () => { + render(<CheckboxWithLabel {...defaultProps} tooltip="Help text" />) + expect(screen.getByTestId('tooltip')).toBeInTheDocument() + }) + + it('should not render tooltip when not provided', () => { + render(<CheckboxWithLabel {...defaultProps} />) + expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() + }) + + it('should apply custom className', () => { + const { container } = render(<CheckboxWithLabel {...defaultProps} className="custom-cls" />) + expect(container.querySelector('.custom-cls')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx new file mode 100644 index 0000000000..80d1f4ee19 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result-item.spec.tsx @@ -0,0 +1,69 @@ +import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CrawledResultItem from '../crawled-result-item' + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( + <button data-testid="preview-button" onClick={onClick}>{children}</button> + ), +})) + +vi.mock('@/app/components/base/checkbox', () => ({ + default: ({ checked, onCheck }: { checked: boolean, onCheck: () => void }) => ( + <input type="checkbox" data-testid="checkbox" checked={checked} onChange={onCheck} /> + ), +})) + +vi.mock('@/app/components/base/radio/ui', () => ({ + default: ({ isChecked, onCheck }: { isChecked: boolean, onCheck: () => void }) => ( + <input type="radio" data-testid="radio" checked={isChecked} onChange={onCheck} /> + ), +})) + +describe('CrawledResultItem', () => { + const defaultProps = { + payload: { + title: 'Test Page', + source_url: 'https://example.com/page', + markdown: '', + description: '', + } satisfies CrawlResultItemType, + isChecked: false, + onCheckChange: vi.fn(), + isPreview: false, + showPreview: true, + onPreview: vi.fn(), + isMultipleChoice: true, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render title and URL', () => { + render(<CrawledResultItem {...defaultProps} />) + expect(screen.getByText('Test Page')).toBeInTheDocument() + expect(screen.getByText('https://example.com/page')).toBeInTheDocument() + }) + + it('should render checkbox in multiple choice mode', () => { + render(<CrawledResultItem {...defaultProps} />) + expect(screen.getByTestId('checkbox')).toBeInTheDocument() + }) + + it('should render radio in single choice mode', () => { + render(<CrawledResultItem {...defaultProps} isMultipleChoice={false} />) + expect(screen.getByTestId('radio')).toBeInTheDocument() + }) + + it('should show preview button when showPreview is true', () => { + render(<CrawledResultItem {...defaultProps} />) + expect(screen.getByTestId('preview-button')).toBeInTheDocument() + }) + + it('should not show preview button when showPreview is false', () => { + render(<CrawledResultItem {...defaultProps} showPreview={false} />) + expect(screen.queryByTestId('preview-button')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result.spec.tsx new file mode 100644 index 0000000000..9c71f91d8d --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawled-result.spec.tsx @@ -0,0 +1,214 @@ +import type { CrawlResultItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' + +import CrawledResult from '../crawled-result' + +vi.mock('../checkbox-with-label', () => ({ + default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => ( + <label> + <input + type="checkbox" + checked={isChecked} + onChange={onChange} + data-testid="check-all-checkbox" + /> + {label} + </label> + ), +})) + +vi.mock('../crawled-result-item', () => ({ + default: ({ + payload, + isChecked, + onCheckChange, + onPreview, + }: { + payload: CrawlResultItem + isChecked: boolean + onCheckChange: (checked: boolean) => void + onPreview: () => void + }) => ( + <div data-testid={`crawled-item-${payload.source_url}`}> + <span data-testid="item-url">{payload.source_url}</span> + <button data-testid={`check-${payload.source_url}`} onClick={() => onCheckChange(!isChecked)}> + {isChecked ? 'uncheck' : 'check'} + </button> + <button data-testid={`preview-${payload.source_url}`} onClick={onPreview}> + preview + </button> + </div> + ), +})) + +const createItem = (url: string): CrawlResultItem => ({ + source_url: url, + title: `Title for ${url}`, + markdown: `# ${url}`, + description: `Desc for ${url}`, +}) + +const defaultList: CrawlResultItem[] = [ + createItem('https://example.com/a'), + createItem('https://example.com/b'), + createItem('https://example.com/c'), +] + +describe('CrawledResult', () => { + const defaultProps = { + list: defaultList, + checkedList: [] as CrawlResultItem[], + onSelectedChange: vi.fn(), + usedTime: 12.345, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render scrap time info with correct total and time', () => { + render(<CrawledResult {...defaultProps} />) + + expect( + screen.getByText(/scrapTimeInfo/), + ).toBeInTheDocument() + // The global i18n mock serialises params, so verify total and time appear + expect(screen.getByText(/"total":3/)).toBeInTheDocument() + expect(screen.getByText(/"time":"12.3"/)).toBeInTheDocument() + }) + + it('should render all items from list', () => { + render(<CrawledResult {...defaultProps} />) + + for (const item of defaultList) { + expect(screen.getByTestId(`crawled-item-${item.source_url}`)).toBeInTheDocument() + } + }) + + it('should apply custom className', () => { + const { container } = render( + <CrawledResult {...defaultProps} className="my-custom-class" />, + ) + + expect(container.firstChild).toHaveClass('my-custom-class') + }) + }) + + // Check-all checkbox visibility + describe('Check All Checkbox', () => { + it('should show check-all checkbox in multiple choice mode', () => { + render(<CrawledResult {...defaultProps} isMultipleChoice={true} />) + + expect(screen.getByTestId('check-all-checkbox')).toBeInTheDocument() + }) + + it('should hide check-all checkbox in single choice mode', () => { + render(<CrawledResult {...defaultProps} isMultipleChoice={false} />) + + expect(screen.queryByTestId('check-all-checkbox')).not.toBeInTheDocument() + }) + }) + + // Toggle all items + describe('Toggle All', () => { + it('should select all when not all checked', () => { + const onSelectedChange = vi.fn() + render( + <CrawledResult + {...defaultProps} + checkedList={[defaultList[0]]} + onSelectedChange={onSelectedChange} + />, + ) + + fireEvent.click(screen.getByTestId('check-all-checkbox')) + + expect(onSelectedChange).toHaveBeenCalledWith(defaultList) + }) + + it('should deselect all when all checked', () => { + const onSelectedChange = vi.fn() + render( + <CrawledResult + {...defaultProps} + checkedList={[...defaultList]} + onSelectedChange={onSelectedChange} + />, + ) + + fireEvent.click(screen.getByTestId('check-all-checkbox')) + + expect(onSelectedChange).toHaveBeenCalledWith([]) + }) + }) + + // Individual item check + describe('Individual Item Check', () => { + it('should add item to selection in multiple choice mode', () => { + const onSelectedChange = vi.fn() + render( + <CrawledResult + {...defaultProps} + checkedList={[defaultList[0]]} + onSelectedChange={onSelectedChange} + isMultipleChoice={true} + />, + ) + + fireEvent.click(screen.getByTestId(`check-${defaultList[1].source_url}`)) + + expect(onSelectedChange).toHaveBeenCalledWith([defaultList[0], defaultList[1]]) + }) + + it('should replace selection in single choice mode', () => { + const onSelectedChange = vi.fn() + render( + <CrawledResult + {...defaultProps} + checkedList={[defaultList[0]]} + onSelectedChange={onSelectedChange} + isMultipleChoice={false} + />, + ) + + fireEvent.click(screen.getByTestId(`check-${defaultList[1].source_url}`)) + + expect(onSelectedChange).toHaveBeenCalledWith([defaultList[1]]) + }) + + it('should remove item from selection when unchecked', () => { + const onSelectedChange = vi.fn() + render( + <CrawledResult + {...defaultProps} + checkedList={[defaultList[0], defaultList[1]]} + onSelectedChange={onSelectedChange} + isMultipleChoice={true} + />, + ) + + fireEvent.click(screen.getByTestId(`check-${defaultList[0].source_url}`)) + + expect(onSelectedChange).toHaveBeenCalledWith([defaultList[1]]) + }) + }) + + // Preview + describe('Preview', () => { + it('should call onPreview with correct item and index', () => { + const onPreview = vi.fn() + render( + <CrawledResult + {...defaultProps} + onPreview={onPreview} + showPreview={true} + />, + ) + + fireEvent.click(screen.getByTestId(`preview-${defaultList[1].source_url}`)) + + expect(onPreview).toHaveBeenCalledWith(defaultList[1], 1) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawling.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawling.spec.tsx new file mode 100644 index 0000000000..e2836b7978 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/crawling.spec.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import Crawling from '../crawling' + +describe('Crawling', () => { + it('should render crawl progress', () => { + render(<Crawling crawledNum={5} totalNum={10} />) + expect(screen.getByText(/5/)).toBeInTheDocument() + expect(screen.getByText(/10/)).toBeInTheDocument() + }) + + it('should render total page scraped label', () => { + render(<Crawling crawledNum={0} totalNum={0} />) + expect(screen.getByText(/stepOne\.website\.totalPageScraped/)).toBeInTheDocument() + }) + + it('should apply custom className', () => { + const { container } = render(<Crawling crawledNum={1} totalNum={5} className="custom" />) + expect(container.querySelector('.custom')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/error-message.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/error-message.spec.tsx new file mode 100644 index 0000000000..ee989c6224 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/error-message.spec.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import ErrorMessage from '../error-message' + +describe('ErrorMessage', () => { + it('should render title', () => { + render(<ErrorMessage title="Something went wrong" />) + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + + it('should render error message when provided', () => { + render(<ErrorMessage title="Error" errorMsg="Detailed error info" />) + expect(screen.getByText('Detailed error info')).toBeInTheDocument() + }) + + it('should not render error message when not provided', () => { + const { container } = render(<ErrorMessage title="Error" />) + const textElements = container.querySelectorAll('.system-xs-regular') + expect(textElements).toHaveLength(0) + }) + + it('should apply custom className', () => { + const { container } = render(<ErrorMessage title="Error" className="custom-cls" />) + expect(container.querySelector('.custom-cls')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx index 94de64d791..f537d63a73 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/__tests__/index.spec.tsx @@ -1,15 +1,11 @@ import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import CheckboxWithLabel from './checkbox-with-label' -import CrawledResult from './crawled-result' -import CrawledResultItem from './crawled-result-item' -import Crawling from './crawling' -import ErrorMessage from './error-message' - -// ========================================== -// Test Data Builders -// ========================================== +import CheckboxWithLabel from '../checkbox-with-label' +import CrawledResult from '../crawled-result' +import CrawledResultItem from '../crawled-result-item' +import Crawling from '../crawling' +import ErrorMessage from '../error-message' const createMockCrawlResultItem = (overrides?: Partial<CrawlResultItemType>): CrawlResultItemType => ({ source_url: 'https://example.com/page1', @@ -27,9 +23,7 @@ const createMockCrawlResultItems = (count = 3): CrawlResultItemType[] => { })) } -// ========================================== // CheckboxWithLabel Tests -// ========================================== describe('CheckboxWithLabel', () => { const defaultProps = { isChecked: false, @@ -43,15 +37,12 @@ describe('CheckboxWithLabel', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<CheckboxWithLabel {...defaultProps} />) - // Assert expect(screen.getByText('Test Label')).toBeInTheDocument() }) it('should render checkbox in unchecked state', () => { - // Arrange & Act const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={false} />) // Assert - Custom checkbox component uses div with data-testid @@ -61,7 +52,6 @@ describe('CheckboxWithLabel', () => { }) it('should render checkbox in checked state', () => { - // Arrange & Act const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={true} />) // Assert - Checked state has check icon @@ -70,7 +60,6 @@ describe('CheckboxWithLabel', () => { }) it('should render tooltip when provided', () => { - // Arrange & Act render(<CheckboxWithLabel {...defaultProps} tooltip="Helpful tooltip text" />) // Assert - Tooltip trigger should be present @@ -79,10 +68,8 @@ describe('CheckboxWithLabel', () => { }) it('should not render tooltip when not provided', () => { - // Arrange & Act render(<CheckboxWithLabel {...defaultProps} />) - // Assert const tooltipTrigger = document.querySelector('[class*="ml-0.5"]') expect(tooltipTrigger).not.toBeInTheDocument() }) @@ -90,21 +77,17 @@ describe('CheckboxWithLabel', () => { describe('Props', () => { it('should apply custom className', () => { - // Arrange & Act const { container } = render( <CheckboxWithLabel {...defaultProps} className="custom-class" />, ) - // Assert const label = container.querySelector('label') expect(label).toHaveClass('custom-class') }) it('should apply custom labelClassName', () => { - // Arrange & Act render(<CheckboxWithLabel {...defaultProps} labelClassName="custom-label-class" />) - // Assert const labelText = screen.getByText('Test Label') expect(labelText).toHaveClass('custom-label-class') }) @@ -112,33 +95,26 @@ describe('CheckboxWithLabel', () => { describe('User Interactions', () => { it('should call onChange with true when clicking unchecked checkbox', () => { - // Arrange const mockOnChange = vi.fn() const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={false} onChange={mockOnChange} />) - // Act const checkbox = container.querySelector('[data-testid^="checkbox"]')! fireEvent.click(checkbox) - // Assert expect(mockOnChange).toHaveBeenCalledWith(true) }) it('should call onChange with false when clicking checked checkbox', () => { - // Arrange const mockOnChange = vi.fn() const { container } = render(<CheckboxWithLabel {...defaultProps} isChecked={true} onChange={mockOnChange} />) - // Act const checkbox = container.querySelector('[data-testid^="checkbox"]')! fireEvent.click(checkbox) - // Assert expect(mockOnChange).toHaveBeenCalledWith(false) }) it('should not trigger onChange when clicking label text due to custom checkbox', () => { - // Arrange const mockOnChange = vi.fn() render(<CheckboxWithLabel {...defaultProps} onChange={mockOnChange} />) @@ -152,9 +128,7 @@ describe('CheckboxWithLabel', () => { }) }) -// ========================================== // CrawledResultItem Tests -// ========================================== describe('CrawledResultItem', () => { const defaultProps = { payload: createMockCrawlResultItem(), @@ -171,16 +145,13 @@ describe('CrawledResultItem', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<CrawledResultItem {...defaultProps} />) - // Assert expect(screen.getByText('Test Page Title')).toBeInTheDocument() expect(screen.getByText('https://example.com/page1')).toBeInTheDocument() }) it('should render checkbox when isMultipleChoice is true', () => { - // Arrange & Act const { container } = render(<CrawledResultItem {...defaultProps} isMultipleChoice={true} />) // Assert - Custom checkbox uses data-testid @@ -189,7 +160,6 @@ describe('CrawledResultItem', () => { }) it('should render radio when isMultipleChoice is false', () => { - // Arrange & Act const { container } = render(<CrawledResultItem {...defaultProps} isMultipleChoice={false} />) // Assert - Radio component has size-4 rounded-full classes @@ -198,7 +168,6 @@ describe('CrawledResultItem', () => { }) it('should render checkbox as checked when isChecked is true', () => { - // Arrange & Act const { container } = render(<CrawledResultItem {...defaultProps} isChecked={true} />) // Assert - Checked state shows check icon @@ -207,35 +176,27 @@ describe('CrawledResultItem', () => { }) it('should render preview button when showPreview is true', () => { - // Arrange & Act render(<CrawledResultItem {...defaultProps} showPreview={true} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should not render preview button when showPreview is false', () => { - // Arrange & Act render(<CrawledResultItem {...defaultProps} showPreview={false} />) - // Assert expect(screen.queryByRole('button')).not.toBeInTheDocument() }) it('should apply active background when isPreview is true', () => { - // Arrange & Act const { container } = render(<CrawledResultItem {...defaultProps} isPreview={true} />) - // Assert const item = container.firstChild expect(item).toHaveClass('bg-state-base-active') }) it('should apply hover styles when isPreview is false', () => { - // Arrange & Act const { container } = render(<CrawledResultItem {...defaultProps} isPreview={false} />) - // Assert const item = container.firstChild expect(item).toHaveClass('group') expect(item).toHaveClass('hover:bg-state-base-hover') @@ -244,35 +205,26 @@ describe('CrawledResultItem', () => { describe('Props', () => { it('should display payload title', () => { - // Arrange const payload = createMockCrawlResultItem({ title: 'Custom Title' }) - // Act render(<CrawledResultItem {...defaultProps} payload={payload} />) - // Assert expect(screen.getByText('Custom Title')).toBeInTheDocument() }) it('should display payload source_url', () => { - // Arrange const payload = createMockCrawlResultItem({ source_url: 'https://custom.url/path' }) - // Act render(<CrawledResultItem {...defaultProps} payload={payload} />) - // Assert expect(screen.getByText('https://custom.url/path')).toBeInTheDocument() }) it('should set title attribute for truncation tooltip', () => { - // Arrange const payload = createMockCrawlResultItem({ title: 'Very Long Title' }) - // Act render(<CrawledResultItem {...defaultProps} payload={payload} />) - // Assert const titleElement = screen.getByText('Very Long Title') expect(titleElement).toHaveAttribute('title', 'Very Long Title') }) @@ -280,7 +232,6 @@ describe('CrawledResultItem', () => { describe('User Interactions', () => { it('should call onCheckChange with true when clicking unchecked checkbox', () => { - // Arrange const mockOnCheckChange = vi.fn() const { container } = render( <CrawledResultItem @@ -290,16 +241,13 @@ describe('CrawledResultItem', () => { />, ) - // Act const checkbox = container.querySelector('[data-testid^="checkbox"]')! fireEvent.click(checkbox) - // Assert expect(mockOnCheckChange).toHaveBeenCalledWith(true) }) it('should call onCheckChange with false when clicking checked checkbox', () => { - // Arrange const mockOnCheckChange = vi.fn() const { container } = render( <CrawledResultItem @@ -309,28 +257,22 @@ describe('CrawledResultItem', () => { />, ) - // Act const checkbox = container.querySelector('[data-testid^="checkbox"]')! fireEvent.click(checkbox) - // Assert expect(mockOnCheckChange).toHaveBeenCalledWith(false) }) it('should call onPreview when clicking preview button', () => { - // Arrange const mockOnPreview = vi.fn() render(<CrawledResultItem {...defaultProps} onPreview={mockOnPreview} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnPreview).toHaveBeenCalled() }) it('should toggle radio state when isMultipleChoice is false', () => { - // Arrange const mockOnCheckChange = vi.fn() const { container } = render( <CrawledResultItem @@ -345,15 +287,12 @@ describe('CrawledResultItem', () => { const radio = container.querySelector('.size-4.rounded-full')! fireEvent.click(radio) - // Assert expect(mockOnCheckChange).toHaveBeenCalledWith(true) }) }) }) -// ========================================== // CrawledResult Tests -// ========================================== describe('CrawledResult', () => { const defaultProps = { list: createMockCrawlResultItems(3), @@ -368,7 +307,6 @@ describe('CrawledResult', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} />) // Assert - Check for time info which contains total count @@ -376,17 +314,14 @@ describe('CrawledResult', () => { }) it('should render all list items', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} />) - // Assert expect(screen.getByText('Page 1')).toBeInTheDocument() expect(screen.getByText('Page 2')).toBeInTheDocument() expect(screen.getByText('Page 3')).toBeInTheDocument() }) it('should display scrape time info', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} usedTime={2.5} />) // Assert - Check for the time display @@ -394,7 +329,6 @@ describe('CrawledResult', () => { }) it('should render select all checkbox when isMultipleChoice is true', () => { - // Arrange & Act const { container } = render(<CrawledResult {...defaultProps} isMultipleChoice={true} />) // Assert - Multiple custom checkboxes (select all + items) @@ -403,7 +337,6 @@ describe('CrawledResult', () => { }) it('should not render select all checkbox when isMultipleChoice is false', () => { - // Arrange & Act const { container } = render(<CrawledResult {...defaultProps} isMultipleChoice={false} />) // Assert - No select all checkbox, only radio buttons for items @@ -415,38 +348,30 @@ describe('CrawledResult', () => { }) it('should show "Select All" when not all items are checked', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} checkedList={[]} />) - // Assert expect(screen.getByText(/selectAll|Select All/i)).toBeInTheDocument() }) it('should show "Reset All" when all items are checked', () => { - // Arrange const allChecked = createMockCrawlResultItems(3) - // Act render(<CrawledResult {...defaultProps} checkedList={allChecked} />) - // Assert expect(screen.getByText(/resetAll|Reset All/i)).toBeInTheDocument() }) }) describe('Props', () => { it('should apply custom className', () => { - // Arrange & Act const { container } = render( <CrawledResult {...defaultProps} className="custom-class" />, ) - // Assert expect(container.firstChild).toHaveClass('custom-class') }) it('should highlight item at previewIndex', () => { - // Arrange & Act const { container } = render( <CrawledResult {...defaultProps} previewIndex={1} />, ) @@ -457,7 +382,6 @@ describe('CrawledResult', () => { }) it('should pass showPreview to items', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} showPreview={true} />) // Assert - Preview buttons should be visible @@ -466,17 +390,14 @@ describe('CrawledResult', () => { }) it('should not show preview buttons when showPreview is false', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} showPreview={false} />) - // Assert expect(screen.queryByRole('button')).not.toBeInTheDocument() }) }) describe('User Interactions', () => { it('should call onSelectedChange with all items when clicking select all', () => { - // Arrange const mockOnSelectedChange = vi.fn() const list = createMockCrawlResultItems(3) const { container } = render( @@ -492,12 +413,10 @@ describe('CrawledResult', () => { const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') fireEvent.click(checkboxes[0]) - // Assert expect(mockOnSelectedChange).toHaveBeenCalledWith(list) }) it('should call onSelectedChange with empty array when clicking reset all', () => { - // Arrange const mockOnSelectedChange = vi.fn() const list = createMockCrawlResultItems(3) const { container } = render( @@ -509,16 +428,13 @@ describe('CrawledResult', () => { />, ) - // Act const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') fireEvent.click(checkboxes[0]) - // Assert expect(mockOnSelectedChange).toHaveBeenCalledWith([]) }) it('should add item to checkedList when checking unchecked item', () => { - // Arrange const mockOnSelectedChange = vi.fn() const list = createMockCrawlResultItems(3) const { container } = render( @@ -534,12 +450,10 @@ describe('CrawledResult', () => { const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') fireEvent.click(checkboxes[2]) - // Assert expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0], list[1]]) }) it('should remove item from checkedList when unchecking checked item', () => { - // Arrange const mockOnSelectedChange = vi.fn() const list = createMockCrawlResultItems(3) const { container } = render( @@ -555,12 +469,10 @@ describe('CrawledResult', () => { const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') fireEvent.click(checkboxes[1]) - // Assert expect(mockOnSelectedChange).toHaveBeenCalledWith([list[1]]) }) it('should replace selection when checking in single choice mode', () => { - // Arrange const mockOnSelectedChange = vi.fn() const list = createMockCrawlResultItems(3) const { container } = render( @@ -582,7 +494,6 @@ describe('CrawledResult', () => { }) it('should call onPreview with item and index when clicking preview', () => { - // Arrange const mockOnPreview = vi.fn() const list = createMockCrawlResultItems(3) render( @@ -594,11 +505,9 @@ describe('CrawledResult', () => { />, ) - // Act const buttons = screen.getAllByRole('button') fireEvent.click(buttons[1]) // Second item's preview button - // Assert expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1) }) @@ -625,7 +534,6 @@ describe('CrawledResult', () => { describe('Edge Cases', () => { it('should handle empty list', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} list={[]} usedTime={0.5} />) // Assert - Should show time info with 0 count @@ -633,29 +541,22 @@ describe('CrawledResult', () => { }) it('should handle single item list', () => { - // Arrange const singleItem = [createMockCrawlResultItem()] - // Act render(<CrawledResult {...defaultProps} list={singleItem} />) - // Assert expect(screen.getByText('Test Page Title')).toBeInTheDocument() }) it('should format usedTime to one decimal place', () => { - // Arrange & Act render(<CrawledResult {...defaultProps} usedTime={1.567} />) - // Assert expect(screen.getByText(/1.6/)).toBeInTheDocument() }) }) }) -// ========================================== // Crawling Tests -// ========================================== describe('Crawling', () => { const defaultProps = { crawledNum: 5, @@ -668,23 +569,18 @@ describe('Crawling', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<Crawling {...defaultProps} />) - // Assert expect(screen.getByText(/5\/10/)).toBeInTheDocument() }) it('should display crawled count and total', () => { - // Arrange & Act render(<Crawling crawledNum={3} totalNum={15} />) - // Assert expect(screen.getByText(/3\/15/)).toBeInTheDocument() }) it('should render skeleton items', () => { - // Arrange & Act const { container } = render(<Crawling {...defaultProps} />) // Assert - Should have 3 skeleton items @@ -693,10 +589,8 @@ describe('Crawling', () => { }) it('should render header skeleton block', () => { - // Arrange & Act const { container } = render(<Crawling {...defaultProps} />) - // Assert const headerBlocks = container.querySelectorAll('.px-4.py-2 .bg-text-quaternary') expect(headerBlocks.length).toBeGreaterThan(0) }) @@ -704,35 +598,28 @@ describe('Crawling', () => { describe('Props', () => { it('should apply custom className', () => { - // Arrange & Act const { container } = render( <Crawling {...defaultProps} className="custom-crawling-class" />, ) - // Assert expect(container.firstChild).toHaveClass('custom-crawling-class') }) it('should handle zero values', () => { - // Arrange & Act render(<Crawling crawledNum={0} totalNum={0} />) - // Assert expect(screen.getByText(/0\/0/)).toBeInTheDocument() }) it('should handle large numbers', () => { - // Arrange & Act render(<Crawling crawledNum={999} totalNum={1000} />) - // Assert expect(screen.getByText(/999\/1000/)).toBeInTheDocument() }) }) describe('Skeleton Structure', () => { it('should render blocks with correct width classes', () => { - // Arrange & Act const { container } = render(<Crawling {...defaultProps} />) // Assert - Check for various width classes @@ -743,9 +630,7 @@ describe('Crawling', () => { }) }) -// ========================================== // ErrorMessage Tests -// ========================================== describe('ErrorMessage', () => { const defaultProps = { title: 'Error Title', @@ -757,41 +642,32 @@ describe('ErrorMessage', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<ErrorMessage {...defaultProps} />) - // Assert expect(screen.getByText('Error Title')).toBeInTheDocument() }) it('should render error icon', () => { - // Arrange & Act const { container } = render(<ErrorMessage {...defaultProps} />) - // Assert const icon = container.querySelector('svg') expect(icon).toBeInTheDocument() expect(icon).toHaveClass('text-text-destructive') }) it('should render title', () => { - // Arrange & Act render(<ErrorMessage title="Custom Error Title" />) - // Assert expect(screen.getByText('Custom Error Title')).toBeInTheDocument() }) it('should render error message when provided', () => { - // Arrange & Act render(<ErrorMessage {...defaultProps} errorMsg="Detailed error description" />) - // Assert expect(screen.getByText('Detailed error description')).toBeInTheDocument() }) it('should not render error message when not provided', () => { - // Arrange & Act render(<ErrorMessage {...defaultProps} />) // Assert - Should only have title, not error message container @@ -802,17 +678,14 @@ describe('ErrorMessage', () => { describe('Props', () => { it('should apply custom className', () => { - // Arrange & Act const { container } = render( <ErrorMessage {...defaultProps} className="custom-error-class" />, ) - // Assert expect(container.firstChild).toHaveClass('custom-error-class') }) it('should render with empty errorMsg', () => { - // Arrange & Act render(<ErrorMessage {...defaultProps} errorMsg="" />) // Assert - Empty string should not render message div @@ -820,64 +693,47 @@ describe('ErrorMessage', () => { }) it('should handle long title text', () => { - // Arrange const longTitle = 'This is a very long error title that might wrap to multiple lines' - // Act render(<ErrorMessage title={longTitle} />) - // Assert expect(screen.getByText(longTitle)).toBeInTheDocument() }) it('should handle long error message', () => { - // Arrange const longErrorMsg = 'This is a very detailed error message explaining what went wrong and how to fix it. It contains multiple sentences.' - // Act render(<ErrorMessage {...defaultProps} errorMsg={longErrorMsg} />) - // Assert expect(screen.getByText(longErrorMsg)).toBeInTheDocument() }) }) describe('Styling', () => { it('should have error background styling', () => { - // Arrange & Act const { container } = render(<ErrorMessage {...defaultProps} />) - // Assert expect(container.firstChild).toHaveClass('bg-toast-error-bg') }) it('should have border styling', () => { - // Arrange & Act const { container } = render(<ErrorMessage {...defaultProps} />) - // Assert expect(container.firstChild).toHaveClass('border-components-panel-border') }) it('should have rounded corners', () => { - // Arrange & Act const { container } = render(<ErrorMessage {...defaultProps} />) - // Assert expect(container.firstChild).toHaveClass('rounded-xl') }) }) }) -// ========================================== -// Integration Tests -// ========================================== describe('Base Components Integration', () => { it('should render CrawledResult with CrawledResultItem children', () => { - // Arrange const list = createMockCrawlResultItems(2) - // Act render( <CrawledResult list={list} @@ -893,10 +749,8 @@ describe('Base Components Integration', () => { }) it('should render CrawledResult with CheckboxWithLabel for select all', () => { - // Arrange const list = createMockCrawlResultItems(2) - // Act const { container } = render( <CrawledResult list={list} @@ -913,7 +767,6 @@ describe('Base Components Integration', () => { }) it('should allow selecting and previewing items', () => { - // Arrange const list = createMockCrawlResultItems(3) const mockOnSelectedChange = vi.fn() const mockOnPreview = vi.fn() @@ -933,14 +786,12 @@ describe('Base Components Integration', () => { const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') fireEvent.click(checkboxes[1]) - // Assert expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0]]) // Act - Preview second item const previewButtons = screen.getAllByRole('button') fireEvent.click(previewButtons[1]) - // Assert expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx index b89114c84b..c147e969a6 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx @@ -6,13 +6,7 @@ import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/ty import Toast from '@/app/components/base/toast' import { CrawlStep } from '@/models/datasets' import { PipelineInputVarType } from '@/models/pipeline' -import Options from './index' - -// ========================================== -// Mock Modules -// ========================================== - -// Note: react-i18next uses global mock from web/vitest.setup.ts +import Options from '../index' // Mock useInitialData and useConfigurations hooks const { mockUseInitialData, mockUseConfigurations } = vi.hoisted(() => ({ @@ -28,15 +22,16 @@ vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ // Mock BaseField const mockBaseField = vi.fn() vi.mock('@/app/components/base/form/form-scenarios/base/field', () => { - const MockBaseFieldFactory = (props: any) => { + const MockBaseFieldFactory = (props: Record<string, unknown>) => { mockBaseField(props) - const MockField = ({ form }: { form: any }) => ( - <div data-testid={`field-${props.config?.variable || 'unknown'}`}> - <span data-testid={`field-label-${props.config?.variable}`}>{props.config?.label}</span> + const config = props.config as { variable?: string, label?: string } | undefined + const MockField = ({ form }: { form: { getFieldValue?: (field: string) => string, setFieldValue?: (field: string, value: string) => void } }) => ( + <div data-testid={`field-${config?.variable || 'unknown'}`}> + <span data-testid={`field-label-${config?.variable}`}>{config?.label}</span> <input - data-testid={`field-input-${props.config?.variable}`} - value={form.getFieldValue?.(props.config?.variable) || ''} - onChange={e => form.setFieldValue?.(props.config?.variable, e.target.value)} + data-testid={`field-input-${config?.variable}`} + value={form.getFieldValue?.(config?.variable || '') || ''} + onChange={e => form.setFieldValue?.(config?.variable || '', e.target.value)} /> </div> ) @@ -47,9 +42,9 @@ vi.mock('@/app/components/base/form/form-scenarios/base/field', () => { // Mock useAppForm const mockHandleSubmit = vi.fn() -const mockFormValues: Record<string, any> = {} +const mockFormValues: Record<string, unknown> = {} vi.mock('@/app/components/base/form', () => ({ - useAppForm: (options: any) => { + useAppForm: (options: { validators?: { onSubmit?: (arg: { value: Record<string, unknown> }) => unknown }, onSubmit?: (arg: { value: Record<string, unknown> }) => void }) => { const formOptions = options return { handleSubmit: () => { @@ -60,17 +55,13 @@ vi.mock('@/app/components/base/form', () => ({ } }, getFieldValue: (field: string) => mockFormValues[field], - setFieldValue: (field: string, value: any) => { + setFieldValue: (field: string, value: unknown) => { mockFormValues[field] = value }, } }, })) -// ========================================== -// Test Data Builders -// ========================================== - const createMockVariable = (overrides?: Partial<RAGPipelineVariables[0]>): RAGPipelineVariables[0] => ({ belong_to_node_id: 'node-1', type: PipelineInputVarType.textInput, @@ -91,7 +82,18 @@ const createMockVariables = (count = 1): RAGPipelineVariables => { })) } -const createMockConfiguration = (overrides?: Partial<any>): any => ({ +type MockConfiguration = { + type: BaseFieldType + variable: string + label: string + required: boolean + maxLength: number + options: unknown[] + showConditions: unknown[] + placeholder: string +} + +const createMockConfiguration = (overrides?: Partial<MockConfiguration>): MockConfiguration => ({ type: BaseFieldType.textInput, variable: 'test_variable', label: 'Test Label', @@ -113,9 +115,6 @@ const createDefaultProps = (overrides?: Partial<OptionsProps>): OptionsProps => ...overrides, }) -// ========================================== -// Test Suites -// ========================================== describe('Options', () => { let toastNotifySpy: MockInstance @@ -137,46 +136,33 @@ describe('Options', () => { toastNotifySpy.mockRestore() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Options {...props} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() }) it('should render options header with toggle text', () => { - // Arrange const props = createDefaultProps() - // Act render(<Options {...props} />) - // Assert expect(screen.getByText(/options/i)).toBeInTheDocument() }) it('should render Run button', () => { - // Arrange const props = createDefaultProps() - // Act render(<Options {...props} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() expect(screen.getByText(/run/i)).toBeInTheDocument() }) it('should render form fields when not folded', () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'url', label: 'URL' }), createMockConfiguration({ variable: 'depth', label: 'Depth' }), @@ -184,19 +170,15 @@ describe('Options', () => { mockUseConfigurations.mockReturnValue(configurations) const props = createDefaultProps() - // Act render(<Options {...props} />) - // Assert expect(screen.getByTestId('field-url')).toBeInTheDocument() expect(screen.getByTestId('field-depth')).toBeInTheDocument() }) it('should render arrow icon in correct orientation when expanded', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Options {...props} />) // Assert - Arrow should not have -rotate-90 class when expanded @@ -206,37 +188,27 @@ describe('Options', () => { }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('variables prop', () => { it('should pass variables to useInitialData hook', () => { - // Arrange const variables = createMockVariables(3) const props = createDefaultProps({ variables }) - // Act render(<Options {...props} />) - // Assert expect(mockUseInitialData).toHaveBeenCalledWith(variables) }) it('should pass variables to useConfigurations hook', () => { - // Arrange const variables = createMockVariables(2) const props = createDefaultProps({ variables }) - // Act render(<Options {...props} />) - // Assert expect(mockUseConfigurations).toHaveBeenCalledWith(variables) }) it('should render correct number of fields based on configurations', () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'field_1', label: 'Field 1' }), createMockConfiguration({ variable: 'field_2', label: 'Field 2' }), @@ -245,24 +217,19 @@ describe('Options', () => { mockUseConfigurations.mockReturnValue(configurations) const props = createDefaultProps() - // Act render(<Options {...props} />) - // Assert expect(screen.getByTestId('field-field_1')).toBeInTheDocument() expect(screen.getByTestId('field-field_2')).toBeInTheDocument() expect(screen.getByTestId('field-field_3')).toBeInTheDocument() }) it('should handle empty variables array', () => { - // Arrange mockUseConfigurations.mockReturnValue([]) const props = createDefaultProps({ variables: [] }) - // Act const { container } = render(<Options {...props} />) - // Assert expect(container.querySelector('form')).toBeInTheDocument() expect(screen.queryByTestId(/field-/)).not.toBeInTheDocument() }) @@ -270,54 +237,40 @@ describe('Options', () => { describe('step prop', () => { it('should show "Run" text when step is init', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.init }) - // Act render(<Options {...props} />) - // Assert expect(screen.getByText(/run/i)).toBeInTheDocument() }) it('should show "Running" text when step is running', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.running }) - // Act render(<Options {...props} />) - // Assert expect(screen.getByText(/running/i)).toBeInTheDocument() }) it('should disable button when step is running', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.running }) - // Act render(<Options {...props} />) - // Assert expect(screen.getByRole('button')).toBeDisabled() }) it('should enable button when step is finished', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.finished, runDisabled: false }) - // Act render(<Options {...props} />) - // Assert expect(screen.getByRole('button')).not.toBeDisabled() }) it('should show loading state on button when step is running', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.running }) - // Act render(<Options {...props} />) // Assert - Button should have loading prop which disables it @@ -328,47 +281,35 @@ describe('Options', () => { describe('runDisabled prop', () => { it('should disable button when runDisabled is true', () => { - // Arrange const props = createDefaultProps({ runDisabled: true }) - // Act render(<Options {...props} />) - // Assert expect(screen.getByRole('button')).toBeDisabled() }) it('should enable button when runDisabled is false and step is not running', () => { - // Arrange const props = createDefaultProps({ runDisabled: false, step: CrawlStep.init }) - // Act render(<Options {...props} />) - // Assert expect(screen.getByRole('button')).not.toBeDisabled() }) it('should disable button when both runDisabled is true and step is running', () => { - // Arrange const props = createDefaultProps({ runDisabled: true, step: CrawlStep.running }) - // Act render(<Options {...props} />) - // Assert expect(screen.getByRole('button')).toBeDisabled() }) it('should default runDisabled to undefined (falsy)', () => { - // Arrange const props = createDefaultProps() - delete (props as any).runDisabled + delete (props as Partial<OptionsProps>).runDisabled - // Act render(<Options {...props} />) - // Assert expect(screen.getByRole('button')).not.toBeDisabled() }) }) @@ -385,16 +326,13 @@ describe('Options', () => { const mockOnSubmit = vi.fn() const props = createDefaultProps({ onSubmit: mockOnSubmit }) - // Act render(<Options {...props} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSubmit).toHaveBeenCalled() }) it('should not call onSubmit when validation fails', () => { - // Arrange const mockOnSubmit = vi.fn() // Create a required field configuration const requiredConfig = createMockConfiguration({ @@ -407,11 +345,9 @@ describe('Options', () => { // mockFormValues is empty, so required field validation will fail const props = createDefaultProps({ onSubmit: mockOnSubmit }) - // Act render(<Options {...props} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSubmit).not.toHaveBeenCalled() }) @@ -427,22 +363,17 @@ describe('Options', () => { const mockOnSubmit = vi.fn() const props = createDefaultProps({ onSubmit: mockOnSubmit }) - // Act render(<Options {...props} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSubmit).toHaveBeenCalledWith({ url: 'https://example.com', depth: 2 }) }) }) }) - // ========================================== // Side Effects and Cleanup (useEffect) - // ========================================== describe('Side Effects and Cleanup', () => { it('should expand options when step changes to init', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.finished }) const { rerender, container } = render(<Options {...props} />) @@ -456,7 +387,6 @@ describe('Options', () => { }) it('should collapse options when step changes to running', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.init }) const { rerender, container } = render(<Options {...props} />) @@ -473,7 +403,6 @@ describe('Options', () => { }) it('should collapse options when step changes to finished', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.init }) const { rerender, container } = render(<Options {...props} />) @@ -487,7 +416,6 @@ describe('Options', () => { }) it('should respond to step transitions from init -> running -> finished', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.init }) const { rerender, container } = render(<Options {...props} />) @@ -512,7 +440,6 @@ describe('Options', () => { }) it('should expand when step transitions from finished to init', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.finished }) const { rerender } = render(<Options {...props} />) @@ -527,12 +454,9 @@ describe('Options', () => { }) }) - // ========================================== // Memoization Logic and Dependencies - // ========================================== describe('Memoization Logic and Dependencies', () => { it('should regenerate schema when configurations change', () => { - // Arrange const config1 = [createMockConfiguration({ variable: 'url' })] const config2 = [createMockConfiguration({ variable: 'depth' })] mockUseConfigurations.mockReturnValue(config1) @@ -551,10 +475,8 @@ describe('Options', () => { }) it('should compute isRunning correctly for init step', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.init }) - // Act render(<Options {...props} />) // Assert - Button should not be in loading state @@ -564,10 +486,8 @@ describe('Options', () => { }) it('should compute isRunning correctly for running step', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.running }) - // Act render(<Options {...props} />) // Assert - Button should be in loading state @@ -577,10 +497,8 @@ describe('Options', () => { }) it('should compute isRunning correctly for finished step', () => { - // Arrange const props = createDefaultProps({ step: CrawlStep.finished }) - // Act render(<Options {...props} />) // Assert - Button should not be in loading state @@ -606,12 +524,9 @@ describe('Options', () => { }) }) - // ========================================== // User Interactions and Event Handlers - // ========================================== describe('User Interactions and Event Handlers', () => { it('should toggle fold state when header is clicked', () => { - // Arrange const props = createDefaultProps() render(<Options {...props} />) @@ -632,11 +547,9 @@ describe('Options', () => { }) it('should prevent default and stop propagation on form submit', () => { - // Arrange const props = createDefaultProps() const { container } = render(<Options {...props} />) - // Act const form = container.querySelector('form')! const mockPreventDefault = vi.fn() const mockStopPropagation = vi.fn() @@ -662,15 +575,12 @@ describe('Options', () => { const props = createDefaultProps({ onSubmit: mockOnSubmit }) render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSubmit).toHaveBeenCalled() }) it('should not trigger submit when button is disabled', () => { - // Arrange const mockOnSubmit = vi.fn() const props = createDefaultProps({ onSubmit: mockOnSubmit, runDisabled: true }) render(<Options {...props} />) @@ -678,12 +588,10 @@ describe('Options', () => { // Act - Try to click disabled button fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSubmit).not.toHaveBeenCalled() }) it('should maintain fold state after form submission', () => { - // Arrange const props = createDefaultProps() render(<Options {...props} />) @@ -698,7 +606,6 @@ describe('Options', () => { }) it('should allow clicking on arrow icon container to toggle', () => { - // Arrange const props = createDefaultProps() const { container } = render(<Options {...props} />) @@ -714,9 +621,6 @@ describe('Options', () => { }) }) - // ========================================== - // Edge Cases and Error Handling - // ========================================== describe('Edge Cases and Error Handling', () => { it('should handle validation error and show toast', () => { // Arrange - Create required field that will fail validation when empty @@ -731,7 +635,6 @@ describe('Options', () => { const props = createDefaultProps() render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) // Assert - Toast should be called with error message @@ -754,7 +657,6 @@ describe('Options', () => { const props = createDefaultProps() render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) // Assert - Toast message should contain field path @@ -767,11 +669,9 @@ describe('Options', () => { }) it('should handle empty variables gracefully', () => { - // Arrange mockUseConfigurations.mockReturnValue([]) const props = createDefaultProps({ variables: [] }) - // Act const { container } = render(<Options {...props} />) // Assert - Should render without errors @@ -780,29 +680,23 @@ describe('Options', () => { }) it('should handle single variable configuration', () => { - // Arrange const singleConfig = [createMockConfiguration({ variable: 'only_field' })] mockUseConfigurations.mockReturnValue(singleConfig) const props = createDefaultProps() - // Act render(<Options {...props} />) - // Assert expect(screen.getByTestId('field-only_field')).toBeInTheDocument() }) it('should handle many configurations', () => { - // Arrange const manyConfigs = Array.from({ length: 10 }, (_, i) => createMockConfiguration({ variable: `field_${i}`, label: `Field ${i}` })) mockUseConfigurations.mockReturnValue(manyConfigs) const props = createDefaultProps() - // Act render(<Options {...props} />) - // Assert for (let i = 0; i < 10; i++) expect(screen.getByTestId(`field-field_${i}`)).toBeInTheDocument() }) @@ -817,7 +711,6 @@ describe('Options', () => { const props = createDefaultProps() render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) // Assert - Toast should be called once (only first error) @@ -830,7 +723,6 @@ describe('Options', () => { }) it('should handle validation pass when all required fields have values', () => { - // Arrange const requiredConfig = createMockConfiguration({ variable: 'url', label: 'URL', @@ -843,7 +735,6 @@ describe('Options', () => { const props = createDefaultProps({ onSubmit: mockOnSubmit }) render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) // Assert - No toast error, onSubmit called @@ -852,17 +743,15 @@ describe('Options', () => { }) it('should handle undefined variables gracefully', () => { - // Arrange mockUseInitialData.mockReturnValue({}) mockUseConfigurations.mockReturnValue([]) - const props = createDefaultProps({ variables: undefined as any }) + const props = createDefaultProps({ variables: undefined as unknown as RAGPipelineVariables }) // Act & Assert - Should not throw expect(() => render(<Options {...props} />)).not.toThrow() }) it('should handle rapid fold/unfold toggling', () => { - // Arrange const props = createDefaultProps() render(<Options {...props} />) @@ -876,9 +765,7 @@ describe('Options', () => { }) }) - // ========================================== // All Prop Variations - // ========================================== describe('Prop Variations', () => { it.each([ [{ step: CrawlStep.init, runDisabled: false }, false, 'run'], @@ -888,13 +775,10 @@ describe('Options', () => { [{ step: CrawlStep.finished, runDisabled: false }, false, 'run'], [{ step: CrawlStep.finished, runDisabled: true }, true, 'run'], ] as const)('should render correctly with step=%s, runDisabled=%s', (propVariation, expectedDisabled, expectedText) => { - // Arrange const props = createDefaultProps(propVariation) - // Act render(<Options {...props} />) - // Assert const button = screen.getByRole('button') if (expectedDisabled) expect(button).toBeDisabled() @@ -915,7 +799,6 @@ describe('Options', () => { }) it('should handle variables with different types', () => { - // Arrange const variables: RAGPipelineVariables = [ createMockVariable({ type: PipelineInputVarType.textInput, variable: 'text_field' }), createMockVariable({ type: PipelineInputVarType.paragraph, variable: 'paragraph_field' }), @@ -927,19 +810,15 @@ describe('Options', () => { mockUseConfigurations.mockReturnValue(configurations) const props = createDefaultProps({ variables }) - // Act render(<Options {...props} />) - // Assert variables.forEach((v) => { expect(screen.getByTestId(`field-${v.variable}`)).toBeInTheDocument() }) }) }) - // ========================================== // Form Validation - // ========================================== describe('Form Validation', () => { it('should pass validation with valid data', () => { // Arrange - Use non-required field so empty value passes @@ -953,10 +832,8 @@ describe('Options', () => { const props = createDefaultProps({ onSubmit: mockOnSubmit }) render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSubmit).toHaveBeenCalled() expect(toastNotifySpy).not.toHaveBeenCalled() }) @@ -974,10 +851,8 @@ describe('Options', () => { const props = createDefaultProps({ onSubmit: mockOnSubmit }) render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnSubmit).not.toHaveBeenCalled() expect(toastNotifySpy).toHaveBeenCalled() }) @@ -994,10 +869,8 @@ describe('Options', () => { const props = createDefaultProps() render(<Options {...props} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(toastNotifySpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', @@ -1007,99 +880,75 @@ describe('Options', () => { }) }) - // ========================================== // Styling Tests - // ========================================== describe('Styling', () => { it('should apply correct container classes to form', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Options {...props} />) - // Assert const form = container.querySelector('form') expect(form).toHaveClass('w-full') }) it('should apply cursor-pointer class to toggle container', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Options {...props} />) - // Assert const toggleContainer = container.querySelector('.cursor-pointer') expect(toggleContainer).toBeInTheDocument() }) it('should apply select-none class to prevent text selection on toggle', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Options {...props} />) - // Assert const toggleContainer = container.querySelector('.select-none') expect(toggleContainer).toBeInTheDocument() }) it('should apply rotate class to arrow icon when folded', () => { - // Arrange const props = createDefaultProps() const { container } = render(<Options {...props} />) // Act - Fold the options fireEvent.click(screen.getByText(/options/i)) - // Assert const arrowIcon = container.querySelector('svg') expect(arrowIcon).toHaveClass('-rotate-90') }) it('should not apply rotate class to arrow icon when expanded', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Options {...props} />) - // Assert const arrowIcon = container.querySelector('svg') expect(arrowIcon).not.toHaveClass('-rotate-90') }) it('should apply border class to fields container when expanded', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<Options {...props} />) - // Assert const fieldsContainer = container.querySelector('.border-t') expect(fieldsContainer).toBeInTheDocument() }) }) - // ========================================== // BaseField Integration - // ========================================== describe('BaseField Integration', () => { it('should pass correct props to BaseField factory', () => { - // Arrange const config = createMockConfiguration({ variable: 'test_var', label: 'Test Label' }) mockUseConfigurations.mockReturnValue([config]) mockUseInitialData.mockReturnValue({ test_var: 'default_value' }) const props = createDefaultProps() - // Act render(<Options {...props} />) - // Assert expect(mockBaseField).toHaveBeenCalledWith( expect.objectContaining({ initialData: { test_var: 'default_value' }, @@ -1109,7 +958,6 @@ describe('Options', () => { }) it('should render unique key for each field', () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'field_a' }), createMockConfiguration({ variable: 'field_b' }), @@ -1118,7 +966,6 @@ describe('Options', () => { mockUseConfigurations.mockReturnValue(configurations) const props = createDefaultProps() - // Act render(<Options {...props} />) // Assert - All fields should be rendered (React would warn if keys aren't unique) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-add-documents-steps.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-add-documents-steps.spec.ts new file mode 100644 index 0000000000..5776f597ab --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-add-documents-steps.spec.ts @@ -0,0 +1,50 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAddDocumentsSteps } from '../use-add-documents-steps' + +describe('useAddDocumentsSteps', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should initialize with step 1', () => { + const { result } = renderHook(() => useAddDocumentsSteps()) + expect(result.current.currentStep).toBe(1) + }) + + it('should return 3 steps', () => { + const { result } = renderHook(() => useAddDocumentsSteps()) + expect(result.current.steps).toHaveLength(3) + }) + + it('should have correct step labels', () => { + const { result } = renderHook(() => useAddDocumentsSteps()) + const labels = result.current.steps.map(s => s.label) + expect(labels[0]).toContain('chooseDatasource') + expect(labels[1]).toContain('processDocuments') + expect(labels[2]).toContain('processingDocuments') + }) + + it('should increment step on handleNextStep', () => { + const { result } = renderHook(() => useAddDocumentsSteps()) + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(2) + }) + + it('should decrement step on handleBackStep', () => { + const { result } = renderHook(() => useAddDocumentsSteps()) + act(() => { + result.current.handleNextStep() + }) + act(() => { + result.current.handleNextStep() + }) + expect(result.current.currentStep).toBe(3) + act(() => { + result.current.handleBackStep() + }) + expect(result.current.currentStep).toBe(2) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts new file mode 100644 index 0000000000..e6da4313f1 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-actions.spec.ts @@ -0,0 +1,204 @@ +import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types' +import type { DataSourceNotionPageMap, NotionPage } from '@/models/common' +import type { CrawlResultItem, DocumentItem, FileItem } from '@/models/datasets' +import type { OnlineDriveFile } from '@/models/pipeline' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DatasourceType } from '@/models/pipeline' +import { createDataSourceStore } from '../../data-source/store' +import { useDatasourceActions } from '../use-datasource-actions' + +const mockRunPublishedPipeline = vi.fn() +vi.mock('@/service/use-pipeline', () => ({ + useRunPublishedPipeline: () => ({ + mutateAsync: mockRunPublishedPipeline, + isIdle: true, + isPending: false, + }), +})) +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: vi.fn(), +})) + +describe('useDatasourceActions', () => { + let store: ReturnType<typeof createDataSourceStore> + const defaultParams = () => ({ + datasource: { nodeId: 'node-1', nodeData: { provider_type: DatasourceType.localFile } } as unknown as Datasource, + datasourceType: DatasourceType.localFile, + pipelineId: 'pipeline-1', + dataSourceStore: store, + setEstimateData: vi.fn(), + setBatchId: vi.fn(), + setDocuments: vi.fn(), + handleNextStep: vi.fn(), + PagesMapAndSelectedPagesId: {}, + currentWorkspacePages: undefined as { page_id: string }[] | undefined, + clearOnlineDocumentData: vi.fn(), + clearWebsiteCrawlData: vi.fn(), + clearOnlineDriveData: vi.fn(), + setDatasource: vi.fn(), + }) + + beforeEach(() => { + vi.clearAllMocks() + store = createDataSourceStore() + }) + + it('should return all action functions', () => { + const params = defaultParams() + const { result } = renderHook(() => useDatasourceActions(params)) + + expect(typeof result.current.onClickProcess).toBe('function') + expect(typeof result.current.onClickPreview).toBe('function') + expect(typeof result.current.handleSubmit).toBe('function') + expect(typeof result.current.handlePreviewFileChange).toBe('function') + expect(typeof result.current.handlePreviewOnlineDocumentChange).toBe('function') + expect(typeof result.current.handlePreviewWebsiteChange).toBe('function') + expect(typeof result.current.handlePreviewOnlineDriveFileChange).toBe('function') + expect(typeof result.current.handleSelectAll).toBe('function') + expect(typeof result.current.handleSwitchDataSource).toBe('function') + expect(typeof result.current.handleCredentialChange).toBe('function') + expect(result.current.isIdle).toBe(true) + expect(result.current.isPending).toBe(false) + }) + + it('should handle credential change by clearing data and setting new credential', () => { + const params = defaultParams() + const { result } = renderHook(() => useDatasourceActions(params)) + + act(() => { + result.current.handleCredentialChange('cred-new') + }) + + expect(store.getState().currentCredentialId).toBe('cred-new') + }) + + it('should handle switch data source', () => { + const params = defaultParams() + const newDatasource = { + nodeId: 'node-2', + nodeData: { provider_type: DatasourceType.onlineDocument }, + } as unknown as Datasource + + const { result } = renderHook(() => useDatasourceActions(params)) + act(() => { + result.current.handleSwitchDataSource(newDatasource) + }) + + expect(store.getState().currentCredentialId).toBe('') + expect(store.getState().currentNodeIdRef.current).toBe('node-2') + expect(params.setDatasource).toHaveBeenCalledWith(newDatasource) + }) + + it('should handle preview file change by updating ref', () => { + const params = defaultParams() + params.dataSourceStore = store + + const { result } = renderHook(() => useDatasourceActions(params)) + + // Set up formRef to prevent null error + result.current.formRef.current = { submit: vi.fn() } + + const file = { id: 'f1', name: 'test.pdf' } as unknown as DocumentItem + act(() => { + result.current.handlePreviewFileChange(file) + }) + + expect(store.getState().previewLocalFileRef.current).toEqual(file) + }) + + it('should handle preview online document change', () => { + const params = defaultParams() + const { result } = renderHook(() => useDatasourceActions(params)) + result.current.formRef.current = { submit: vi.fn() } + + const page = { page_id: 'p1', page_name: 'My Page' } as unknown as NotionPage + act(() => { + result.current.handlePreviewOnlineDocumentChange(page) + }) + + expect(store.getState().previewOnlineDocumentRef.current).toEqual(page) + }) + + it('should handle preview website change', () => { + const params = defaultParams() + const { result } = renderHook(() => useDatasourceActions(params)) + result.current.formRef.current = { submit: vi.fn() } + + const website = { title: 'Page', source_url: 'https://example.com' } as unknown as CrawlResultItem + act(() => { + result.current.handlePreviewWebsiteChange(website) + }) + + expect(store.getState().previewWebsitePageRef.current).toEqual(website) + }) + + it('should handle select all for online documents', () => { + const params = defaultParams() + params.datasourceType = DatasourceType.onlineDocument + params.currentWorkspacePages = [{ page_id: 'p1' }, { page_id: 'p2' }] as unknown as NotionPage[] + params.PagesMapAndSelectedPagesId = { + p1: { page_id: 'p1', page_name: 'A', workspace_id: 'w1' }, + p2: { page_id: 'p2', page_name: 'B', workspace_id: 'w1' }, + } as unknown as DataSourceNotionPageMap + + const { result } = renderHook(() => useDatasourceActions(params)) + + // First call: select all + act(() => { + result.current.handleSelectAll() + }) + expect(store.getState().onlineDocuments).toHaveLength(2) + + // Second call: deselect all + act(() => { + result.current.handleSelectAll() + }) + expect(store.getState().onlineDocuments).toEqual([]) + }) + + it('should handle select all for online drive', () => { + const params = defaultParams() + params.datasourceType = DatasourceType.onlineDrive + + store.getState().setOnlineDriveFileList([ + { id: 'f1', type: 'file' }, + { id: 'f2', type: 'file' }, + { id: 'b1', type: 'bucket' }, + ] as unknown as OnlineDriveFile[]) + + const { result } = renderHook(() => useDatasourceActions(params)) + + act(() => { + result.current.handleSelectAll() + }) + // Should select f1, f2 but not b1 (bucket) + expect(store.getState().selectedFileIds).toEqual(['f1', 'f2']) + }) + + it('should handle submit with preview mode', async () => { + const params = defaultParams() + store.getState().setLocalFileList([{ file: { id: 'f1', name: 'test.pdf' } }] as unknown as FileItem[]) + store.getState().previewLocalFileRef.current = { id: 'f1', name: 'test.pdf' } as unknown as DocumentItem + + mockRunPublishedPipeline.mockResolvedValue({ data: { outputs: { tokens: 100 } } }) + + const { result } = renderHook(() => useDatasourceActions(params)) + + // Set preview mode + result.current.isPreview.current = true + + await act(async () => { + await result.current.handleSubmit({ query: 'test' }) + }) + + expect(mockRunPublishedPipeline).toHaveBeenCalledWith( + expect.objectContaining({ + pipeline_id: 'pipeline-1', + is_preview: true, + start_node_id: 'node-1', + }), + expect.anything(), + ) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts new file mode 100644 index 0000000000..7ecd4bf841 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-options.spec.ts @@ -0,0 +1,58 @@ +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import type { Node } from '@/app/components/workflow/types' +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/app/components/workflow/types', async () => { + const actual = await vi.importActual<Record<string, unknown>>('@/app/components/workflow/types') + const blockEnum = actual.BlockEnum as Record<string, string> + return { + ...actual, + BlockEnum: { + ...blockEnum, + DataSource: 'data-source', + }, + } +}) + +const { useDatasourceOptions } = await import('../use-datasource-options') + +describe('useDatasourceOptions', () => { + const createNode = (id: string, title: string, type: string): Node<DataSourceNodeType> => ({ + id, + position: { x: 0, y: 0 }, + data: { + type, + title, + provider_type: 'local_file', + }, + } as unknown as Node<DataSourceNodeType>) + + it('should return empty array for no datasource nodes', () => { + const nodes = [ + createNode('n1', 'LLM Node', 'llm'), + ] + const { result } = renderHook(() => useDatasourceOptions(nodes)) + expect(result.current).toEqual([]) + }) + + it('should return options for datasource nodes', () => { + const nodes = [ + createNode('n1', 'File Upload', 'data-source'), + createNode('n2', 'Web Crawl', 'data-source'), + createNode('n3', 'LLM Node', 'llm'), + ] + const { result } = renderHook(() => useDatasourceOptions(nodes)) + expect(result.current).toHaveLength(2) + expect(result.current[0]).toEqual({ + label: 'File Upload', + value: 'n1', + data: expect.objectContaining({ title: 'File Upload' }), + }) + expect(result.current[1]).toEqual({ + label: 'Web Crawl', + value: 'n2', + data: expect.objectContaining({ title: 'Web Crawl' }), + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-store.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-store.spec.ts new file mode 100644 index 0000000000..155b41541b --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-store.spec.ts @@ -0,0 +1,207 @@ +import type { ReactNode } from 'react' +import type { DataSourceNotionWorkspace, NotionPage } from '@/models/common' +import type { CrawlResultItem, CustomFile as File, FileItem } from '@/models/datasets' +import type { OnlineDriveFile } from '@/models/pipeline' +import { act, renderHook } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CrawlStep } from '@/models/datasets' +import { createDataSourceStore } from '../../data-source/store' +import { DataSourceContext } from '../../data-source/store/provider' +import { useLocalFile, useOnlineDocument, useOnlineDrive, useWebsiteCrawl } from '../use-datasource-store' + +const createWrapper = (store: ReturnType<typeof createDataSourceStore>) => { + return ({ children }: { children: ReactNode }) => + React.createElement(DataSourceContext.Provider, { value: store }, children) +} + +describe('useLocalFile', () => { + let store: ReturnType<typeof createDataSourceStore> + + beforeEach(() => { + vi.clearAllMocks() + store = createDataSourceStore() + }) + + it('should return local file list and initial state', () => { + const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) }) + + expect(result.current.localFileList).toEqual([]) + expect(result.current.allFileLoaded).toBe(false) + expect(result.current.currentLocalFile).toBeUndefined() + }) + + it('should compute allFileLoaded when all files have ids', () => { + store.getState().setLocalFileList([ + { file: { id: 'f1', name: 'a.pdf' } }, + { file: { id: 'f2', name: 'b.pdf' } }, + ] as unknown as FileItem[]) + + const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) }) + expect(result.current.allFileLoaded).toBe(true) + }) + + it('should compute allFileLoaded as false when some files lack ids', () => { + store.getState().setLocalFileList([ + { file: { id: 'f1', name: 'a.pdf' } }, + { file: { id: '', name: 'b.pdf' } }, + ] as unknown as FileItem[]) + + const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) }) + expect(result.current.allFileLoaded).toBe(false) + }) + + it('should hide preview local file', () => { + store.getState().setCurrentLocalFile({ id: 'f1' } as unknown as File) + const { result } = renderHook(() => useLocalFile(), { wrapper: createWrapper(store) }) + + act(() => { + result.current.hidePreviewLocalFile() + }) + expect(store.getState().currentLocalFile).toBeUndefined() + }) +}) + +describe('useOnlineDocument', () => { + let store: ReturnType<typeof createDataSourceStore> + + beforeEach(() => { + vi.clearAllMocks() + store = createDataSourceStore() + }) + + it('should return initial state', () => { + const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) }) + + expect(result.current.onlineDocuments).toEqual([]) + expect(result.current.currentDocument).toBeUndefined() + expect(result.current.currentWorkspace).toBeUndefined() + }) + + it('should build PagesMapAndSelectedPagesId from documentsData', () => { + store.getState().setDocumentsData([ + { workspace_id: 'w1', pages: [{ page_id: 'p1', page_name: 'Page 1' }] }, + ] as unknown as DataSourceNotionWorkspace[]) + + const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) }) + expect(result.current.PagesMapAndSelectedPagesId).toHaveProperty('p1') + expect(result.current.PagesMapAndSelectedPagesId.p1.workspace_id).toBe('w1') + }) + + it('should hide preview online document', () => { + store.getState().setCurrentDocument({ page_id: 'p1' } as unknown as NotionPage) + const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) }) + + act(() => { + result.current.hidePreviewOnlineDocument() + }) + expect(store.getState().currentDocument).toBeUndefined() + }) + + it('should clear online document data', () => { + store.getState().setDocumentsData([{ workspace_id: 'w1', pages: [] }] as unknown as DataSourceNotionWorkspace[]) + store.getState().setSearchValue('test') + store.getState().setOnlineDocuments([{ page_id: 'p1' }] as unknown as NotionPage[]) + + const { result } = renderHook(() => useOnlineDocument(), { wrapper: createWrapper(store) }) + act(() => { + result.current.clearOnlineDocumentData() + }) + + expect(store.getState().documentsData).toEqual([]) + expect(store.getState().searchValue).toBe('') + expect(store.getState().onlineDocuments).toEqual([]) + }) +}) + +describe('useWebsiteCrawl', () => { + let store: ReturnType<typeof createDataSourceStore> + + beforeEach(() => { + vi.clearAllMocks() + store = createDataSourceStore() + }) + + it('should return initial state', () => { + const { result } = renderHook(() => useWebsiteCrawl(), { wrapper: createWrapper(store) }) + + expect(result.current.websitePages).toEqual([]) + expect(result.current.currentWebsite).toBeUndefined() + }) + + it('should hide website preview', () => { + store.getState().setCurrentWebsite({ title: 'Test' } as unknown as CrawlResultItem) + store.getState().setPreviewIndex(2) + const { result } = renderHook(() => useWebsiteCrawl(), { wrapper: createWrapper(store) }) + + act(() => { + result.current.hideWebsitePreview() + }) + + expect(store.getState().currentWebsite).toBeUndefined() + expect(store.getState().previewIndex).toBe(-1) + }) + + it('should clear website crawl data', () => { + store.getState().setStep(CrawlStep.running) + store.getState().setWebsitePages([{ title: 'Test' }] as unknown as CrawlResultItem[]) + + const { result } = renderHook(() => useWebsiteCrawl(), { wrapper: createWrapper(store) }) + act(() => { + result.current.clearWebsiteCrawlData() + }) + + expect(store.getState().step).toBe(CrawlStep.init) + expect(store.getState().websitePages).toEqual([]) + expect(store.getState().currentWebsite).toBeUndefined() + }) +}) + +describe('useOnlineDrive', () => { + let store: ReturnType<typeof createDataSourceStore> + + beforeEach(() => { + vi.clearAllMocks() + store = createDataSourceStore() + }) + + it('should return initial state', () => { + const { result } = renderHook(() => useOnlineDrive(), { wrapper: createWrapper(store) }) + + expect(result.current.onlineDriveFileList).toEqual([]) + expect(result.current.selectedFileIds).toEqual([]) + expect(result.current.selectedOnlineDriveFileList).toEqual([]) + }) + + it('should compute selected online drive file list', () => { + const files = [ + { id: 'f1', name: 'a.pdf' }, + { id: 'f2', name: 'b.pdf' }, + { id: 'f3', name: 'c.pdf' }, + ] as unknown as OnlineDriveFile[] + store.getState().setOnlineDriveFileList(files) + store.getState().setSelectedFileIds(['f1', 'f3']) + + const { result } = renderHook(() => useOnlineDrive(), { wrapper: createWrapper(store) }) + expect(result.current.selectedOnlineDriveFileList).toEqual([files[0], files[2]]) + }) + + it('should clear online drive data', () => { + store.getState().setOnlineDriveFileList([{ id: 'f1' }] as unknown as OnlineDriveFile[]) + store.getState().setBucket('b1') + store.getState().setPrefix(['p1']) + store.getState().setKeywords('kw') + store.getState().setSelectedFileIds(['f1']) + + const { result } = renderHook(() => useOnlineDrive(), { wrapper: createWrapper(store) }) + act(() => { + result.current.clearOnlineDriveData() + }) + + expect(store.getState().onlineDriveFileList).toEqual([]) + expect(store.getState().bucket).toBe('') + expect(store.getState().prefix).toEqual([]) + expect(store.getState().keywords).toBe('') + expect(store.getState().selectedFileIds).toEqual([]) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts new file mode 100644 index 0000000000..2032bb2c09 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/hooks/__tests__/use-datasource-ui-state.spec.ts @@ -0,0 +1,205 @@ +import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types' +import type { OnlineDriveFile } from '@/models/pipeline' +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline' +import { useDatasourceUIState } from '../use-datasource-ui-state' + +describe('useDatasourceUIState', () => { + const defaultParams = { + datasource: { nodeData: { provider_type: DatasourceType.localFile } } as unknown as Datasource, + allFileLoaded: true, + localFileListLength: 3, + onlineDocumentsLength: 0, + websitePagesLength: 0, + selectedFileIdsLength: 0, + onlineDriveFileList: [] as OnlineDriveFile[], + isVectorSpaceFull: false, + enableBilling: false, + currentWorkspacePagesLength: 0, + fileUploadConfig: { file_size_limit: 50, batch_count_limit: 20 }, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('datasourceType', () => { + it('should return provider_type from datasource', () => { + const { result } = renderHook(() => useDatasourceUIState(defaultParams)) + expect(result.current.datasourceType).toBe(DatasourceType.localFile) + }) + + it('should return undefined when no datasource', () => { + const { result } = renderHook(() => + useDatasourceUIState({ ...defaultParams, datasource: undefined }), + ) + expect(result.current.datasourceType).toBeUndefined() + }) + }) + + describe('isShowVectorSpaceFull', () => { + it('should be false when billing disabled', () => { + const { result } = renderHook(() => + useDatasourceUIState({ ...defaultParams, isVectorSpaceFull: true }), + ) + expect(result.current.isShowVectorSpaceFull).toBe(false) + }) + + it('should be true when billing enabled and space is full for local file', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + isVectorSpaceFull: true, + enableBilling: true, + allFileLoaded: true, + }), + ) + expect(result.current.isShowVectorSpaceFull).toBe(true) + }) + + it('should be false when no datasource', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: undefined, + isVectorSpaceFull: true, + enableBilling: true, + }), + ) + expect(result.current.isShowVectorSpaceFull).toBe(false) + }) + }) + + describe('nextBtnDisabled', () => { + it('should be true when no datasource', () => { + const { result } = renderHook(() => + useDatasourceUIState({ ...defaultParams, datasource: undefined }), + ) + expect(result.current.nextBtnDisabled).toBe(true) + }) + + it('should be false when local files loaded', () => { + const { result } = renderHook(() => useDatasourceUIState(defaultParams)) + expect(result.current.nextBtnDisabled).toBe(false) + }) + + it('should be true when local file list empty', () => { + const { result } = renderHook(() => + useDatasourceUIState({ ...defaultParams, localFileListLength: 0 }), + ) + expect(result.current.nextBtnDisabled).toBe(true) + }) + + it('should be true when files not all loaded', () => { + const { result } = renderHook(() => + useDatasourceUIState({ ...defaultParams, allFileLoaded: false }), + ) + expect(result.current.nextBtnDisabled).toBe(true) + }) + + it('should be false for online document with documents selected', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource, + onlineDocumentsLength: 2, + }), + ) + expect(result.current.nextBtnDisabled).toBe(false) + }) + + it('should be true for online document with no documents', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource, + onlineDocumentsLength: 0, + }), + ) + expect(result.current.nextBtnDisabled).toBe(true) + }) + }) + + describe('showSelect', () => { + it('should be false for local file type', () => { + const { result } = renderHook(() => useDatasourceUIState(defaultParams)) + expect(result.current.showSelect).toBe(false) + }) + + it('should be true for online document with workspace pages', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource, + currentWorkspacePagesLength: 5, + }), + ) + expect(result.current.showSelect).toBe(true) + }) + + it('should be true for online drive with non-bucket files', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: { nodeData: { provider_type: DatasourceType.onlineDrive } } as unknown as Datasource, + onlineDriveFileList: [ + { id: '1', name: 'file.txt', type: OnlineDriveFileType.file }, + ], + }), + ) + expect(result.current.showSelect).toBe(true) + }) + + it('should be false for online drive showing only buckets', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: { nodeData: { provider_type: DatasourceType.onlineDrive } } as unknown as Datasource, + onlineDriveFileList: [ + { id: '1', name: 'bucket-1', type: OnlineDriveFileType.bucket }, + ], + }), + ) + expect(result.current.showSelect).toBe(false) + }) + }) + + describe('totalOptions and selectedOptions', () => { + it('should return workspace pages count for online document', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource, + currentWorkspacePagesLength: 10, + onlineDocumentsLength: 3, + }), + ) + expect(result.current.totalOptions).toBe(10) + expect(result.current.selectedOptions).toBe(3) + }) + + it('should return undefined for local file type', () => { + const { result } = renderHook(() => useDatasourceUIState(defaultParams)) + expect(result.current.totalOptions).toBeUndefined() + expect(result.current.selectedOptions).toBeUndefined() + }) + }) + + describe('tip', () => { + it('should return empty string for local file', () => { + const { result } = renderHook(() => useDatasourceUIState(defaultParams)) + expect(result.current.tip).toBe('') + }) + + it('should return tip for online document', () => { + const { result } = renderHook(() => + useDatasourceUIState({ + ...defaultParams, + datasource: { nodeData: { provider_type: DatasourceType.onlineDocument } } as unknown as Datasource, + }), + ) + expect(result.current.tip).toContain('selectOnlineDocumentTip') + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/chunk-preview.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/chunk-preview.spec.tsx index 127fdc3624..c98acc2086 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/chunk-preview.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/chunk-preview.spec.tsx @@ -5,7 +5,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { ChunkingMode } from '@/models/datasets' import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline' -import ChunkPreview from './chunk-preview' +import ChunkPreview from '../chunk-preview' // Uses global react-i18next mock from web/vitest.setup.ts @@ -18,7 +18,7 @@ vi.mock('@/context/dataset-detail', () => ({ })) // Mock document picker - needs mock for simplified interaction testing -vi.mock('../../../common/document-picker/preview-document-picker', () => ({ +vi.mock('../../../../common/document-picker/preview-document-picker', () => ({ default: ({ files, onChange, value }: { files: Array<{ id: string, name: string, extension: string }> onChange: (selected: { id: string, name: string, extension: string }) => void @@ -43,7 +43,6 @@ vi.mock('../../../common/document-picker/preview-document-picker', () => ({ ), })) -// Test data factories const createMockLocalFile = (overrides?: Partial<CustomFile>): CustomFile => ({ id: 'file-1', name: 'test-file.pdf', diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/file-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/file-preview.spec.tsx new file mode 100644 index 0000000000..715d1650df --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/file-preview.spec.tsx @@ -0,0 +1,68 @@ +import type { CustomFile } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import FilePreview from '../file-preview' + +const mockFileData = { content: 'file content here with some text' } +let mockIsFetching = false + +vi.mock('@/service/use-common', () => ({ + useFilePreview: () => ({ + data: mockIsFetching ? undefined : mockFileData, + isFetching: mockIsFetching, + }), +})) + +vi.mock('../../../../common/document-file-icon', () => ({ + default: () => <span data-testid="file-icon" />, +})) + +vi.mock('../loading', () => ({ + default: () => <div data-testid="loading" />, +})) + +describe('FilePreview', () => { + const defaultProps = { + file: { + id: 'file-1', + name: 'document.pdf', + extension: 'pdf', + size: 1024, + } as CustomFile, + hidePreview: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockIsFetching = false + }) + + it('should render preview label', () => { + render(<FilePreview {...defaultProps} />) + expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument() + }) + + it('should render file name', () => { + render(<FilePreview {...defaultProps} />) + expect(screen.getByText('document.pdf')).toBeInTheDocument() + }) + + it('should render file content when loaded', () => { + render(<FilePreview {...defaultProps} />) + expect(screen.getByText('file content here with some text')).toBeInTheDocument() + }) + + it('should render loading state', () => { + mockIsFetching = true + render(<FilePreview {...defaultProps} />) + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) + + it('should call hidePreview when close button clicked', () => { + render(<FilePreview {...defaultProps} />) + const buttons = screen.getAllByRole('button') + const closeBtn = buttons[buttons.length - 1] + fireEvent.click(closeBtn) + expect(defaultProps.hidePreview).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx index 5375a0197c..947313cda5 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx @@ -2,7 +2,7 @@ import type { NotionPage } from '@/models/common' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import Toast from '@/app/components/base/toast' -import OnlineDocumentPreview from './online-document-preview' +import OnlineDocumentPreview from '../online-document-preview' // Uses global react-i18next mock from web/vitest.setup.ts @@ -29,7 +29,7 @@ const mockCurrentCredentialId = 'credential-123' const mockGetState = vi.fn(() => ({ currentCredentialId: mockCurrentCredentialId, })) -vi.mock('../data-source/store', () => ({ +vi.mock('../../data-source/store', () => ({ useDataSourceStore: () => ({ getState: mockGetState, }), diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/web-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/web-preview.spec.tsx new file mode 100644 index 0000000000..1f59e11035 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/web-preview.spec.tsx @@ -0,0 +1,48 @@ +import type { CrawlResultItem } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import WebPreview from '../web-preview' + +describe('WebPreview', () => { + const defaultProps = { + currentWebsite: { + title: 'Test Page', + source_url: 'https://example.com', + markdown: 'Hello **markdown** content', + description: '', + } satisfies CrawlResultItem, + hidePreview: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render preview label', () => { + render(<WebPreview {...defaultProps} />) + expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument() + }) + + it('should render page title', () => { + render(<WebPreview {...defaultProps} />) + expect(screen.getByText('Test Page')).toBeInTheDocument() + }) + + it('should render source URL', () => { + render(<WebPreview {...defaultProps} />) + expect(screen.getByText('https://example.com')).toBeInTheDocument() + }) + + it('should render markdown content', () => { + render(<WebPreview {...defaultProps} />) + expect(screen.getByText('Hello **markdown** content')).toBeInTheDocument() + }) + + it('should call hidePreview when close button clicked', () => { + render(<WebPreview {...defaultProps} />) + const buttons = screen.getAllByRole('button') + const closeBtn = buttons[buttons.length - 1] + fireEvent.click(closeBtn) + expect(defaultProps.hidePreview).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.spec.tsx deleted file mode 100644 index 6f040ffb00..0000000000 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/file-preview.spec.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import type { CustomFile as File } from '@/models/datasets' -import { fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' -import FilePreview from './file-preview' - -// Uses global react-i18next mock from web/vitest.setup.ts - -// Mock useFilePreview hook - needs to be mocked to control return values -const mockUseFilePreview = vi.fn() -vi.mock('@/service/use-common', () => ({ - useFilePreview: (fileID: string) => mockUseFilePreview(fileID), -})) - -// Test data factory -const createMockFile = (overrides?: Partial<File>): File => ({ - id: 'file-123', - name: 'test-document.pdf', - size: 2048, - type: 'application/pdf', - extension: 'pdf', - lastModified: Date.now(), - webkitRelativePath: '', - arrayBuffer: vi.fn() as () => Promise<ArrayBuffer>, - bytes: vi.fn() as () => Promise<Uint8Array>, - slice: vi.fn() as (start?: number, end?: number, contentType?: string) => Blob, - stream: vi.fn() as () => ReadableStream<Uint8Array>, - text: vi.fn() as () => Promise<string>, - ...overrides, -} as File) - -const createMockFilePreviewData = (content: string = 'This is the file content') => ({ - content, -}) - -const defaultProps = { - file: createMockFile(), - hidePreview: vi.fn(), -} - -describe('FilePreview', () => { - beforeEach(() => { - vi.clearAllMocks() - mockUseFilePreview.mockReturnValue({ - data: undefined, - isFetching: false, - }) - }) - - describe('Rendering', () => { - it('should render the component with file information', () => { - render(<FilePreview {...defaultProps} />) - - // i18n mock returns key by default - expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument() - expect(screen.getByText('test-document.pdf')).toBeInTheDocument() - }) - - it('should display file extension in uppercase via CSS class', () => { - render(<FilePreview {...defaultProps} />) - - // The extension is displayed in the info section (as uppercase via CSS class) - const extensionElement = screen.getByText('pdf') - expect(extensionElement).toBeInTheDocument() - expect(extensionElement).toHaveClass('uppercase') - }) - - it('should display formatted file size', () => { - render(<FilePreview {...defaultProps} />) - - // Real formatFileSize: 2048 bytes => "2.00 KB" - expect(screen.getByText('2.00 KB')).toBeInTheDocument() - }) - - it('should render close button', () => { - render(<FilePreview {...defaultProps} />) - - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should call useFilePreview with correct fileID', () => { - const file = createMockFile({ id: 'specific-file-id' }) - - render(<FilePreview {...defaultProps} file={file} />) - - expect(mockUseFilePreview).toHaveBeenCalledWith('specific-file-id') - }) - }) - - describe('File Name Processing', () => { - it('should extract file name without extension', () => { - const file = createMockFile({ name: 'my-document.pdf', extension: 'pdf' }) - - render(<FilePreview {...defaultProps} file={file} />) - - // The displayed text is `${fileName}.${extension}`, where fileName is name without ext - // my-document.pdf -> fileName = 'my-document', displayed as 'my-document.pdf' - expect(screen.getByText('my-document.pdf')).toBeInTheDocument() - }) - - it('should handle file name with multiple dots', () => { - const file = createMockFile({ name: 'my.file.name.pdf', extension: 'pdf' }) - - render(<FilePreview {...defaultProps} file={file} />) - - // fileName = arr.slice(0, -1).join() = 'my,file,name', then displayed as 'my,file,name.pdf' - expect(screen.getByText('my,file,name.pdf')).toBeInTheDocument() - }) - - it('should handle empty file name', () => { - const file = createMockFile({ name: '', extension: '' }) - - render(<FilePreview {...defaultProps} file={file} />) - - // fileName = '', displayed as '.' - expect(screen.getByText('.')).toBeInTheDocument() - }) - - it('should handle file without extension in name', () => { - const file = createMockFile({ name: 'noextension', extension: '' }) - - render(<FilePreview {...defaultProps} file={file} />) - - // fileName = '' (slice returns empty for single element array), displayed as '.' - expect(screen.getByText('.')).toBeInTheDocument() - }) - }) - - describe('Loading State', () => { - it('should render loading component when fetching', () => { - mockUseFilePreview.mockReturnValue({ - data: undefined, - isFetching: true, - }) - - render(<FilePreview {...defaultProps} />) - - // Loading component renders skeleton - expect(document.querySelector('.overflow-hidden')).toBeInTheDocument() - }) - - it('should not render content when loading', () => { - mockUseFilePreview.mockReturnValue({ - data: createMockFilePreviewData('Some content'), - isFetching: true, - }) - - render(<FilePreview {...defaultProps} />) - - expect(screen.queryByText('Some content')).not.toBeInTheDocument() - }) - }) - - describe('Content Display', () => { - it('should render file content when loaded', () => { - mockUseFilePreview.mockReturnValue({ - data: createMockFilePreviewData('This is the file content'), - isFetching: false, - }) - - render(<FilePreview {...defaultProps} />) - - expect(screen.getByText('This is the file content')).toBeInTheDocument() - }) - - it('should display character count when data is available', () => { - mockUseFilePreview.mockReturnValue({ - data: createMockFilePreviewData('Hello'), // 5 characters - isFetching: false, - }) - - render(<FilePreview {...defaultProps} />) - - // Real formatNumberAbbreviated returns "5" for numbers < 1000 - expect(screen.getByText(/5/)).toBeInTheDocument() - }) - - it('should format large character counts', () => { - const longContent = 'a'.repeat(2500) - mockUseFilePreview.mockReturnValue({ - data: createMockFilePreviewData(longContent), - isFetching: false, - }) - - render(<FilePreview {...defaultProps} />) - - // Real formatNumberAbbreviated uses lowercase 'k': "2.5k" - expect(screen.getByText(/2\.5k/)).toBeInTheDocument() - }) - - it('should not display character count when data is not available', () => { - mockUseFilePreview.mockReturnValue({ - data: undefined, - isFetching: false, - }) - - render(<FilePreview {...defaultProps} />) - - // No character text shown - expect(screen.queryByText(/datasetPipeline\.addDocuments\.characters/)).not.toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call hidePreview when close button is clicked', () => { - const hidePreview = vi.fn() - - render(<FilePreview {...defaultProps} hidePreview={hidePreview} />) - - const closeButton = screen.getByRole('button') - fireEvent.click(closeButton) - - expect(hidePreview).toHaveBeenCalledTimes(1) - }) - }) - - describe('File Size Formatting', () => { - it('should format small file sizes in bytes', () => { - const file = createMockFile({ size: 500 }) - - render(<FilePreview {...defaultProps} file={file} />) - - // Real formatFileSize: 500 => "500.00 bytes" - expect(screen.getByText('500.00 bytes')).toBeInTheDocument() - }) - - it('should format kilobyte file sizes', () => { - const file = createMockFile({ size: 5120 }) - - render(<FilePreview {...defaultProps} file={file} />) - - // Real formatFileSize: 5120 => "5.00 KB" - expect(screen.getByText('5.00 KB')).toBeInTheDocument() - }) - - it('should format megabyte file sizes', () => { - const file = createMockFile({ size: 2097152 }) - - render(<FilePreview {...defaultProps} file={file} />) - - // Real formatFileSize: 2097152 => "2.00 MB" - expect(screen.getByText('2.00 MB')).toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should handle undefined file id', () => { - const file = createMockFile({ id: undefined }) - - render(<FilePreview {...defaultProps} file={file} />) - - expect(mockUseFilePreview).toHaveBeenCalledWith('') - }) - - it('should handle empty extension', () => { - const file = createMockFile({ extension: undefined }) - - render(<FilePreview {...defaultProps} file={file} />) - - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should handle zero file size', () => { - const file = createMockFile({ size: 0 }) - - render(<FilePreview {...defaultProps} file={file} />) - - // Real formatFileSize returns 0 for falsy values - // The component still renders - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should handle very long file content', () => { - const veryLongContent = 'a'.repeat(1000000) - mockUseFilePreview.mockReturnValue({ - data: createMockFilePreviewData(veryLongContent), - isFetching: false, - }) - - render(<FilePreview {...defaultProps} />) - - // Real formatNumberAbbreviated: 1000000 => "1M" - expect(screen.getByText(/1M/)).toBeInTheDocument() - }) - - it('should handle empty content', () => { - mockUseFilePreview.mockReturnValue({ - data: createMockFilePreviewData(''), - isFetching: false, - }) - - render(<FilePreview {...defaultProps} />) - - // Real formatNumberAbbreviated: 0 => "0" - // Find the element that contains character count info - expect(screen.getByText(/0 datasetPipeline/)).toBeInTheDocument() - }) - }) - - describe('useMemo for fileName', () => { - it('should extract file name when file exists', () => { - // When file exists, it should extract the name without extension - const file = createMockFile({ name: 'document.txt', extension: 'txt' }) - - render(<FilePreview {...defaultProps} file={file} />) - - expect(screen.getByText('document.txt')).toBeInTheDocument() - }) - - it('should memoize fileName based on file prop', () => { - const file = createMockFile({ name: 'test.pdf', extension: 'pdf' }) - - const { rerender } = render(<FilePreview {...defaultProps} file={file} />) - - // Same file should produce same result - rerender(<FilePreview {...defaultProps} file={file} />) - - expect(screen.getByText('test.pdf')).toBeInTheDocument() - }) - }) -}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.spec.tsx deleted file mode 100644 index 2cfb14f42a..0000000000 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/web-preview.spec.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import type { CrawlResultItem } from '@/models/datasets' -import { fireEvent, render, screen } from '@testing-library/react' -import * as React from 'react' -import WebsitePreview from './web-preview' - -// Uses global react-i18next mock from web/vitest.setup.ts - -// Test data factory -const createMockCrawlResult = (overrides?: Partial<CrawlResultItem>): CrawlResultItem => ({ - title: 'Test Website Title', - markdown: 'This is the **markdown** content of the website.', - description: 'Test description', - source_url: 'https://example.com/page', - ...overrides, -}) - -const defaultProps = { - currentWebsite: createMockCrawlResult(), - hidePreview: vi.fn(), -} - -describe('WebsitePreview', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render the component with website information', () => { - render(<WebsitePreview {...defaultProps} />) - - // i18n mock returns key by default - expect(screen.getByText('datasetPipeline.addDocuments.stepOne.preview')).toBeInTheDocument() - expect(screen.getByText('Test Website Title')).toBeInTheDocument() - }) - - it('should display the source URL', () => { - render(<WebsitePreview {...defaultProps} />) - - expect(screen.getByText('https://example.com/page')).toBeInTheDocument() - }) - - it('should render close button', () => { - render(<WebsitePreview {...defaultProps} />) - - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should render the markdown content', () => { - render(<WebsitePreview {...defaultProps} />) - - expect(screen.getByText('This is the **markdown** content of the website.')).toBeInTheDocument() - }) - }) - - describe('Character Count', () => { - it('should display character count for small content', () => { - const currentWebsite = createMockCrawlResult({ markdown: 'Hello' }) // 5 characters - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - // Real formatNumberAbbreviated returns "5" for numbers < 1000 - expect(screen.getByText(/5/)).toBeInTheDocument() - }) - - it('should format character count in thousands', () => { - const longContent = 'a'.repeat(2500) - const currentWebsite = createMockCrawlResult({ markdown: longContent }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - // Real formatNumberAbbreviated uses lowercase 'k': "2.5k" - expect(screen.getByText(/2\.5k/)).toBeInTheDocument() - }) - - it('should format character count in millions', () => { - const veryLongContent = 'a'.repeat(1500000) - const currentWebsite = createMockCrawlResult({ markdown: veryLongContent }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByText(/1\.5M/)).toBeInTheDocument() - }) - - it('should show 0 characters for empty markdown', () => { - const currentWebsite = createMockCrawlResult({ markdown: '' }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByText(/0/)).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call hidePreview when close button is clicked', () => { - const hidePreview = vi.fn() - - render(<WebsitePreview {...defaultProps} hidePreview={hidePreview} />) - - const closeButton = screen.getByRole('button') - fireEvent.click(closeButton) - - expect(hidePreview).toHaveBeenCalledTimes(1) - }) - }) - - describe('URL Display', () => { - it('should display long URLs', () => { - const longUrl = 'https://example.com/very/long/path/to/page/with/many/segments' - const currentWebsite = createMockCrawlResult({ source_url: longUrl }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - const urlElement = screen.getByTitle(longUrl) - expect(urlElement).toBeInTheDocument() - expect(urlElement).toHaveTextContent(longUrl) - }) - - it('should display URL with title attribute', () => { - const currentWebsite = createMockCrawlResult({ source_url: 'https://test.com' }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByTitle('https://test.com')).toBeInTheDocument() - }) - }) - - describe('Content Display', () => { - it('should display the markdown content in content area', () => { - const currentWebsite = createMockCrawlResult({ - markdown: 'Content with **bold** and *italic* text.', - }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByText('Content with **bold** and *italic* text.')).toBeInTheDocument() - }) - - it('should handle multiline content', () => { - const multilineContent = 'Line 1\nLine 2\nLine 3' - const currentWebsite = createMockCrawlResult({ markdown: multilineContent }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - // Multiline content is rendered as-is - expect(screen.getByText((content) => { - return content.includes('Line 1') && content.includes('Line 2') && content.includes('Line 3') - })).toBeInTheDocument() - }) - - it('should handle special characters in content', () => { - const specialContent = '<script>alert("xss")</script> & < > " \'' - const currentWebsite = createMockCrawlResult({ markdown: specialContent }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByText(specialContent)).toBeInTheDocument() - }) - }) - - describe('Edge Cases', () => { - it('should handle empty title', () => { - const currentWebsite = createMockCrawlResult({ title: '' }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should handle empty source URL', () => { - const currentWebsite = createMockCrawlResult({ source_url: '' }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByRole('button')).toBeInTheDocument() - }) - - it('should handle very long title', () => { - const longTitle = 'A'.repeat(500) - const currentWebsite = createMockCrawlResult({ title: longTitle }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByText(longTitle)).toBeInTheDocument() - }) - - it('should handle unicode characters in content', () => { - const unicodeContent = '你好世界 🌍 مرحبا こんにちは' - const currentWebsite = createMockCrawlResult({ markdown: unicodeContent }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByText(unicodeContent)).toBeInTheDocument() - }) - - it('should handle URL with query parameters', () => { - const urlWithParams = 'https://example.com/page?query=test¶m=value' - const currentWebsite = createMockCrawlResult({ source_url: urlWithParams }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByTitle(urlWithParams)).toBeInTheDocument() - }) - - it('should handle URL with hash fragment', () => { - const urlWithHash = 'https://example.com/page#section-1' - const currentWebsite = createMockCrawlResult({ source_url: urlWithHash }) - - render(<WebsitePreview {...defaultProps} currentWebsite={currentWebsite} />) - - expect(screen.getByTitle(urlWithHash)).toBeInTheDocument() - }) - }) - - describe('Styling', () => { - it('should apply container styles', () => { - const { container } = render(<WebsitePreview {...defaultProps} />) - - const mainContainer = container.firstChild as HTMLElement - expect(mainContainer).toHaveClass('flex', 'h-full', 'w-full', 'flex-col') - }) - }) - - describe('Multiple Renders', () => { - it('should update when currentWebsite changes', () => { - const website1 = createMockCrawlResult({ title: 'Website 1', markdown: 'Content 1' }) - const website2 = createMockCrawlResult({ title: 'Website 2', markdown: 'Content 2' }) - - const { rerender } = render(<WebsitePreview {...defaultProps} currentWebsite={website1} />) - - expect(screen.getByText('Website 1')).toBeInTheDocument() - expect(screen.getByText('Content 1')).toBeInTheDocument() - - rerender(<WebsitePreview {...defaultProps} currentWebsite={website2} />) - - expect(screen.getByText('Website 2')).toBeInTheDocument() - expect(screen.getByText('Content 2')).toBeInTheDocument() - }) - - it('should call new hidePreview when prop changes', () => { - const hidePreview1 = vi.fn() - const hidePreview2 = vi.fn() - - const { rerender } = render(<WebsitePreview {...defaultProps} hidePreview={hidePreview1} />) - - const closeButton = screen.getByRole('button') - fireEvent.click(closeButton) - expect(hidePreview1).toHaveBeenCalledTimes(1) - - rerender(<WebsitePreview {...defaultProps} hidePreview={hidePreview2} />) - - fireEvent.click(closeButton) - expect(hidePreview2).toHaveBeenCalledTimes(1) - expect(hidePreview1).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/actions.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/actions.spec.tsx new file mode 100644 index 0000000000..a4c5ec4938 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/actions.spec.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Actions from '../actions' + +describe('Actions', () => { + const defaultProps = { + onBack: vi.fn(), + onProcess: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verify both action buttons render with correct labels + describe('Rendering', () => { + it('should render back button and process button', () => { + render(<Actions {...defaultProps} />) + + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(2) + expect(screen.getByText('datasetPipeline.operations.dataSource')).toBeInTheDocument() + expect(screen.getByText('datasetPipeline.operations.saveAndProcess')).toBeInTheDocument() + }) + }) + + // User interactions: clicking back and process buttons + describe('User Interactions', () => { + it('should call onBack when back button clicked', () => { + render(<Actions {...defaultProps} />) + + fireEvent.click(screen.getByText('datasetPipeline.operations.dataSource')) + + expect(defaultProps.onBack).toHaveBeenCalledOnce() + }) + + it('should call onProcess when process button clicked', () => { + render(<Actions {...defaultProps} />) + + fireEvent.click(screen.getByText('datasetPipeline.operations.saveAndProcess')) + + expect(defaultProps.onProcess).toHaveBeenCalledOnce() + }) + }) + + // Props: disabled state for the process button + describe('Props', () => { + it('should disable process button when runDisabled is true', () => { + render(<Actions {...defaultProps} runDisabled />) + + const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button') + expect(processButton).toBeDisabled() + }) + + it('should enable process button when runDisabled is false', () => { + render(<Actions {...defaultProps} runDisabled={false} />) + + const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button') + expect(processButton).not.toBeDisabled() + }) + + it('should enable process button when runDisabled is undefined', () => { + render(<Actions {...defaultProps} />) + + const processButton = screen.getByText('datasetPipeline.operations.saveAndProcess').closest('button') + expect(processButton).not.toBeDisabled() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/components.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx similarity index 83% rename from web/app/components/datasets/documents/create-from-pipeline/process-documents/components.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx index 6f47575b27..c82b5a8468 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/components.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx @@ -4,18 +4,14 @@ import * as React from 'react' import * as z from 'zod' import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' import Toast from '@/app/components/base/toast' -import Actions from './actions' -import Form from './form' -import Header from './header' +import Actions from '../actions' +import Form from '../form' +import Header from '../header' -// ========================================== // Spy on Toast.notify for validation tests -// ========================================== const toastNotifySpy = vi.spyOn(Toast, 'notify') -// ========================================== // Test Data Factory Functions -// ========================================== /** * Creates mock configuration for testing @@ -56,9 +52,7 @@ const createFailingSchema = () => { } as unknown as z.ZodType } -// ========================================== // Actions Component Tests -// ========================================== describe('Actions', () => { const defaultActionsProps = { onBack: vi.fn(), @@ -69,137 +63,101 @@ describe('Actions', () => { vi.clearAllMocks() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<Actions {...defaultActionsProps} />) - // Assert expect(screen.getByText('datasetPipeline.operations.dataSource')).toBeInTheDocument() expect(screen.getByText('datasetPipeline.operations.saveAndProcess')).toBeInTheDocument() }) it('should render back button with arrow icon', () => { - // Arrange & Act render(<Actions {...defaultActionsProps} />) - // Assert const backButton = screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i }) expect(backButton).toBeInTheDocument() expect(backButton.querySelector('svg')).toBeInTheDocument() }) it('should render process button', () => { - // Arrange & Act render(<Actions {...defaultActionsProps} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).toBeInTheDocument() }) it('should have correct container layout', () => { - // Arrange & Act const { container } = render(<Actions {...defaultActionsProps} />) - // Assert const mainContainer = container.querySelector('.flex.items-center.justify-between') expect(mainContainer).toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('runDisabled prop', () => { it('should not disable process button when runDisabled is false', () => { - // Arrange & Act render(<Actions {...defaultActionsProps} runDisabled={false} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).not.toBeDisabled() }) it('should disable process button when runDisabled is true', () => { - // Arrange & Act render(<Actions {...defaultActionsProps} runDisabled={true} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).toBeDisabled() }) it('should not disable process button when runDisabled is undefined', () => { - // Arrange & Act render(<Actions {...defaultActionsProps} runDisabled={undefined} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).not.toBeDisabled() }) }) }) - // ========================================== // User Interactions Testing - // ========================================== describe('User Interactions', () => { it('should call onBack when back button is clicked', () => { - // Arrange const onBack = vi.fn() render(<Actions {...defaultActionsProps} onBack={onBack} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i })) - // Assert expect(onBack).toHaveBeenCalledTimes(1) }) it('should call onProcess when process button is clicked', () => { - // Arrange const onProcess = vi.fn() render(<Actions {...defaultActionsProps} onProcess={onProcess} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) - // Assert expect(onProcess).toHaveBeenCalledTimes(1) }) it('should not call onProcess when process button is disabled and clicked', () => { - // Arrange const onProcess = vi.fn() render(<Actions {...defaultActionsProps} onProcess={onProcess} runDisabled={true} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) - // Assert expect(onProcess).not.toHaveBeenCalled() }) }) - // ========================================== // Component Memoization Testing - // ========================================== describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Assert expect(Actions.$$typeof).toBe(Symbol.for('react.memo')) }) }) }) -// ========================================== // Header Component Tests -// ========================================== describe('Header', () => { const defaultHeaderProps = { onReset: vi.fn(), @@ -211,73 +169,53 @@ describe('Header', () => { vi.clearAllMocks() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) it('should render reset button', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} />) - // Assert expect(screen.getByRole('button', { name: /common.operation.reset/i })).toBeInTheDocument() }) it('should render preview button with icon', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).toBeInTheDocument() expect(previewButton.querySelector('svg')).toBeInTheDocument() }) it('should render title with correct text', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) it('should have correct container layout', () => { - // Arrange & Act const { container } = render(<Header {...defaultHeaderProps} />) - // Assert const mainContainer = container.querySelector('.flex.items-center.gap-x-1') expect(mainContainer).toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('resetDisabled prop', () => { it('should not disable reset button when resetDisabled is false', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} resetDisabled={false} />) - // Assert const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) expect(resetButton).not.toBeDisabled() }) it('should disable reset button when resetDisabled is true', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} resetDisabled={true} />) - // Assert const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) expect(resetButton).toBeDisabled() }) @@ -285,32 +223,25 @@ describe('Header', () => { describe('previewDisabled prop', () => { it('should not disable preview button when previewDisabled is false', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} previewDisabled={false} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).not.toBeDisabled() }) it('should disable preview button when previewDisabled is true', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} previewDisabled={true} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).toBeDisabled() }) }) it('should handle onPreview being undefined', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} onPreview={undefined} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).toBeInTheDocument() - // Click should not throw let didThrow = false try { fireEvent.click(previewButton) @@ -322,78 +253,57 @@ describe('Header', () => { }) }) - // ========================================== // User Interactions Testing - // ========================================== describe('User Interactions', () => { it('should call onReset when reset button is clicked', () => { - // Arrange const onReset = vi.fn() render(<Header {...defaultHeaderProps} onReset={onReset} />) - // Act fireEvent.click(screen.getByRole('button', { name: /common.operation.reset/i })) - // Assert expect(onReset).toHaveBeenCalledTimes(1) }) it('should not call onReset when reset button is disabled and clicked', () => { - // Arrange const onReset = vi.fn() render(<Header {...defaultHeaderProps} onReset={onReset} resetDisabled={true} />) - // Act fireEvent.click(screen.getByRole('button', { name: /common.operation.reset/i })) - // Assert expect(onReset).not.toHaveBeenCalled() }) it('should call onPreview when preview button is clicked', () => { - // Arrange const onPreview = vi.fn() render(<Header {...defaultHeaderProps} onPreview={onPreview} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) - // Assert expect(onPreview).toHaveBeenCalledTimes(1) }) it('should not call onPreview when preview button is disabled and clicked', () => { - // Arrange const onPreview = vi.fn() render(<Header {...defaultHeaderProps} onPreview={onPreview} previewDisabled={true} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) - // Assert expect(onPreview).not.toHaveBeenCalled() }) }) - // ========================================== // Component Memoization Testing - // ========================================== describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { - // Assert expect(Header.$$typeof).toBe(Symbol.for('react.memo')) }) }) - // ========================================== // Edge Cases Testing - // ========================================== describe('Edge Cases', () => { it('should handle both buttons disabled', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} resetDisabled={true} previewDisabled={true} />) - // Assert const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(resetButton).toBeDisabled() @@ -401,10 +311,8 @@ describe('Header', () => { }) it('should handle both buttons enabled', () => { - // Arrange & Act render(<Header {...defaultHeaderProps} resetDisabled={false} previewDisabled={false} />) - // Assert const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(resetButton).not.toBeDisabled() @@ -413,9 +321,7 @@ describe('Header', () => { }) }) -// ========================================== // Form Component Tests -// ========================================== describe('Form', () => { const defaultFormProps = { initialData: { field1: '' }, @@ -432,66 +338,48 @@ describe('Form', () => { toastNotifySpy.mockClear() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<Form {...defaultFormProps} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) it('should render form element', () => { - // Arrange & Act const { container } = render(<Form {...defaultFormProps} />) - // Assert const form = container.querySelector('form') expect(form).toBeInTheDocument() }) it('should render Header component', () => { - // Arrange & Act render(<Form {...defaultFormProps} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() expect(screen.getByRole('button', { name: /common.operation.reset/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })).toBeInTheDocument() }) it('should have correct form structure', () => { - // Arrange & Act const { container } = render(<Form {...defaultFormProps} />) - // Assert const form = container.querySelector('form.flex.w-full.flex-col') expect(form).toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('isRunning prop', () => { it('should disable preview button when isRunning is true', () => { - // Arrange & Act render(<Form {...defaultFormProps} isRunning={true} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).toBeDisabled() }) it('should not disable preview button when isRunning is false', () => { - // Arrange & Act render(<Form {...defaultFormProps} isRunning={false} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).not.toBeDisabled() }) @@ -499,7 +387,6 @@ describe('Form', () => { describe('configurations prop', () => { it('should render empty when configurations is empty', () => { - // Arrange & Act const { container } = render(<Form {...defaultFormProps} configurations={[]} />) // Assert - the fields container should have no field children @@ -508,17 +395,14 @@ describe('Form', () => { }) it('should render all configurations', () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'var1', label: 'Variable 1' }), createMockConfiguration({ variable: 'var2', label: 'Variable 2' }), createMockConfiguration({ variable: 'var3', label: 'Variable 3' }), ] - // Act render(<Form {...defaultFormProps} configurations={configurations} initialData={{ var1: '', var2: '', var3: '' }} />) - // Assert expect(screen.getByText('Variable 1')).toBeInTheDocument() expect(screen.getByText('Variable 2')).toBeInTheDocument() expect(screen.getByText('Variable 3')).toBeInTheDocument() @@ -526,24 +410,18 @@ describe('Form', () => { }) it('should expose submit method via ref', () => { - // Arrange const mockRef = { current: null } as React.MutableRefObject<{ submit: () => void } | null> - // Act render(<Form {...defaultFormProps} ref={mockRef} />) - // Assert expect(mockRef.current).not.toBeNull() expect(typeof mockRef.current?.submit).toBe('function') }) }) - // ========================================== // Ref Submit Testing - // ========================================== describe('Ref Submit', () => { it('should call onSubmit when ref.submit() is called', async () => { - // Arrange const onSubmit = vi.fn() const mockRef = { current: null } as React.MutableRefObject<{ submit: () => void } | null> render(<Form {...defaultFormProps} ref={mockRef} onSubmit={onSubmit} />) @@ -551,14 +429,12 @@ describe('Form', () => { // Act - call submit via ref mockRef.current?.submit() - // Assert await waitFor(() => { expect(onSubmit).toHaveBeenCalled() }) }) it('should trigger form validation when ref.submit() is called', async () => { - // Arrange const failingSchema = createFailingSchema() const mockRef = { current: null } as React.MutableRefObject<{ submit: () => void } | null> render(<Form {...defaultFormProps} ref={mockRef} schema={failingSchema} />) @@ -576,53 +452,40 @@ describe('Form', () => { }) }) - // ========================================== // User Interactions Testing - // ========================================== describe('User Interactions', () => { it('should call onPreview when preview button is clicked', () => { - // Arrange const onPreview = vi.fn() render(<Form {...defaultFormProps} onPreview={onPreview} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) - // Assert expect(onPreview).toHaveBeenCalledTimes(1) }) it('should handle form submission via form element', async () => { - // Arrange const onSubmit = vi.fn() const { container } = render(<Form {...defaultFormProps} onSubmit={onSubmit} />) const form = container.querySelector('form')! - // Act fireEvent.submit(form) - // Assert await waitFor(() => { expect(onSubmit).toHaveBeenCalled() }) }) }) - // ========================================== // Form State Testing - // ========================================== describe('Form State', () => { it('should disable reset button initially when form is not dirty', () => { - // Arrange & Act render(<Form {...defaultFormProps} />) - // Assert const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) expect(resetButton).toBeDisabled() }) it('should enable reset button when form becomes dirty', async () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'field1', label: 'Field 1' }), ] @@ -633,7 +496,6 @@ describe('Form', () => { const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'new value' } }) - // Assert await waitFor(() => { const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) expect(resetButton).not.toBeDisabled() @@ -641,7 +503,6 @@ describe('Form', () => { }) it('should reset form to initial values when reset button is clicked', async () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'field1', label: 'Field 1' }), ] @@ -659,7 +520,6 @@ describe('Form', () => { expect(resetButton).not.toBeDisabled() }) - // Click reset button const resetButton = screen.getByRole('button', { name: /common.operation.reset/i }) fireEvent.click(resetButton) @@ -670,7 +530,6 @@ describe('Form', () => { }) it('should call form.reset when handleReset is triggered', async () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'field1', label: 'Field 1' }), ] @@ -697,20 +556,15 @@ describe('Form', () => { }) }) - // ========================================== // Validation Testing - // ========================================== describe('Validation', () => { it('should show toast notification on validation error', async () => { - // Arrange const failingSchema = createFailingSchema() const { container } = render(<Form {...defaultFormProps} schema={failingSchema} />) - // Act const form = container.querySelector('form')! fireEvent.submit(form) - // Assert await waitFor(() => { expect(toastNotifySpy).toHaveBeenCalledWith({ type: 'error', @@ -720,12 +574,10 @@ describe('Form', () => { }) it('should not call onSubmit when validation fails', async () => { - // Arrange const onSubmit = vi.fn() const failingSchema = createFailingSchema() const { container } = render(<Form {...defaultFormProps} schema={failingSchema} onSubmit={onSubmit} />) - // Act const form = container.querySelector('form')! fireEvent.submit(form) @@ -737,93 +589,71 @@ describe('Form', () => { }) it('should call onSubmit when validation passes', async () => { - // Arrange const onSubmit = vi.fn() const passingSchema = createMockSchema() const { container } = render(<Form {...defaultFormProps} schema={passingSchema} onSubmit={onSubmit} />) - // Act const form = container.querySelector('form')! fireEvent.submit(form) - // Assert await waitFor(() => { expect(onSubmit).toHaveBeenCalled() }) }) }) - // ========================================== // Edge Cases Testing - // ========================================== describe('Edge Cases', () => { it('should handle empty initialData', () => { - // Arrange & Act render(<Form {...defaultFormProps} initialData={{}} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) it('should handle configurations with different field types', () => { - // Arrange const configurations = [ createMockConfiguration({ type: BaseFieldType.textInput, variable: 'text', label: 'Text Field' }), createMockConfiguration({ type: BaseFieldType.numberInput, variable: 'number', label: 'Number Field' }), ] - // Act render(<Form {...defaultFormProps} configurations={configurations} initialData={{ text: '', number: 0 }} />) - // Assert expect(screen.getByText('Text Field')).toBeInTheDocument() expect(screen.getByText('Number Field')).toBeInTheDocument() }) it('should handle null ref', () => { - // Arrange & Act render(<Form {...defaultFormProps} ref={{ current: null }} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) }) - // ========================================== // Configuration Variations Testing - // ========================================== describe('Configuration Variations', () => { it('should render configuration with label', () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'field1', label: 'Custom Label' }), ] - // Act render(<Form {...defaultFormProps} configurations={configurations} />) - // Assert expect(screen.getByText('Custom Label')).toBeInTheDocument() }) it('should render required configuration', () => { - // Arrange const configurations = [ createMockConfiguration({ variable: 'field1', label: 'Required Field', required: true }), ] - // Act render(<Form {...defaultFormProps} configurations={configurations} />) - // Assert expect(screen.getByText('Required Field')).toBeInTheDocument() }) }) }) -// ========================================== // Integration Tests (Cross-component) -// ========================================== describe('Process Documents Components Integration', () => { beforeEach(() => { vi.clearAllMocks() @@ -841,19 +671,15 @@ describe('Process Documents Components Integration', () => { } it('should render Header within Form', () => { - // Arrange & Act render(<Form {...defaultFormProps} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() expect(screen.getByRole('button', { name: /common.operation.reset/i })).toBeInTheDocument() }) it('should pass isRunning to Header for previewDisabled', () => { - // Arrange & Act render(<Form {...defaultFormProps} isRunning={true} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).toBeDisabled() }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx new file mode 100644 index 0000000000..7ce3a6396e --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/header.spec.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Header from '../header' + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick, disabled, variant }: { children: React.ReactNode, onClick: () => void, disabled?: boolean, variant: string }) => ( + <button data-testid={`btn-${variant}`} onClick={onClick} disabled={disabled}> + {children} + </button> + ), +})) + +describe('Header', () => { + const defaultProps = { + onReset: vi.fn(), + resetDisabled: false, + previewDisabled: false, + onPreview: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render chunk settings title', () => { + render(<Header {...defaultProps} />) + expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() + }) + + it('should render reset and preview buttons', () => { + render(<Header {...defaultProps} />) + expect(screen.getByTestId('btn-ghost')).toBeInTheDocument() + expect(screen.getByTestId('btn-secondary-accent')).toBeInTheDocument() + }) + + it('should call onReset when reset clicked', () => { + render(<Header {...defaultProps} />) + fireEvent.click(screen.getByTestId('btn-ghost')) + expect(defaultProps.onReset).toHaveBeenCalled() + }) + + it('should call onPreview when preview clicked', () => { + render(<Header {...defaultProps} />) + fireEvent.click(screen.getByTestId('btn-secondary-accent')) + expect(defaultProps.onPreview).toHaveBeenCalled() + }) + + it('should disable reset button when resetDisabled is true', () => { + render(<Header {...defaultProps} resetDisabled={true} />) + expect(screen.getByTestId('btn-ghost')).toBeDisabled() + }) + + it('should disable preview button when previewDisabled is true', () => { + render(<Header {...defaultProps} previewDisabled={true} />) + expect(screen.getByTestId('btn-secondary-accent')).toBeDisabled() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..440c978196 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/hooks.spec.ts @@ -0,0 +1,52 @@ +import type { PipelineProcessingParamsRequest } from '@/models/pipeline' +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useInputVariables } from '../hooks' + +const mockUseDatasetDetailContextWithSelector = vi.fn() +const mockUsePublishedPipelineProcessingParams = vi.fn() + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (value: unknown) => unknown) => mockUseDatasetDetailContextWithSelector(selector), +})) +vi.mock('@/service/use-pipeline', () => ({ + usePublishedPipelineProcessingParams: (params: PipelineProcessingParamsRequest) => mockUsePublishedPipelineProcessingParams(params), +})) + +describe('useInputVariables', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseDatasetDetailContextWithSelector.mockReturnValue('pipeline-123') + mockUsePublishedPipelineProcessingParams.mockReturnValue({ + data: { inputs: [{ name: 'query', type: 'string' }] }, + isFetching: false, + }) + }) + + it('should return paramsConfig and isFetchingParams', () => { + const { result } = renderHook(() => useInputVariables('node-1')) + + expect(result.current.paramsConfig).toEqual({ inputs: [{ name: 'query', type: 'string' }] }) + expect(result.current.isFetchingParams).toBe(false) + }) + + it('should call usePublishedPipelineProcessingParams with pipeline_id and node_id', () => { + renderHook(() => useInputVariables('node-1')) + + expect(mockUsePublishedPipelineProcessingParams).toHaveBeenCalledWith({ + pipeline_id: 'pipeline-123', + node_id: 'node-1', + }) + }) + + it('should return isFetchingParams true when loading', () => { + mockUsePublishedPipelineProcessingParams.mockReturnValue({ + data: undefined, + isFetching: true, + }) + + const { result } = renderHook(() => useInputVariables('node-1')) + expect(result.current.isFetchingParams).toBe(true) + expect(result.current.paramsConfig).toBeUndefined() + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/index.spec.tsx similarity index 85% rename from web/app/components/datasets/documents/create-from-pipeline/process-documents/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/index.spec.tsx index 318a6c2cba..6fe6957134 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/index.spec.tsx @@ -3,17 +3,13 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' import { useConfigurations, useInitialData } from '@/app/components/rag-pipeline/hooks/use-input-fields' -import { useInputVariables } from './hooks' -import ProcessDocuments from './index' - -// ========================================== -// Mock External Dependencies -// ========================================== +import { useInputVariables } from '../hooks' +import ProcessDocuments from '../index' // Mock useInputVariables hook let mockIsFetchingParams = false let mockParamsConfig: { variables: unknown[] } | undefined = { variables: [] } -vi.mock('./hooks', () => ({ +vi.mock('../hooks', () => ({ useInputVariables: vi.fn(() => ({ isFetchingParams: mockIsFetchingParams, paramsConfig: mockParamsConfig, @@ -30,9 +26,7 @@ vi.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ useConfigurations: vi.fn(() => mockConfigurations), })) -// ========================================== // Test Data Factory Functions -// ========================================== /** * Creates mock configuration for testing @@ -64,10 +58,6 @@ const createDefaultProps = (overrides: Partial<React.ComponentProps<typeof Proce ...overrides, }) -// ========================================== -// Test Suite -// ========================================== - describe('ProcessDocuments', () => { beforeEach(() => { vi.clearAllMocks() @@ -78,16 +68,11 @@ describe('ProcessDocuments', () => { mockConfigurations = [] }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { // Tests basic rendering functionality it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) // Assert - check for Header title from Form component @@ -95,10 +80,8 @@ describe('ProcessDocuments', () => { }) it('should render Form and Actions components', () => { - // Arrange const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) // Assert - check for elements from both components @@ -108,80 +91,59 @@ describe('ProcessDocuments', () => { }) it('should render with correct container structure', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = render(<ProcessDocuments {...props} />) - // Assert const mainContainer = container.querySelector('.flex.flex-col.gap-y-4.pt-4') expect(mainContainer).toBeInTheDocument() }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { describe('dataSourceNodeId prop', () => { it('should pass dataSourceNodeId to useInputVariables hook', () => { - // Arrange const props = createDefaultProps({ dataSourceNodeId: 'custom-node-id' }) - // Act render(<ProcessDocuments {...props} />) - // Assert expect(vi.mocked(useInputVariables)).toHaveBeenCalledWith('custom-node-id') }) it('should handle empty dataSourceNodeId', () => { - // Arrange const props = createDefaultProps({ dataSourceNodeId: '' }) - // Act render(<ProcessDocuments {...props} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) }) describe('isRunning prop', () => { it('should disable preview button when isRunning is true', () => { - // Arrange const props = createDefaultProps({ isRunning: true }) - // Act render(<ProcessDocuments {...props} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).toBeDisabled() }) it('should not disable preview button when isRunning is false', () => { - // Arrange const props = createDefaultProps({ isRunning: false }) - // Act render(<ProcessDocuments {...props} />) - // Assert const previewButton = screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i }) expect(previewButton).not.toBeDisabled() }) it('should disable process button in Actions when isRunning is true', () => { - // Arrange mockIsFetchingParams = false const props = createDefaultProps({ isRunning: true }) - // Act render(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).toBeDisabled() }) @@ -189,200 +151,153 @@ describe('ProcessDocuments', () => { describe('ref prop', () => { it('should expose submit method via ref', () => { - // Arrange const mockRef = { current: null } as React.MutableRefObject<{ submit: () => void } | null> const props = createDefaultProps({ ref: mockRef }) - // Act render(<ProcessDocuments {...props} />) - // Assert expect(mockRef.current).not.toBeNull() expect(typeof mockRef.current?.submit).toBe('function') }) }) }) - // ========================================== // User Interactions Testing - // ========================================== describe('User Interactions', () => { it('should call onProcess when Actions process button is clicked', () => { - // Arrange const onProcess = vi.fn() const props = createDefaultProps({ onProcess }) render(<ProcessDocuments {...props} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) - // Assert expect(onProcess).toHaveBeenCalledTimes(1) }) it('should call onBack when Actions back button is clicked', () => { - // Arrange const onBack = vi.fn() const props = createDefaultProps({ onBack }) render(<ProcessDocuments {...props} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i })) - // Assert expect(onBack).toHaveBeenCalledTimes(1) }) it('should call onPreview when preview button is clicked', () => { - // Arrange const onPreview = vi.fn() const props = createDefaultProps({ onPreview }) render(<ProcessDocuments {...props} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) - // Assert expect(onPreview).toHaveBeenCalledTimes(1) }) it('should call onSubmit when form is submitted', async () => { - // Arrange const onSubmit = vi.fn() const props = createDefaultProps({ onSubmit }) const { container } = render(<ProcessDocuments {...props} />) - // Act const form = container.querySelector('form')! fireEvent.submit(form) - // Assert await waitFor(() => { expect(onSubmit).toHaveBeenCalled() }) }) }) - // ========================================== // Hook Integration Tests - // ========================================== describe('Hook Integration', () => { it('should pass variables from useInputVariables to useInitialData', () => { - // Arrange const mockVariables = [{ variable: 'testVar', type: 'text', label: 'Test' }] mockParamsConfig = { variables: mockVariables } const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(vi.mocked(useInitialData)).toHaveBeenCalledWith(mockVariables) }) it('should pass variables from useInputVariables to useConfigurations', () => { - // Arrange const mockVariables = [{ variable: 'testVar', type: 'text', label: 'Test' }] mockParamsConfig = { variables: mockVariables } const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(vi.mocked(useConfigurations)).toHaveBeenCalledWith(mockVariables) }) it('should use empty array when paramsConfig.variables is undefined', () => { - // Arrange mockParamsConfig = { variables: undefined as unknown as unknown[] } const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(vi.mocked(useInitialData)).toHaveBeenCalledWith([]) expect(vi.mocked(useConfigurations)).toHaveBeenCalledWith([]) }) it('should use empty array when paramsConfig is undefined', () => { - // Arrange mockParamsConfig = undefined const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(vi.mocked(useInitialData)).toHaveBeenCalledWith([]) expect(vi.mocked(useConfigurations)).toHaveBeenCalledWith([]) }) }) - // ========================================== // Actions runDisabled Testing - // ========================================== describe('Actions runDisabled', () => { it('should disable process button when isFetchingParams is true', () => { - // Arrange mockIsFetchingParams = true const props = createDefaultProps({ isRunning: false }) - // Act render(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).toBeDisabled() }) it('should disable process button when isRunning is true', () => { - // Arrange mockIsFetchingParams = false const props = createDefaultProps({ isRunning: true }) - // Act render(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).toBeDisabled() }) it('should enable process button when both isFetchingParams and isRunning are false', () => { - // Arrange mockIsFetchingParams = false const props = createDefaultProps({ isRunning: false }) - // Act render(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).not.toBeDisabled() }) it('should disable process button when both isFetchingParams and isRunning are true', () => { - // Arrange mockIsFetchingParams = true const props = createDefaultProps({ isRunning: true }) - // Act render(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) expect(processButton).toBeDisabled() }) }) - // ========================================== // Component Memoization Testing - // ========================================== describe('Component Memoization', () => { it('should be wrapped with React.memo', () => { // Assert - verify component has memo wrapper @@ -390,86 +305,65 @@ describe('ProcessDocuments', () => { }) it('should render correctly after rerender with same props', () => { - // Arrange const props = createDefaultProps() - // Act const { rerender } = render(<ProcessDocuments {...props} />) rerender(<ProcessDocuments {...props} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) it('should update when dataSourceNodeId prop changes', () => { - // Arrange const props = createDefaultProps({ dataSourceNodeId: 'node-1' }) - // Act const { rerender } = render(<ProcessDocuments {...props} />) expect(vi.mocked(useInputVariables)).toHaveBeenLastCalledWith('node-1') rerender(<ProcessDocuments {...props} dataSourceNodeId="node-2" />) - // Assert expect(vi.mocked(useInputVariables)).toHaveBeenLastCalledWith('node-2') }) }) - // ========================================== // Edge Cases Testing - // ========================================== describe('Edge Cases', () => { it('should handle undefined paramsConfig gracefully', () => { - // Arrange mockParamsConfig = undefined const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) it('should handle empty variables array', () => { - // Arrange mockParamsConfig = { variables: [] } mockConfigurations = [] const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() }) it('should handle special characters in dataSourceNodeId', () => { - // Arrange const props = createDefaultProps({ dataSourceNodeId: 'node-id-with-special_chars:123' }) - // Act render(<ProcessDocuments {...props} />) - // Assert expect(vi.mocked(useInputVariables)).toHaveBeenCalledWith('node-id-with-special_chars:123') }) it('should handle long dataSourceNodeId', () => { - // Arrange const longId = 'a'.repeat(1000) const props = createDefaultProps({ dataSourceNodeId: longId }) - // Act render(<ProcessDocuments {...props} />) - // Assert expect(vi.mocked(useInputVariables)).toHaveBeenCalledWith(longId) }) it('should handle multiple callbacks without interference', () => { - // Arrange const onProcess = vi.fn() const onBack = vi.fn() const onPreview = vi.fn() @@ -477,21 +371,17 @@ describe('ProcessDocuments', () => { render(<ProcessDocuments {...props} />) - // Act fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i })) fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.operations.dataSource/i })) fireEvent.click(screen.getByRole('button', { name: /datasetPipeline.addDocuments.stepTwo.previewChunks/i })) - // Assert expect(onProcess).toHaveBeenCalledTimes(1) expect(onBack).toHaveBeenCalledTimes(1) expect(onPreview).toHaveBeenCalledTimes(1) }) }) - // ========================================== // runDisabled Logic Testing (with test.each) - // ========================================== describe('runDisabled Logic', () => { const runDisabledTestCases = [ { isFetchingParams: false, isRunning: false, expectedDisabled: false }, @@ -503,14 +393,11 @@ describe('ProcessDocuments', () => { it.each(runDisabledTestCases)( 'should set process button disabled=$expectedDisabled when isFetchingParams=$isFetchingParams and isRunning=$isRunning', ({ isFetchingParams, isRunning, expectedDisabled }) => { - // Arrange mockIsFetchingParams = isFetchingParams const props = createDefaultProps({ isRunning }) - // Act render(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: /datasetPipeline.operations.saveAndProcess/i }) if (expectedDisabled) expect(processButton).toBeDisabled() @@ -520,12 +407,9 @@ describe('ProcessDocuments', () => { ) }) - // ========================================== // Configuration Rendering Tests - // ========================================== describe('Configuration Rendering', () => { it('should render configurations as form fields', () => { - // Arrange mockConfigurations = [ createMockConfiguration({ variable: 'var1', label: 'Variable 1' }), createMockConfiguration({ variable: 'var2', label: 'Variable 2' }), @@ -533,16 +417,13 @@ describe('ProcessDocuments', () => { mockInitialData = { var1: '', var2: '' } const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(screen.getByText('Variable 1')).toBeInTheDocument() expect(screen.getByText('Variable 2')).toBeInTheDocument() }) it('should handle configurations with different field types', () => { - // Arrange mockConfigurations = [ createMockConfiguration({ type: BaseFieldType.textInput, variable: 'textVar', label: 'Text Field' }), createMockConfiguration({ type: BaseFieldType.numberInput, variable: 'numberVar', label: 'Number Field' }), @@ -550,21 +431,16 @@ describe('ProcessDocuments', () => { mockInitialData = { textVar: '', numberVar: 0 } const props = createDefaultProps() - // Act render(<ProcessDocuments {...props} />) - // Assert expect(screen.getByText('Text Field')).toBeInTheDocument() expect(screen.getByText('Number Field')).toBeInTheDocument() }) }) - // ========================================== // Full Integration Props Testing - // ========================================== describe('Full Prop Integration', () => { it('should render correctly with all props provided', () => { - // Arrange const mockRef = { current: null } as React.MutableRefObject<{ submit: () => void } | null> mockIsFetchingParams = false mockParamsConfig = { variables: [{ variable: 'testVar', type: 'text', label: 'Test' }] } @@ -581,10 +457,8 @@ describe('ProcessDocuments', () => { onBack: vi.fn(), } - // Act render(<ProcessDocuments {...props} />) - // Assert expect(screen.getByText('datasetPipeline.addDocuments.stepTwo.chunkSettings')).toBeInTheDocument() expect(screen.getByText('datasetPipeline.operations.dataSource')).toBeInTheDocument() expect(screen.getByText('datasetPipeline.operations.saveAndProcess')).toBeInTheDocument() diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/datasets/documents/create-from-pipeline/processing/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/processing/__tests__/index.spec.tsx index 554af2a546..688d26f245 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/__tests__/index.spec.tsx @@ -3,11 +3,7 @@ import type { InitialDocumentDetail } from '@/models/pipeline' import { render, screen } from '@testing-library/react' import * as React from 'react' import { DatasourceType } from '@/models/pipeline' -import Processing from './index' - -// ========================================== -// Mock External Dependencies -// ========================================== +import Processing from '../index' // Mock useDocLink - returns a function that generates doc URLs // Strips leading slash from path to match actual implementation behavior @@ -33,7 +29,7 @@ vi.mock('@/context/dataset-detail', () => ({ // Mock the EmbeddingProcess component to track props let embeddingProcessProps: Record<string, unknown> = {} -vi.mock('./embedding-process', () => ({ +vi.mock('../embedding-process', () => ({ default: (props: Record<string, unknown>) => { embeddingProcessProps = props return ( @@ -48,9 +44,7 @@ vi.mock('./embedding-process', () => ({ }, })) -// ========================================== // Test Data Factory Functions -// ========================================== /** * Creates a mock InitialDocumentDetail for testing @@ -80,10 +74,6 @@ const createMockDocuments = (count: number): InitialDocumentDetail[] => position: index, })) -// ========================================== -// Test Suite -// ========================================== - describe('Processing', () => { beforeEach(() => { vi.clearAllMocks() @@ -98,47 +88,36 @@ describe('Processing', () => { } }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { // Tests basic rendering functionality it('should render without crashing', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(2), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() }) it('should render the EmbeddingProcess component', () => { - // Arrange const props = { batchId: 'batch-456', documents: createMockDocuments(3), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() }) it('should render the side tip section with correct content', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) // Assert - verify translation keys are rendered @@ -148,16 +127,13 @@ describe('Processing', () => { }) it('should render the documentation link with correct attributes', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert const link = screen.getByRole('link', { name: 'datasetPipeline.addDocuments.stepThree.learnMore' }) expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/use-dify/knowledge/knowledge-pipeline/authorize-data-source') expect(link).toHaveAttribute('target', '_blank') @@ -165,13 +141,11 @@ describe('Processing', () => { }) it('should render the book icon in the side tip', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(1), } - // Act const { container } = render(<Processing {...props} />) // Assert - check for icon container with shadow styling @@ -180,45 +154,35 @@ describe('Processing', () => { }) }) - // ========================================== - // Props Testing - // ========================================== describe('Props', () => { // Tests that props are correctly passed to child components it('should pass batchId to EmbeddingProcess', () => { - // Arrange const testBatchId = 'test-batch-id-789' const props = { batchId: testBatchId, documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-batch-id')).toHaveTextContent(testBatchId) expect(embeddingProcessProps.batchId).toBe(testBatchId) }) it('should pass documents to EmbeddingProcess', () => { - // Arrange const documents = createMockDocuments(5) const props = { batchId: 'batch-123', documents, } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('5') expect(embeddingProcessProps.documents).toEqual(documents) }) it('should pass datasetId from context to EmbeddingProcess', () => { - // Arrange mockDataset = { id: 'context-dataset-id', indexing_technique: 'high_quality', @@ -229,16 +193,13 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-dataset-id')).toHaveTextContent('context-dataset-id') expect(embeddingProcessProps.datasetId).toBe('context-dataset-id') }) it('should pass indexingType from context to EmbeddingProcess', () => { - // Arrange mockDataset = { id: 'dataset-123', indexing_technique: 'economy', @@ -249,16 +210,13 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-indexing-type')).toHaveTextContent('economy') expect(embeddingProcessProps.indexingType).toBe('economy') }) it('should pass retrievalMethod from context to EmbeddingProcess', () => { - // Arrange mockDataset = { id: 'dataset-123', indexing_technique: 'high_quality', @@ -269,16 +227,13 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-retrieval-method')).toHaveTextContent('keyword_search') expect(embeddingProcessProps.retrievalMethod).toBe('keyword_search') }) it('should handle different document types', () => { - // Arrange const documents = [ createMockDocument({ id: 'doc-local', @@ -301,63 +256,49 @@ describe('Processing', () => { documents, } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('3') expect(embeddingProcessProps.documents).toEqual(documents) }) }) - // ========================================== - // Edge Cases - // ========================================== describe('Edge Cases', () => { // Tests for boundary conditions and unusual inputs it('should handle empty documents array', () => { - // Arrange const props = { batchId: 'batch-123', documents: [], } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('0') expect(embeddingProcessProps.documents).toEqual([]) }) it('should handle empty batchId', () => { - // Arrange const props = { batchId: '', documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('') }) it('should handle undefined dataset from context', () => { - // Arrange mockDataset = undefined const props = { batchId: 'batch-123', documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.datasetId).toBeUndefined() expect(embeddingProcessProps.indexingType).toBeUndefined() @@ -365,7 +306,6 @@ describe('Processing', () => { }) it('should handle dataset with undefined id', () => { - // Arrange mockDataset = { id: undefined, indexing_technique: 'high_quality', @@ -376,16 +316,13 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.datasetId).toBeUndefined() }) it('should handle dataset with undefined indexing_technique', () => { - // Arrange mockDataset = { id: 'dataset-123', indexing_technique: undefined, @@ -396,16 +333,13 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.indexingType).toBeUndefined() }) it('should handle dataset with undefined retrieval_model_dict', () => { - // Arrange mockDataset = { id: 'dataset-123', indexing_technique: 'high_quality', @@ -416,16 +350,13 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.retrievalMethod).toBeUndefined() }) it('should handle dataset with empty retrieval_model_dict', () => { - // Arrange mockDataset = { id: 'dataset-123', indexing_technique: 'high_quality', @@ -436,31 +367,25 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.retrievalMethod).toBeUndefined() }) it('should handle large number of documents', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(100), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('100') }) it('should handle documents with error status', () => { - // Arrange const documents = [ createMockDocument({ id: 'doc-error', @@ -474,16 +399,13 @@ describe('Processing', () => { documents, } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.documents).toEqual(documents) }) it('should handle documents with special characters in names', () => { - // Arrange const documents = [ createMockDocument({ id: 'doc-special', @@ -495,36 +417,28 @@ describe('Processing', () => { documents, } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.documents).toEqual(documents) }) it('should handle batchId with special characters', () => { - // Arrange const props = { batchId: 'batch-123-abc_xyz:456', documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-batch-id')).toHaveTextContent('batch-123-abc_xyz:456') }) }) - // ========================================== // Context Integration Tests - // ========================================== describe('Context Integration', () => { // Tests for proper context usage it('should correctly use context selectors for all dataset properties', () => { - // Arrange mockDataset = { id: 'full-dataset-id', indexing_technique: 'high_quality', @@ -535,10 +449,8 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(embeddingProcessProps.datasetId).toBe('full-dataset-id') expect(embeddingProcessProps.indexingType).toBe('high_quality') expect(embeddingProcessProps.retrievalMethod).toBe('hybrid_search') @@ -556,7 +468,6 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act const { rerender } = render(<Processing {...props} />) // Assert economy indexing @@ -577,19 +488,14 @@ describe('Processing', () => { }) }) - // ========================================== - // Layout Tests - // ========================================== describe('Layout', () => { // Tests for proper layout and structure it('should render with correct layout structure', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(1), } - // Act const { container } = render(<Processing {...props} />) // Assert - Check for flex layout with proper widths @@ -606,13 +512,11 @@ describe('Processing', () => { }) it('should render side tip card with correct styling', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(1), } - // Act const { container } = render(<Processing {...props} />) // Assert - Check for card container with rounded corners and background @@ -621,28 +525,22 @@ describe('Processing', () => { }) it('should constrain max-width for EmbeddingProcess container', () => { - // Arrange const props = { batchId: 'batch-123', documents: createMockDocuments(1), } - // Act const { container } = render(<Processing {...props} />) - // Assert const maxWidthContainer = container.querySelector('.max-w-\\[640px\\]') expect(maxWidthContainer).toBeInTheDocument() }) }) - // ========================================== // Document Variations Tests - // ========================================== describe('Document Variations', () => { // Tests for different document configurations it('should handle documents with all indexing statuses', () => { - // Arrange const statuses: DocumentIndexingStatus[] = [ 'waiting', 'parsing', @@ -666,16 +564,13 @@ describe('Processing', () => { documents, } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-documents-count')).toHaveTextContent(String(statuses.length)) expect(embeddingProcessProps.documents).toEqual(documents) }) it('should handle documents with enabled and disabled states', () => { - // Arrange const documents = [ createMockDocument({ id: 'doc-enabled', enable: true }), createMockDocument({ id: 'doc-disabled', enable: false }), @@ -685,16 +580,13 @@ describe('Processing', () => { documents, } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('ep-documents-count')).toHaveTextContent('2') expect(embeddingProcessProps.documents).toEqual(documents) }) it('should handle documents from online drive source', () => { - // Arrange const documents = [ createMockDocument({ id: 'doc-drive', @@ -708,16 +600,13 @@ describe('Processing', () => { documents, } - // Act render(<Processing {...props} />) - // Assert expect(screen.getByTestId('embedding-process')).toBeInTheDocument() expect(embeddingProcessProps.documents).toEqual(documents) }) it('should handle documents with complex data_source_info', () => { - // Arrange const documents = [ createMockDocument({ id: 'doc-notion', @@ -735,23 +624,18 @@ describe('Processing', () => { documents, } - // Act render(<Processing {...props} />) - // Assert expect(embeddingProcessProps.documents).toEqual(documents) }) }) - // ========================================== // Retrieval Method Variations - // ========================================== describe('Retrieval Method Variations', () => { // Tests for different retrieval methods const retrievalMethods = ['semantic_search', 'keyword_search', 'hybrid_search', 'full_text_search'] it.each(retrievalMethods)('should handle %s retrieval method', (method) => { - // Arrange mockDataset = { id: 'dataset-123', indexing_technique: 'high_quality', @@ -762,23 +646,18 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(embeddingProcessProps.retrievalMethod).toBe(method) }) }) - // ========================================== // Indexing Technique Variations - // ========================================== describe('Indexing Technique Variations', () => { // Tests for different indexing techniques const indexingTechniques = ['high_quality', 'economy'] it.each(indexingTechniques)('should handle %s indexing technique', (technique) => { - // Arrange mockDataset = { id: 'dataset-123', indexing_technique: technique, @@ -789,10 +668,8 @@ describe('Processing', () => { documents: createMockDocuments(1), } - // Act render(<Processing {...props} />) - // Assert expect(embeddingProcessProps.indexingType).toBe(technique) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx index 81e97a79a1..aa107b8635 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx @@ -7,13 +7,8 @@ import { Plan } from '@/app/components/billing/type' import { IndexingType } from '@/app/components/datasets/create/step-two' import { DatasourceType } from '@/models/pipeline' import { RETRIEVE_METHOD } from '@/types/app' -import EmbeddingProcess from './index' +import EmbeddingProcess from '../index' -// ========================================== -// Mock External Dependencies -// ========================================== - -// Mock next/navigation const mockPush = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ @@ -64,9 +59,7 @@ vi.mock('@/hooks/use-api-access-url', () => ({ useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets', })) -// ========================================== // Test Data Factory Functions -// ========================================== /** * Creates a mock InitialDocumentDetail for testing @@ -122,10 +115,6 @@ const createDefaultProps = (overrides: Partial<{ ...overrides, }) -// ========================================== -// Test Suite -// ========================================== - describe('EmbeddingProcess', () => { beforeEach(() => { vi.clearAllMocks() @@ -151,30 +140,22 @@ describe('EmbeddingProcess', () => { vi.useRealTimers() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { // Tests basic rendering functionality it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) - // Assert expect(screen.getByTestId('rule-detail')).toBeInTheDocument() }) it('should render RuleDetail component with correct props', () => { - // Arrange const props = createDefaultProps({ indexingType: IndexingType.ECONOMICAL, retrievalMethod: RETRIEVE_METHOD.fullText, }) - // Act render(<EmbeddingProcess {...props} />) // Assert - RuleDetail renders FieldInfo components with translated text @@ -183,13 +164,10 @@ describe('EmbeddingProcess', () => { }) it('should render API reference link with correct URL', () => { - // Arrange const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) - // Assert const apiLink = screen.getByRole('link', { name: /access the api/i }) expect(apiLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets') expect(apiLink).toHaveAttribute('target', '_blank') @@ -197,231 +175,185 @@ describe('EmbeddingProcess', () => { }) it('should render navigation button', () => { - // Arrange const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) - // Assert expect(screen.getByText('datasetCreation.stepThree.navTo')).toBeInTheDocument() }) }) - // ========================================== // Billing/Upgrade Banner Tests - // ========================================== describe('Billing and Upgrade Banner', () => { // Tests for billing-related UI it('should not show upgrade banner when billing is disabled', () => { - // Arrange mockEnableBilling = false const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) - // Assert expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityUpgrade')).not.toBeInTheDocument() }) it('should show upgrade banner when billing is enabled and plan is not team', () => { - // Arrange mockEnableBilling = true mockPlanType = Plan.sandbox const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) - // Assert expect(screen.getByText('billing.plansCommon.documentProcessingPriorityUpgrade')).toBeInTheDocument() }) it('should not show upgrade banner when plan is team', () => { - // Arrange mockEnableBilling = true mockPlanType = Plan.team const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) - // Assert expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityUpgrade')).not.toBeInTheDocument() }) it('should show upgrade banner for professional plan', () => { - // Arrange mockEnableBilling = true mockPlanType = Plan.professional const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) - // Assert expect(screen.getByText('billing.plansCommon.documentProcessingPriorityUpgrade')).toBeInTheDocument() }) }) - // ========================================== // Status Display Tests - // ========================================== describe('Status Display', () => { // Tests for embedding status display it('should show waiting status when all documents are waiting', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'waiting' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.waiting')).toBeInTheDocument() }) it('should show processing status when any document is indexing', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() }) it('should show processing status when any document is splitting', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'splitting' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() }) it('should show processing status when any document is parsing', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'parsing' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() }) it('should show processing status when any document is cleaning', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'cleaning' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() }) it('should show completed status when all documents are completed', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'completed' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() }) it('should show completed status when all documents have error status', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'error', error: 'Processing failed' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() }) it('should show completed status when all documents are paused', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'paused' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() }) }) - // ========================================== // Progress Bar Tests - // ========================================== describe('Progress Display', () => { // Tests for progress bar rendering it('should show progress percentage for embedding documents', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ @@ -433,18 +365,15 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('50%')).toBeInTheDocument() }) it('should cap progress at 100%', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ @@ -456,18 +385,15 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('100%')).toBeInTheDocument() }) it('should show 0% when total_segments is 0', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ @@ -479,18 +405,15 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('0%')).toBeInTheDocument() }) it('should not show progress for completed documents', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ @@ -502,27 +425,21 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.queryByText('100%')).not.toBeInTheDocument() }) }) - // ========================================== // Polling Logic Tests - // ========================================== describe('Polling Logic', () => { // Tests for API polling behavior it('should start polling on mount', async () => { - // Arrange const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) // Assert - verify fetch was called at least once @@ -532,7 +449,6 @@ describe('EmbeddingProcess', () => { }) it('should continue polling while documents are processing', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), @@ -540,7 +456,6 @@ describe('EmbeddingProcess', () => { const props = createDefaultProps({ documents: [doc1] }) const initialCallCount = mockFetchIndexingStatus.mock.calls.length - // Act render(<EmbeddingProcess {...props} />) // Wait for initial fetch @@ -560,14 +475,12 @@ describe('EmbeddingProcess', () => { }) it('should stop polling when all documents are completed', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'completed' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) // Wait for initial fetch and state update @@ -586,14 +499,12 @@ describe('EmbeddingProcess', () => { }) it('should stop polling when all documents have errors', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'error' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) // Wait for initial fetch @@ -611,14 +522,12 @@ describe('EmbeddingProcess', () => { }) it('should stop polling when all documents are paused', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'paused' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) // Wait for initial fetch @@ -636,14 +545,12 @@ describe('EmbeddingProcess', () => { }) it('should cleanup timeout on unmount', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act const { unmount } = render(<EmbeddingProcess {...props} />) // Wait for initial fetch @@ -664,67 +571,52 @@ describe('EmbeddingProcess', () => { }) }) - // ========================================== - // User Interactions Tests - // ========================================== describe('User Interactions', () => { // Tests for button clicks and navigation it('should navigate to document list when nav button is clicked', async () => { - // Arrange const props = createDefaultProps({ datasetId: 'my-dataset-123' }) - // Act render(<EmbeddingProcess {...props} />) const navButton = screen.getByText('datasetCreation.stepThree.navTo') fireEvent.click(navButton) - // Assert expect(mockInvalidDocumentList).toHaveBeenCalled() expect(mockPush).toHaveBeenCalledWith('/datasets/my-dataset-123/documents') }) it('should call invalidDocumentList before navigation', () => { - // Arrange const props = createDefaultProps() const callOrder: string[] = [] mockInvalidDocumentList.mockImplementation(() => callOrder.push('invalidate')) mockPush.mockImplementation(() => callOrder.push('push')) - // Act render(<EmbeddingProcess {...props} />) const navButton = screen.getByText('datasetCreation.stepThree.navTo') fireEvent.click(navButton) - // Assert expect(callOrder).toEqual(['invalidate', 'push']) }) }) - // ========================================== // Document Display Tests - // ========================================== describe('Document Display', () => { // Tests for document list rendering it('should display document names', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'my-report.pdf' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('my-report.pdf')).toBeInTheDocument() }) it('should display multiple documents', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'file1.txt' }) const doc2 = createMockDocument({ id: 'doc-2', name: 'file2.pdf' }) mockIndexingStatusData = [ @@ -733,43 +625,35 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1, doc2] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('file1.txt')).toBeInTheDocument() expect(screen.getByText('file2.pdf')).toBeInTheDocument() }) it('should handle documents with special characters in names', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'report_2024 (final) - copy.pdf' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('report_2024 (final) - copy.pdf')).toBeInTheDocument() }) }) - // ========================================== // Data Source Type Tests - // ========================================== describe('Data Source Types', () => { // Tests for different data source type displays it('should handle local file data source', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'local-file.pdf', @@ -780,18 +664,15 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('local-file.pdf')).toBeInTheDocument() }) it('should handle online document data source', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'Notion Page', @@ -803,18 +684,15 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('Notion Page')).toBeInTheDocument() }) it('should handle website crawl data source', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'https://example.com/page', @@ -825,18 +703,15 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('https://example.com/page')).toBeInTheDocument() }) it('should handle online drive data source', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'Google Drive Document', @@ -847,24 +722,19 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('Google Drive Document')).toBeInTheDocument() }) }) - // ========================================== // Error Handling Tests - // ========================================== describe('Error Handling', () => { // Tests for error states and displays it('should display error icon for documents with error status', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ @@ -875,7 +745,6 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act const { container } = render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() @@ -887,7 +756,6 @@ describe('EmbeddingProcess', () => { }) it('should apply error styling to document row with error', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ @@ -898,7 +766,6 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act const { container } = render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() @@ -910,13 +777,9 @@ describe('EmbeddingProcess', () => { }) }) - // ========================================== - // Edge Cases - // ========================================== describe('Edge Cases', () => { // Tests for boundary conditions it('should throw error when documents array is empty', () => { - // Arrange // The component accesses documents[0].id for useProcessRule (line 81-82), // which throws TypeError when documents array is empty. // This test documents this known limitation. @@ -934,11 +797,9 @@ describe('EmbeddingProcess', () => { }) it('should handle empty indexing status response', async () => { - // Arrange mockIndexingStatusData = [] const props = createDefaultProps() - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() @@ -951,7 +812,6 @@ describe('EmbeddingProcess', () => { }) it('should handle document with undefined name', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: undefined as unknown as string }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'doc-1', indexing_status: 'indexing' }), @@ -963,7 +823,6 @@ describe('EmbeddingProcess', () => { }) it('should handle document not found in indexing status', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ id: 'other-doc', indexing_status: 'indexing' }), @@ -975,7 +834,6 @@ describe('EmbeddingProcess', () => { }) it('should handle undefined indexing_status', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ createMockIndexingStatus({ @@ -990,7 +848,6 @@ describe('EmbeddingProcess', () => { }) it('should handle mixed status documents', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1' }) const doc2 = createMockDocument({ id: 'doc-2' }) const doc3 = createMockDocument({ id: 'doc-3' }) @@ -1001,7 +858,6 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1, doc2, doc3] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() @@ -1012,16 +868,12 @@ describe('EmbeddingProcess', () => { }) }) - // ========================================== // Props Variations Tests - // ========================================== describe('Props Variations', () => { // Tests for different prop combinations it('should handle undefined indexingType', () => { - // Arrange const props = createDefaultProps({ indexingType: undefined }) - // Act render(<EmbeddingProcess {...props} />) // Assert - component renders without crashing @@ -1029,10 +881,8 @@ describe('EmbeddingProcess', () => { }) it('should handle undefined retrievalMethod', () => { - // Arrange const props = createDefaultProps({ retrievalMethod: undefined }) - // Act render(<EmbeddingProcess {...props} />) // Assert - component renders without crashing @@ -1040,13 +890,11 @@ describe('EmbeddingProcess', () => { }) it('should pass different indexingType values', () => { - // Arrange const indexingTypes = [IndexingType.QUALIFIED, IndexingType.ECONOMICAL] indexingTypes.forEach((indexingType) => { const props = createDefaultProps({ indexingType }) - // Act const { unmount } = render(<EmbeddingProcess {...props} />) // Assert - RuleDetail renders and shows appropriate text based on indexingType @@ -1057,13 +905,11 @@ describe('EmbeddingProcess', () => { }) it('should pass different retrievalMethod values', () => { - // Arrange const retrievalMethods = [RETRIEVE_METHOD.semantic, RETRIEVE_METHOD.fullText, RETRIEVE_METHOD.hybrid] retrievalMethods.forEach((retrievalMethod) => { const props = createDefaultProps({ retrievalMethod }) - // Act const { unmount } = render(<EmbeddingProcess {...props} />) // Assert - RuleDetail renders and shows appropriate text based on retrievalMethod @@ -1074,9 +920,6 @@ describe('EmbeddingProcess', () => { }) }) - // ========================================== - // Memoization Tests - // ========================================== describe('Memoization Logic', () => { // Tests for useMemo computed values it('should correctly compute isEmbeddingWaiting', async () => { @@ -1089,13 +932,11 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1, doc2] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.waiting')).toBeInTheDocument() }) @@ -1109,13 +950,11 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1, doc2] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.processing')).toBeInTheDocument() }) @@ -1131,24 +970,19 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1, doc2, doc3] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('datasetDocuments.embedding.completed')).toBeInTheDocument() }) }) - // ========================================== // File Type Detection Tests - // ========================================== describe('File Type Detection', () => { // Tests for getFileType helper function it('should extract file extension correctly', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'document.pdf', @@ -1159,7 +993,6 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() @@ -1170,7 +1003,6 @@ describe('EmbeddingProcess', () => { }) it('should handle files with multiple dots', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'my.report.2024.pdf', @@ -1181,18 +1013,15 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('my.report.2024.pdf')).toBeInTheDocument() }) it('should handle files without extension', async () => { - // Arrange const doc1 = createMockDocument({ id: 'doc-1', name: 'README', @@ -1203,24 +1032,19 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() }) - // Assert expect(screen.getByText('README')).toBeInTheDocument() }) }) - // ========================================== // Priority Label Tests - // ========================================== describe('Priority Label', () => { // Tests for priority label display it('should show priority label when billing is enabled', async () => { - // Arrange mockEnableBilling = true mockPlanType = Plan.sandbox const doc1 = createMockDocument({ id: 'doc-1' }) @@ -1229,7 +1053,6 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act const { container } = render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() @@ -1241,7 +1064,6 @@ describe('EmbeddingProcess', () => { }) it('should not show priority label when billing is disabled', async () => { - // Arrange mockEnableBilling = false const doc1 = createMockDocument({ id: 'doc-1' }) mockIndexingStatusData = [ @@ -1249,7 +1071,6 @@ describe('EmbeddingProcess', () => { ] const props = createDefaultProps({ documents: [doc1] }) - // Act render(<EmbeddingProcess {...props} />) await waitFor(() => { expect(mockFetchIndexingStatus).toHaveBeenCalled() diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx similarity index 85% rename from web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx index 33b162d450..c11caeb156 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/rule-detail.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/rule-detail.spec.tsx @@ -4,13 +4,9 @@ import * as React from 'react' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ProcessMode } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import RuleDetail from './rule-detail' +import RuleDetail from '../rule-detail' -// ========================================== -// Mock External Dependencies -// ========================================== - -// Mock next/image (using img element for simplicity in tests) +// Override global next/image auto-mock: tests assert on data-testid="next-image" and src attributes vi.mock('next/image', () => ({ default: function MockImage({ src, alt, className }: { src: string, alt: string, className?: string }) { // eslint-disable-next-line next/no-img-element @@ -42,9 +38,7 @@ vi.mock('@/app/components/datasets/create/icons', () => ({ }, })) -// ========================================== // Test Data Factory Functions -// ========================================== /** * Creates a mock ProcessRuleResponse for testing @@ -71,33 +65,22 @@ const createMockProcessRule = (overrides: Partial<ProcessRuleResponse> = {}): Pr ...overrides, }) -// ========================================== -// Test Suite -// ========================================== - describe('RuleDetail', () => { beforeEach(() => { vi.clearAllMocks() }) - // ========================================== - // Rendering Tests - // ========================================== describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<RuleDetail />) - // Assert const fieldInfos = screen.getAllByTestId('field-info') expect(fieldInfos).toHaveLength(3) }) it('should render three FieldInfo components', () => { - // Arrange const sourceData = createMockProcessRule() - // Act render( <RuleDetail sourceData={sourceData} @@ -106,13 +89,11 @@ describe('RuleDetail', () => { />, ) - // Assert const fieldInfos = screen.getAllByTestId('field-info') expect(fieldInfos).toHaveLength(3) }) it('should render mode field with correct label', () => { - // Arrange & Act render(<RuleDetail />) // Assert - first field-info is for mode @@ -121,45 +102,34 @@ describe('RuleDetail', () => { }) }) - // ========================================== // Mode Value Tests - // ========================================== describe('Mode Value', () => { it('should show "-" when sourceData is undefined', () => { - // Arrange & Act render(<RuleDetail />) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[0]).toHaveTextContent('-') }) it('should show "-" when sourceData.mode is undefined', () => { - // Arrange const sourceData = { ...createMockProcessRule(), mode: undefined as unknown as ProcessMode } - // Act render(<RuleDetail sourceData={sourceData} />) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[0]).toHaveTextContent('-') }) it('should show custom mode text when mode is general', () => { - // Arrange const sourceData = createMockProcessRule({ mode: ProcessMode.general }) - // Act render(<RuleDetail sourceData={sourceData} />) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[0]).toHaveTextContent('datasetDocuments.embedding.custom') }) it('should show hierarchical mode with paragraph parent mode', () => { - // Arrange const sourceData = createMockProcessRule({ mode: ProcessMode.parentChild, rules: { @@ -170,16 +140,13 @@ describe('RuleDetail', () => { }, }) - // Act render(<RuleDetail sourceData={sourceData} />) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[0]).toHaveTextContent('datasetDocuments.embedding.hierarchical · dataset.parentMode.paragraph') }) it('should show hierarchical mode with full-doc parent mode', () => { - // Arrange const sourceData = createMockProcessRule({ mode: ProcessMode.parentChild, rules: { @@ -190,24 +157,18 @@ describe('RuleDetail', () => { }, }) - // Act render(<RuleDetail sourceData={sourceData} />) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[0]).toHaveTextContent('datasetDocuments.embedding.hierarchical · dataset.parentMode.fullDoc') }) }) - // ========================================== // Indexing Type Tests - // ========================================== describe('Indexing Type', () => { it('should show qualified indexing type', () => { - // Arrange & Act render(<RuleDetail indexingType={IndexingType.QUALIFIED} />) - // Assert const fieldInfos = screen.getAllByTestId('field-info') expect(fieldInfos[1]).toHaveAttribute('data-label', 'datasetCreation.stepTwo.indexMode') @@ -216,48 +177,37 @@ describe('RuleDetail', () => { }) it('should show economical indexing type', () => { - // Arrange & Act render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[1]).toHaveTextContent('datasetCreation.stepTwo.economical') }) it('should show high_quality icon for qualified indexing', () => { - // Arrange & Act render(<RuleDetail indexingType={IndexingType.QUALIFIED} />) - // Assert const images = screen.getAllByTestId('next-image') expect(images[0]).toHaveAttribute('src', '/icons/high_quality.svg') }) it('should show economical icon for economical indexing', () => { - // Arrange & Act render(<RuleDetail indexingType={IndexingType.ECONOMICAL} />) - // Assert const images = screen.getAllByTestId('next-image') expect(images[0]).toHaveAttribute('src', '/icons/economical.svg') }) }) - // ========================================== // Retrieval Method Tests - // ========================================== describe('Retrieval Method', () => { it('should show retrieval setting label', () => { - // Arrange & Act render(<RuleDetail retrievalMethod={RETRIEVE_METHOD.semantic} />) - // Assert const fieldInfos = screen.getAllByTestId('field-info') expect(fieldInfos[2]).toHaveAttribute('data-label', 'datasetSettings.form.retrievalSetting.title') }) it('should show semantic search title for qualified indexing with semantic method', () => { - // Arrange & Act render( <RuleDetail indexingType={IndexingType.QUALIFIED} @@ -265,13 +215,11 @@ describe('RuleDetail', () => { />, ) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[2]).toHaveTextContent('dataset.retrieval.semantic_search.title') }) it('should show full text search title for fullText method', () => { - // Arrange & Act render( <RuleDetail indexingType={IndexingType.QUALIFIED} @@ -279,13 +227,11 @@ describe('RuleDetail', () => { />, ) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[2]).toHaveTextContent('dataset.retrieval.full_text_search.title') }) it('should show hybrid search title for hybrid method', () => { - // Arrange & Act render( <RuleDetail indexingType={IndexingType.QUALIFIED} @@ -293,13 +239,11 @@ describe('RuleDetail', () => { />, ) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[2]).toHaveTextContent('dataset.retrieval.hybrid_search.title') }) it('should force keyword_search for economical indexing type', () => { - // Arrange & Act render( <RuleDetail indexingType={IndexingType.ECONOMICAL} @@ -307,13 +251,11 @@ describe('RuleDetail', () => { />, ) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[2]).toHaveTextContent('dataset.retrieval.keyword_search.title') }) it('should show vector icon for semantic search', () => { - // Arrange & Act render( <RuleDetail indexingType={IndexingType.QUALIFIED} @@ -321,13 +263,11 @@ describe('RuleDetail', () => { />, ) - // Assert const images = screen.getAllByTestId('next-image') expect(images[1]).toHaveAttribute('src', '/icons/vector.svg') }) it('should show fullText icon for full text search', () => { - // Arrange & Act render( <RuleDetail indexingType={IndexingType.QUALIFIED} @@ -335,13 +275,11 @@ describe('RuleDetail', () => { />, ) - // Assert const images = screen.getAllByTestId('next-image') expect(images[1]).toHaveAttribute('src', '/icons/fullText.svg') }) it('should show hybrid icon for hybrid search', () => { - // Arrange & Act render( <RuleDetail indexingType={IndexingType.QUALIFIED} @@ -349,46 +287,35 @@ describe('RuleDetail', () => { />, ) - // Assert const images = screen.getAllByTestId('next-image') expect(images[1]).toHaveAttribute('src', '/icons/hybrid.svg') }) }) - // ========================================== - // Edge Cases - // ========================================== describe('Edge Cases', () => { it('should handle all props undefined', () => { - // Arrange & Act render(<RuleDetail />) - // Assert expect(screen.getAllByTestId('field-info')).toHaveLength(3) }) it('should handle undefined indexingType with defined retrievalMethod', () => { - // Arrange & Act render(<RuleDetail retrievalMethod={RETRIEVE_METHOD.hybrid} />) - // Assert const fieldValues = screen.getAllByTestId('field-value') // When indexingType is undefined, it's treated as qualified expect(fieldValues[1]).toHaveTextContent('datasetCreation.stepTwo.qualified') }) it('should handle undefined retrievalMethod with defined indexingType', () => { - // Arrange & Act render(<RuleDetail indexingType={IndexingType.QUALIFIED} />) - // Assert const images = screen.getAllByTestId('next-image') // When retrievalMethod is undefined, vector icon is used as default expect(images[1]).toHaveAttribute('src', '/icons/vector.svg') }) it('should handle sourceData with null rules', () => { - // Arrange const sourceData = { ...createMockProcessRule(), mode: ProcessMode.parentChild, @@ -401,15 +328,11 @@ describe('RuleDetail', () => { }) }) - // ========================================== // Props Variations Tests - // ========================================== describe('Props Variations', () => { it('should render correctly with all props provided', () => { - // Arrange const sourceData = createMockProcessRule({ mode: ProcessMode.general }) - // Act render( <RuleDetail sourceData={sourceData} @@ -418,7 +341,6 @@ describe('RuleDetail', () => { />, ) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[0]).toHaveTextContent('datasetDocuments.embedding.custom') expect(fieldValues[1]).toHaveTextContent('datasetCreation.stepTwo.qualified') @@ -426,10 +348,8 @@ describe('RuleDetail', () => { }) it('should render correctly for economical mode with full settings', () => { - // Arrange const sourceData = createMockProcessRule({ mode: ProcessMode.parentChild }) - // Act render( <RuleDetail sourceData={sourceData} @@ -438,7 +358,6 @@ describe('RuleDetail', () => { />, ) - // Assert const fieldValues = screen.getAllByTestId('field-value') expect(fieldValues[1]).toHaveTextContent('datasetCreation.stepTwo.economical') // Economical always uses keyword_search regardless of retrievalMethod @@ -446,9 +365,6 @@ describe('RuleDetail', () => { }) }) - // ========================================== - // Memoization Tests - // ========================================== describe('Memoization', () => { it('should be wrapped in React.memo', () => { // Assert - RuleDetail should be a memoized component @@ -456,7 +372,6 @@ describe('RuleDetail', () => { }) it('should not re-render with same props', () => { - // Arrange const sourceData = createMockProcessRule() const props = { sourceData, @@ -464,7 +379,6 @@ describe('RuleDetail', () => { retrievalMethod: RETRIEVE_METHOD.semantic, } - // Act const { rerender } = render(<RuleDetail {...props} />) rerender(<RuleDetail {...props} />) diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/preview-panel.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/create-from-pipeline/steps/preview-panel.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx index d4893c9d2d..11f1286306 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/preview-panel.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/preview-panel.spec.tsx @@ -6,7 +6,7 @@ import type { OnlineDriveFile } from '@/models/pipeline' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DatasourceType } from '@/models/pipeline' -import { StepOnePreview, StepTwoPreview } from './preview-panel' +import { StepOnePreview, StepTwoPreview } from '../preview-panel' // Mock context hooks (底层依赖) vi.mock('@/context/dataset-detail', () => ({ @@ -38,7 +38,7 @@ vi.mock('@/service/use-pipeline', () => ({ })) // Mock data source store -vi.mock('../data-source/store', () => ({ +vi.mock('../../data-source/store', () => ({ useDataSourceStore: vi.fn(() => ({ getState: () => ({ currentCredentialId: 'mock-credential-id' }), })), diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx similarity index 97% rename from web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx index 0db366221b..ff0c1b125c 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/step-one-content.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx @@ -4,7 +4,7 @@ import type { Node } from '@/app/components/workflow/types' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { DatasourceType } from '@/models/pipeline' -import StepOneContent from './step-one-content' +import StepOneContent from '../step-one-content' // Mock context providers and hooks (底层依赖) vi.mock('@/context/modal-context', () => ({ @@ -25,7 +25,7 @@ vi.mock('@/app/components/billing/upgrade-btn', () => ({ })) // Mock data source store -vi.mock('../data-source/store', () => ({ +vi.mock('../../data-source/store', () => ({ useDataSourceStore: vi.fn(() => ({ getState: () => ({ localFileList: [], @@ -57,19 +57,19 @@ vi.mock('@/service/use-common', () => ({ })) // Mock hooks used by data source options -vi.mock('../hooks', () => ({ +vi.mock('../../hooks', () => ({ useDatasourceOptions: vi.fn(() => [ { label: 'Local File', value: 'node-1', data: { type: 'data-source' } }, ]), })) // Mock useDatasourceIcon hook to avoid complex data source list transformation -vi.mock('../data-source-options/hooks', () => ({ +vi.mock('../../data-source-options/hooks', () => ({ useDatasourceIcon: vi.fn(() => '/icons/local-file.svg'), })) // Mock the entire local-file component since it has deep context dependencies -vi.mock('../data-source/local-file', () => ({ +vi.mock('../../data-source/local-file', () => ({ default: ({ allowedExtensions, supportBatchUpload }: { allowedExtensions: string[] supportBatchUpload: boolean @@ -83,7 +83,7 @@ vi.mock('../data-source/local-file', () => ({ })) // Mock online documents since it has complex OAuth/API dependencies -vi.mock('../data-source/online-documents', () => ({ +vi.mock('../../data-source/online-documents', () => ({ default: ({ nodeId, onCredentialChange }: { nodeId: string onCredentialChange: (credentialId: string) => void @@ -98,7 +98,7 @@ vi.mock('../data-source/online-documents', () => ({ })) // Mock website crawl -vi.mock('../data-source/website-crawl', () => ({ +vi.mock('../../data-source/website-crawl', () => ({ default: ({ nodeId, onCredentialChange }: { nodeId: string onCredentialChange: (credentialId: string) => void @@ -113,7 +113,7 @@ vi.mock('../data-source/website-crawl', () => ({ })) // Mock online drive -vi.mock('../data-source/online-drive', () => ({ +vi.mock('../../data-source/online-drive', () => ({ default: ({ nodeId, onCredentialChange }: { nodeId: string onCredentialChange: (credentialId: string) => void @@ -143,7 +143,6 @@ vi.mock('@/service/base', () => ({ upload: vi.fn().mockResolvedValue({ id: 'uploaded-file-id' }), })) -// Mock next/navigation vi.mock('next/navigation', () => ({ useParams: () => ({ datasetId: 'mock-dataset-id' }), useRouter: () => ({ push: vi.fn() }), diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/step-three-content.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-three-content.spec.tsx similarity index 96% rename from web/app/components/datasets/documents/create-from-pipeline/steps/step-three-content.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-three-content.spec.tsx index 9593e59c93..e217248d2b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/step-three-content.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-three-content.spec.tsx @@ -1,7 +1,7 @@ import type { InitialDocumentDetail } from '@/models/pipeline' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import StepThreeContent from './step-three-content' +import StepThreeContent from '../step-three-content' // Mock context hooks used by Processing component vi.mock('@/context/dataset-detail', () => ({ @@ -24,7 +24,7 @@ vi.mock('@/context/i18n', () => ({ })) // Mock EmbeddingProcess component as it has complex dependencies -vi.mock('../processing/embedding-process', () => ({ +vi.mock('../../processing/embedding-process', () => ({ default: ({ datasetId, batchId, documents }: { datasetId: string batchId: string diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/step-two-content.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-two-content.spec.tsx similarity index 97% rename from web/app/components/datasets/documents/create-from-pipeline/steps/step-two-content.spec.tsx rename to web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-two-content.spec.tsx index 4890f3b500..84cf96aaa9 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/step-two-content.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-two-content.spec.tsx @@ -1,10 +1,10 @@ import type { RefObject } from 'react' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import StepTwoContent from './step-two-content' +import StepTwoContent from '../step-two-content' // Mock ProcessDocuments component as it has complex hook dependencies -vi.mock('../process-documents', () => ({ +vi.mock('../../process-documents', () => ({ default: vi.fn().mockImplementation(({ dataSourceNodeId, isRunning, diff --git a/web/app/components/datasets/documents/create-from-pipeline/utils/__tests__/datasource-info-builder.spec.ts b/web/app/components/datasets/documents/create-from-pipeline/utils/__tests__/datasource-info-builder.spec.ts new file mode 100644 index 0000000000..6085dbf4b4 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/utils/__tests__/datasource-info-builder.spec.ts @@ -0,0 +1,104 @@ +import type { NotionPage } from '@/models/common' +import type { CrawlResultItem, CustomFile } from '@/models/datasets' +import type { OnlineDriveFile } from '@/models/pipeline' +import { describe, expect, it } from 'vitest' +import { OnlineDriveFileType } from '@/models/pipeline' +import { TransferMethod } from '@/types/app' +import { + buildLocalFileDatasourceInfo, + buildOnlineDocumentDatasourceInfo, + buildOnlineDriveDatasourceInfo, + buildWebsiteCrawlDatasourceInfo, +} from '../datasource-info-builder' + +describe('datasource-info-builder', () => { + describe('buildLocalFileDatasourceInfo', () => { + const file: CustomFile = { + id: 'file-1', + name: 'test.pdf', + type: 'application/pdf', + size: 1024, + extension: 'pdf', + mime_type: 'application/pdf', + } as unknown as CustomFile + + it('should build local file datasource info', () => { + const result = buildLocalFileDatasourceInfo(file, 'cred-1') + expect(result).toEqual({ + related_id: 'file-1', + name: 'test.pdf', + type: 'application/pdf', + size: 1024, + extension: 'pdf', + mime_type: 'application/pdf', + url: '', + transfer_method: TransferMethod.local_file, + credential_id: 'cred-1', + }) + }) + + it('should use empty credential when not provided', () => { + const result = buildLocalFileDatasourceInfo(file, '') + expect(result.credential_id).toBe('') + }) + }) + + describe('buildOnlineDocumentDatasourceInfo', () => { + const page = { + page_id: 'page-1', + page_name: 'My Page', + workspace_id: 'ws-1', + parent_id: 'root', + type: 'page', + } as NotionPage & { workspace_id: string } + + it('should build online document info with workspace_id separated', () => { + const result = buildOnlineDocumentDatasourceInfo(page, 'cred-2') + expect(result.workspace_id).toBe('ws-1') + expect(result.credential_id).toBe('cred-2') + expect((result.page as unknown as Record<string, unknown>).page_id).toBe('page-1') + // workspace_id should not be in the page object + expect((result.page as unknown as Record<string, unknown>).workspace_id).toBeUndefined() + }) + }) + + describe('buildWebsiteCrawlDatasourceInfo', () => { + const crawlResult: CrawlResultItem = { + source_url: 'https://example.com', + title: 'Example', + markdown: '# Hello', + description: 'desc', + } as unknown as CrawlResultItem + + it('should spread crawl result and add credential_id', () => { + const result = buildWebsiteCrawlDatasourceInfo(crawlResult, 'cred-3') + expect(result.source_url).toBe('https://example.com') + expect(result.title).toBe('Example') + expect(result.credential_id).toBe('cred-3') + }) + }) + + describe('buildOnlineDriveDatasourceInfo', () => { + const file: OnlineDriveFile = { + id: 'drive-1', + name: 'doc.xlsx', + type: OnlineDriveFileType.file, + } + + it('should build online drive info with bucket', () => { + const result = buildOnlineDriveDatasourceInfo(file, 'my-bucket', 'cred-4') + expect(result).toEqual({ + bucket: 'my-bucket', + id: 'drive-1', + name: 'doc.xlsx', + type: 'file', + credential_id: 'cred-4', + }) + }) + + it('should handle empty bucket', () => { + const result = buildOnlineDriveDatasourceInfo(file, '', 'cred-4') + expect(result.bucket).toBe('') + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/document-title.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx similarity index 87% rename from web/app/components/datasets/documents/detail/document-title.spec.tsx rename to web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx index dca2d068ec..e7945fc409 100644 --- a/web/app/components/datasets/documents/detail/document-title.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx @@ -2,9 +2,8 @@ import { render } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import { DocumentTitle } from './document-title' +import { DocumentTitle } from '../document-title' -// Mock next/navigation const mockPush = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ @@ -13,7 +12,7 @@ vi.mock('next/navigation', () => ({ })) // Mock DocumentPicker -vi.mock('../../common/document-picker', () => ({ +vi.mock('../../../common/document-picker', () => ({ default: ({ datasetId, value, onChange }: { datasetId: string, value: unknown, onChange: (doc: { id: string }) => void }) => ( <div data-testid="document-picker" @@ -31,35 +30,28 @@ describe('DocumentTitle', () => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render( <DocumentTitle datasetId="dataset-1" />, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render DocumentPicker component', () => { - // Arrange & Act const { getByTestId } = render( <DocumentTitle datasetId="dataset-1" />, ) - // Assert expect(getByTestId('document-picker')).toBeInTheDocument() }) it('should render with correct container classes', () => { - // Arrange & Act const { container } = render( <DocumentTitle datasetId="dataset-1" />, ) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('flex-1') @@ -68,20 +60,16 @@ describe('DocumentTitle', () => { }) }) - // Props tests describe('Props', () => { it('should pass datasetId to DocumentPicker', () => { - // Arrange & Act const { getByTestId } = render( <DocumentTitle datasetId="test-dataset-id" />, ) - // Assert expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('test-dataset-id') }) it('should pass value props to DocumentPicker', () => { - // Arrange & Act const { getByTestId } = render( <DocumentTitle datasetId="dataset-1" @@ -92,7 +80,6 @@ describe('DocumentTitle', () => { />, ) - // Assert const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}') expect(value.name).toBe('test-document') expect(value.extension).toBe('pdf') @@ -101,68 +88,54 @@ describe('DocumentTitle', () => { }) it('should default parentMode to paragraph when parent_mode is undefined', () => { - // Arrange & Act const { getByTestId } = render( <DocumentTitle datasetId="dataset-1" />, ) - // Assert const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}') expect(value.parentMode).toBe('paragraph') }) it('should apply custom wrapperCls', () => { - // Arrange & Act const { container } = render( <DocumentTitle datasetId="dataset-1" wrapperCls="custom-wrapper" />, ) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-wrapper') }) }) - // Navigation tests describe('Navigation', () => { it('should navigate to document page when document is selected', () => { - // Arrange const { getByTestId } = render( <DocumentTitle datasetId="dataset-1" />, ) - // Act getByTestId('document-picker').click() - // Assert expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/new-doc-id') }) }) - // Edge cases describe('Edge Cases', () => { it('should handle undefined optional props', () => { - // Arrange & Act const { getByTestId } = render( <DocumentTitle datasetId="dataset-1" />, ) - // Assert const value = JSON.parse(getByTestId('document-picker').getAttribute('data-value') || '{}') expect(value.name).toBeUndefined() expect(value.extension).toBeUndefined() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender, getByTestId } = render( <DocumentTitle datasetId="dataset-1" name="doc1" />, ) - // Act rerender(<DocumentTitle datasetId="dataset-2" name="doc2" />) - // Assert expect(getByTestId('document-picker').getAttribute('data-dataset-id')).toBe('dataset-2') }) }) diff --git a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx new file mode 100644 index 0000000000..ad8741a8e1 --- /dev/null +++ b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx @@ -0,0 +1,454 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// --- All hoisted mock fns and state (accessible inside vi.mock factories) --- +const mocks = vi.hoisted(() => { + const state = { + dataset: { embedding_available: true } as Record<string, unknown> | null, + documentDetail: null as Record<string, unknown> | null, + documentError: null as Error | null, + documentMetadata: null as Record<string, unknown> | null, + media: 'desktop' as string, + } + return { + state, + push: vi.fn(), + detailRefetch: vi.fn(), + checkProgress: vi.fn(), + batchImport: vi.fn(), + invalidDocumentList: vi.fn(), + invalidSegmentList: vi.fn(), + invalidChildSegmentList: vi.fn(), + toastNotify: vi.fn(), + } +}) + +// --- External mocks --- +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), +})) + +vi.mock('@/hooks/use-breakpoints', () => ({ + default: () => mocks.state.media, + MediaType: { mobile: 'mobile', tablet: 'tablet', pc: 'desktop' }, +})) + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (s: Record<string, unknown>) => unknown) => + selector({ dataset: mocks.state.dataset }), +})) + +vi.mock('@/service/knowledge/use-document', () => ({ + useDocumentDetail: () => ({ + data: mocks.state.documentDetail, + error: mocks.state.documentError, + refetch: mocks.detailRefetch, + }), + useDocumentMetadata: () => ({ + data: mocks.state.documentMetadata, + }), + useInvalidDocumentList: () => mocks.invalidDocumentList, +})) + +vi.mock('@/service/knowledge/use-segment', () => ({ + useCheckSegmentBatchImportProgress: () => ({ + mutateAsync: mocks.checkProgress, + }), + useSegmentBatchImport: () => ({ + mutateAsync: mocks.batchImport, + }), + useSegmentListKey: ['segment-list'], + useChildSegmentListKey: ['child-segment-list'], +})) + +vi.mock('@/service/use-base', () => ({ + useInvalid: (key: unknown) => { + const keyStr = JSON.stringify(key) + if (keyStr === JSON.stringify(['segment-list'])) + return mocks.invalidSegmentList + if (keyStr === JSON.stringify(['child-segment-list'])) + return mocks.invalidChildSegmentList + return vi.fn() + }, +})) + +vi.mock('@/app/components/base/toast', () => ({ + default: { notify: mocks.toastNotify }, +})) + +// --- Child component mocks --- +vi.mock('../completed', () => ({ + default: ({ embeddingAvailable, showNewSegmentModal, archived }: { embeddingAvailable?: boolean, showNewSegmentModal?: () => void, archived?: boolean }) => ( + <div + data-testid="completed" + data-embedding-available={embeddingAvailable} + data-show-new-segment={showNewSegmentModal} + data-archived={archived} + > + Completed + </div> + ), +})) + +vi.mock('../embedding', () => ({ + default: ({ detailUpdate }: { detailUpdate?: () => void }) => ( + <div data-testid="embedding"> + <button data-testid="embedding-refresh" onClick={detailUpdate}>Refresh</button> + </div> + ), +})) + +vi.mock('../batch-modal', () => ({ + default: ({ isShow, onCancel, onConfirm }: { isShow?: boolean, onCancel?: () => void, onConfirm?: (val: Record<string, unknown>) => void }) => ( + isShow + ? ( + <div data-testid="batch-modal"> + <button data-testid="batch-cancel" onClick={onCancel}>Cancel</button> + <button data-testid="batch-confirm" onClick={() => onConfirm?.({ file: { id: 'file-1' } })}>Confirm</button> + </div> + ) + : null + ), +})) + +vi.mock('../document-title', () => ({ + DocumentTitle: ({ name, extension }: { name?: string, extension?: string }) => ( + <div data-testid="document-title" data-extension={extension}>{name}</div> + ), +})) + +vi.mock('../segment-add', () => ({ + default: ({ showNewSegmentModal, showBatchModal, embedding }: { showNewSegmentModal?: () => void, showBatchModal?: () => void, embedding?: boolean }) => ( + <div data-testid="segment-add" data-embedding={embedding}> + <button data-testid="new-segment-btn" onClick={showNewSegmentModal}>New Segment</button> + <button data-testid="batch-btn" onClick={showBatchModal}>Batch Import</button> + </div> + ), + ProcessStatus: { + WAITING: 'waiting', + PROCESSING: 'processing', + ERROR: 'error', + COMPLETED: 'completed', + }, +})) + +vi.mock('../../components/operations', () => ({ + default: ({ onUpdate, scene }: { onUpdate?: (action?: string) => void, scene?: string }) => ( + <div data-testid="operations" data-scene={scene}> + <button data-testid="op-rename" onClick={() => onUpdate?.('rename')}>Rename</button> + <button data-testid="op-delete" onClick={() => onUpdate?.('delete')}>Delete</button> + <button data-testid="op-noop" onClick={() => onUpdate?.()}>NoOp</button> + </div> + ), +})) + +vi.mock('../../status-item', () => ({ + default: ({ status, scene }: { status?: string, scene?: string }) => ( + <div data-testid="status-item" data-scene={scene}>{status}</div> + ), +})) + +vi.mock('@/app/components/datasets/metadata/metadata-document', () => ({ + default: ({ datasetId, documentId }: { datasetId?: string, documentId?: string }) => ( + <div data-testid="metadata" data-dataset-id={datasetId} data-document-id={documentId}>Metadata</div> + ), +})) + +vi.mock('@/app/components/base/float-right-container', () => ({ + default: ({ children, isOpen, onClose }: { children?: React.ReactNode, isOpen?: boolean, onClose?: () => void }) => + isOpen + ? ( + <div data-testid="float-right-container"> + <button data-testid="close-metadata" onClick={onClose}>Close</button> + {children} + </div> + ) + : null, +})) + +// --- Lazy import (after all vi.mock calls) --- +const { default: DocumentDetail } = await import('../index') + +// --- Factory --- +const createDocumentDetail = (overrides?: Record<string, unknown>) => ({ + name: 'test-doc.txt', + display_status: 'available', + enabled: true, + archived: false, + doc_form: 'text_model', + data_source_type: 'upload_file', + data_source_info: { upload_file: { extension: '.txt' } }, + error: '', + document_process_rule: null, + dataset_process_rule: null, + ...overrides, +}) + +describe('DocumentDetail', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + mocks.state.dataset = { embedding_available: true } + mocks.state.documentDetail = createDocumentDetail() + mocks.state.documentError = null + mocks.state.documentMetadata = null + mocks.state.media = 'desktop' + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('Loading State', () => { + it('should show loading when no data and no error', () => { + mocks.state.documentDetail = null + mocks.state.documentError = null + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.queryByTestId('completed')).not.toBeInTheDocument() + expect(screen.queryByTestId('embedding')).not.toBeInTheDocument() + }) + + it('should not show loading when error exists', () => { + mocks.state.documentDetail = null + mocks.state.documentError = new Error('Not found') + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('completed')).toBeInTheDocument() + }) + }) + + describe('Content Rendering', () => { + it('should render Completed when status is available', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('completed')).toBeInTheDocument() + expect(screen.queryByTestId('embedding')).not.toBeInTheDocument() + }) + + it.each(['queuing', 'indexing', 'paused'])('should render Embedding when status is %s', (status) => { + mocks.state.documentDetail = createDocumentDetail({ display_status: status }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('embedding')).toBeInTheDocument() + expect(screen.queryByTestId('completed')).not.toBeInTheDocument() + }) + + it('should render DocumentTitle with name and extension', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + const title = screen.getByTestId('document-title') + expect(title).toHaveTextContent('test-doc.txt') + expect(title).toHaveAttribute('data-extension', '.txt') + }) + + it('should render StatusItem with correct status and scene', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + const statusItem = screen.getByTestId('status-item') + expect(statusItem).toHaveTextContent('available') + expect(statusItem).toHaveAttribute('data-scene', 'detail') + }) + + it('should render Operations with scene=detail', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('operations')).toHaveAttribute('data-scene', 'detail') + }) + }) + + describe('SegmentAdd Visibility', () => { + it('should show SegmentAdd when all conditions met', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('segment-add')).toBeInTheDocument() + }) + + it('should hide SegmentAdd when embedding is not available', () => { + mocks.state.dataset = { embedding_available: false } + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.queryByTestId('segment-add')).not.toBeInTheDocument() + }) + + it('should hide SegmentAdd when document is archived', () => { + mocks.state.documentDetail = createDocumentDetail({ archived: true }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.queryByTestId('segment-add')).not.toBeInTheDocument() + }) + + it('should hide SegmentAdd in full-doc parent-child mode', () => { + mocks.state.documentDetail = createDocumentDetail({ + doc_form: 'hierarchical_model', + document_process_rule: { rules: { parent_mode: 'full-doc' } }, + }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.queryByTestId('segment-add')).not.toBeInTheDocument() + }) + }) + + describe('Metadata Panel', () => { + it('should show metadata panel by default on desktop', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('float-right-container')).toBeInTheDocument() + expect(screen.getByTestId('metadata')).toBeInTheDocument() + }) + + it('should toggle metadata panel when button clicked', () => { + const { container } = render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('metadata')).toBeInTheDocument() + + const svgs = container.querySelectorAll('svg') + const toggleBtn = svgs[svgs.length - 1].closest('button')! + fireEvent.click(toggleBtn) + expect(screen.queryByTestId('metadata')).not.toBeInTheDocument() + }) + + it('should pass correct props to Metadata', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + const metadata = screen.getByTestId('metadata') + expect(metadata).toHaveAttribute('data-dataset-id', 'ds-1') + expect(metadata).toHaveAttribute('data-document-id', 'doc-1') + }) + }) + + describe('Navigation', () => { + it('should navigate back when back button clicked', () => { + const { container } = render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + const backBtn = container.querySelector('svg')!.parentElement! + fireEvent.click(backBtn) + expect(mocks.push).toHaveBeenCalledWith('/datasets/ds-1/documents') + }) + + it('should preserve query params when navigating back', () => { + const origLocation = window.location + window.history.pushState({}, '', '?page=2&status=active') + const { container } = render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + const backBtn = container.querySelector('svg')!.parentElement! + fireEvent.click(backBtn) + expect(mocks.push).toHaveBeenCalledWith('/datasets/ds-1/documents?page=2&status=active') + window.history.pushState({}, '', origLocation.href) + }) + }) + + describe('handleOperate', () => { + it('should invalidate document list on any operation', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + fireEvent.click(screen.getByTestId('op-rename')) + expect(mocks.invalidDocumentList).toHaveBeenCalled() + }) + + it('should navigate back on delete operation', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + fireEvent.click(screen.getByTestId('op-delete')) + expect(mocks.invalidDocumentList).toHaveBeenCalled() + expect(mocks.push).toHaveBeenCalled() + }) + + it('should refresh detail on non-delete operation', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + fireEvent.click(screen.getByTestId('op-rename')) + expect(mocks.detailRefetch).toHaveBeenCalled() + }) + + it('should invalidate chunk lists after 5s on named non-delete operation', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + fireEvent.click(screen.getByTestId('op-rename')) + + expect(mocks.invalidSegmentList).not.toHaveBeenCalled() + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(mocks.invalidSegmentList).toHaveBeenCalled() + expect(mocks.invalidChildSegmentList).toHaveBeenCalled() + }) + + it('should not invalidate chunk lists on operation with no name', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + fireEvent.click(screen.getByTestId('op-noop')) + + expect(mocks.detailRefetch).toHaveBeenCalled() + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(mocks.invalidSegmentList).not.toHaveBeenCalled() + }) + }) + + describe('Batch Import', () => { + it('should open batch modal when batch button clicked', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.queryByTestId('batch-modal')).not.toBeInTheDocument() + fireEvent.click(screen.getByTestId('batch-btn')) + expect(screen.getByTestId('batch-modal')).toBeInTheDocument() + }) + + it('should close batch modal when cancel clicked', () => { + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + fireEvent.click(screen.getByTestId('batch-btn')) + expect(screen.getByTestId('batch-modal')).toBeInTheDocument() + fireEvent.click(screen.getByTestId('batch-cancel')) + expect(screen.queryByTestId('batch-modal')).not.toBeInTheDocument() + }) + + it('should call segmentBatchImport on confirm', async () => { + mocks.batchImport.mockResolvedValue({ job_id: 'job-1', job_status: 'waiting' }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + fireEvent.click(screen.getByTestId('batch-btn')) + + await act(async () => { + fireEvent.click(screen.getByTestId('batch-confirm')) + }) + + expect(mocks.batchImport).toHaveBeenCalledWith( + { + url: '/datasets/ds-1/documents/doc-1/segments/batch_import', + body: { upload_file_id: 'file-1' }, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ) + }) + }) + + describe('isFullDocMode', () => { + it('should detect full-doc mode from document_process_rule', () => { + mocks.state.documentDetail = createDocumentDetail({ + doc_form: 'hierarchical_model', + document_process_rule: { rules: { parent_mode: 'full-doc' } }, + }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.queryByTestId('segment-add')).not.toBeInTheDocument() + }) + + it('should detect full-doc mode from dataset_process_rule as fallback', () => { + mocks.state.documentDetail = createDocumentDetail({ + doc_form: 'hierarchical_model', + document_process_rule: null, + dataset_process_rule: { rules: { parent_mode: 'full-doc' } }, + }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.queryByTestId('segment-add')).not.toBeInTheDocument() + }) + + it('should not be full-doc when parentMode is paragraph', () => { + mocks.state.documentDetail = createDocumentDetail({ + doc_form: 'hierarchical_model', + document_process_rule: { rules: { parent_mode: 'paragraph' } }, + }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('segment-add')).toBeInTheDocument() + }) + }) + + describe('Legacy DataSourceInfo', () => { + it('should extract extension from legacy data_source_info', () => { + mocks.state.documentDetail = createDocumentDetail({ + data_source_info: { upload_file: { extension: '.pdf' } }, + }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('document-title')).toHaveAttribute('data-extension', '.pdf') + }) + + it('should handle non-legacy data_source_info gracefully', () => { + mocks.state.documentDetail = createDocumentDetail({ + data_source_info: { url: 'https://example.com' }, + }) + render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />) + expect(screen.getByTestId('document-title')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/new-segment.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx similarity index 61% rename from web/app/components/datasets/documents/detail/new-segment.spec.tsx rename to web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx index 7fc94ab80f..73082108a0 100644 --- a/web/app/components/datasets/documents/detail/new-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx @@ -1,11 +1,11 @@ +import type * as React from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import { IndexingType } from '../../create/step-two' +import { IndexingType } from '../../../create/step-two' -import NewSegmentModal from './new-segment' +import NewSegmentModal from '../new-segment' -// Mock next/navigation vi.mock('next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id', @@ -13,7 +13,6 @@ vi.mock('next/navigation', () => ({ }), })) -// Mock ToastContext const mockNotify = vi.fn() vi.mock('use-context-selector', async (importOriginal) => { const actual = await importOriginal() as Record<string, unknown> @@ -34,7 +33,7 @@ vi.mock('@/context/dataset-detail', () => ({ // Mock segment list context let mockFullScreen = false const mockToggleFullScreen = vi.fn() -vi.mock('./completed', () => ({ +vi.mock('../completed', () => ({ useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => { const state = { fullScreen: mockFullScreen, @@ -57,8 +56,7 @@ vi.mock('@/app/components/app/store', () => ({ useStore: () => ({ appSidebarExpand: 'expand' }), })) -// Mock child components -vi.mock('./completed/common/action-buttons', () => ({ +vi.mock('../completed/common/action-buttons', () => ({ default: ({ handleCancel, handleSave, loading, actionType }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string }) => ( <div data-testid="action-buttons"> <button onClick={handleCancel} data-testid="cancel-btn">Cancel</button> @@ -70,7 +68,7 @@ vi.mock('./completed/common/action-buttons', () => ({ ), })) -vi.mock('./completed/common/add-another', () => ({ +vi.mock('../completed/common/add-another', () => ({ default: ({ isChecked, onCheck, className }: { isChecked: boolean, onCheck: () => void, className?: string }) => ( <div data-testid="add-another" className={className}> <input @@ -83,7 +81,7 @@ vi.mock('./completed/common/add-another', () => ({ ), })) -vi.mock('./completed/common/chunk-content', () => ({ +vi.mock('../completed/common/chunk-content', () => ({ default: ({ docForm, question, answer, onQuestionChange, onAnswerChange, isEditMode }: { docForm: string, question: string, answer: string, onQuestionChange: (v: string) => void, onAnswerChange: (v: string) => void, isEditMode: boolean }) => ( <div data-testid="chunk-content"> <input @@ -105,11 +103,11 @@ vi.mock('./completed/common/chunk-content', () => ({ ), })) -vi.mock('./completed/common/dot', () => ({ +vi.mock('../completed/common/dot', () => ({ default: () => <span data-testid="dot">•</span>, })) -vi.mock('./completed/common/keywords', () => ({ +vi.mock('../completed/common/keywords', () => ({ default: ({ keywords, onKeywordsChange, _isEditMode, _actionType }: { keywords: string[], onKeywordsChange: (v: string[]) => void, _isEditMode?: boolean, _actionType?: string }) => ( <div data-testid="keywords"> <input @@ -121,7 +119,7 @@ vi.mock('./completed/common/keywords', () => ({ ), })) -vi.mock('./completed/common/segment-index-tag', () => ({ +vi.mock('../completed/common/segment-index-tag', () => ({ SegmentIndexTag: ({ label }: { label: string }) => <span data-testid="segment-index-tag">{label}</span>, })) @@ -152,53 +150,40 @@ describe('NewSegmentModal', () => { viewNewlyAddedChunk: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<NewSegmentModal {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render title text', () => { - // Arrange & Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByText(/segment\.addChunk/i)).toBeInTheDocument() }) it('should render chunk content component', () => { - // Arrange & Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('chunk-content')).toBeInTheDocument() }) it('should render image uploader', () => { - // Arrange & Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('image-uploader')).toBeInTheDocument() }) it('should render segment index tag', () => { - // Arrange & Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument() }) it('should render dot separator', () => { - // Arrange & Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('dot')).toBeInTheDocument() }) }) @@ -206,32 +191,24 @@ describe('NewSegmentModal', () => { // Keywords display describe('Keywords', () => { it('should show keywords component when indexing is ECONOMICAL', () => { - // Arrange mockIndexingTechnique = IndexingType.ECONOMICAL - // Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('keywords')).toBeInTheDocument() }) it('should not show keywords when indexing is QUALIFIED', () => { - // Arrange mockIndexingTechnique = IndexingType.QUALIFIED - // Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.queryByTestId('keywords')).not.toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() const { container } = render(<NewSegmentModal {...defaultProps} onCancel={mockOnCancel} />) @@ -241,40 +218,31 @@ describe('NewSegmentModal', () => { if (closeButtons.length > 1) fireEvent.click(closeButtons[1]) - // Assert expect(mockOnCancel).toHaveBeenCalled() }) it('should update question when typing', () => { - // Arrange render(<NewSegmentModal {...defaultProps} />) const questionInput = screen.getByTestId('question-input') - // Act fireEvent.change(questionInput, { target: { value: 'New question content' } }) - // Assert expect(questionInput).toHaveValue('New question content') }) it('should update answer when docForm is QA and typing', () => { - // Arrange render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />) const answerInput = screen.getByTestId('answer-input') - // Act fireEvent.change(answerInput, { target: { value: 'New answer content' } }) - // Assert expect(answerInput).toHaveValue('New answer content') }) it('should toggle add another checkbox', () => { - // Arrange render(<NewSegmentModal {...defaultProps} />) const checkbox = screen.getByTestId('add-another-checkbox') - // Act fireEvent.click(checkbox) // Assert - checkbox state should toggle @@ -285,13 +253,10 @@ describe('NewSegmentModal', () => { // Save validation describe('Save Validation', () => { it('should show error when content is empty for text mode', async () => { - // Arrange render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -302,13 +267,10 @@ describe('NewSegmentModal', () => { }) it('should show error when question is empty for QA mode', async () => { - // Arrange render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -319,14 +281,11 @@ describe('NewSegmentModal', () => { }) it('should show error when answer is empty for QA mode', async () => { - // Arrange render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />) fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Question' } }) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -340,7 +299,6 @@ describe('NewSegmentModal', () => { // Successful save describe('Successful Save', () => { it('should call addSegment when valid content is provided for text mode', async () => { - // Arrange mockAddSegment.mockImplementation((_params, options) => { options.onSuccess() options.onSettled() @@ -350,10 +308,8 @@ describe('NewSegmentModal', () => { render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />) fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Valid content' } }) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockAddSegment).toHaveBeenCalledWith( expect.objectContaining({ @@ -369,7 +325,6 @@ describe('NewSegmentModal', () => { }) it('should show success notification after save', async () => { - // Arrange mockAddSegment.mockImplementation((_params, options) => { options.onSuccess() options.onSettled() @@ -379,10 +334,8 @@ describe('NewSegmentModal', () => { render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />) fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Valid content' } }) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -396,41 +349,31 @@ describe('NewSegmentModal', () => { // Full screen mode describe('Full Screen Mode', () => { it('should apply full screen styling when fullScreen is true', () => { - // Arrange mockFullScreen = true - // Act const { container } = render(<NewSegmentModal {...defaultProps} />) - // Assert const header = container.querySelector('.border-divider-subtle') expect(header).toBeInTheDocument() }) it('should show action buttons in header when fullScreen', () => { - // Arrange mockFullScreen = true - // Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('action-buttons')).toBeInTheDocument() }) it('should show add another in header when fullScreen', () => { - // Arrange mockFullScreen = true - // Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('add-another')).toBeInTheDocument() }) it('should call toggleFullScreen when expand button is clicked', () => { - // Arrange const { container } = render(<NewSegmentModal {...defaultProps} />) // Act - click the expand button (first cursor-pointer) @@ -438,7 +381,6 @@ describe('NewSegmentModal', () => { if (expandButtons.length > 0) fireEvent.click(expandButtons[0]) - // Assert expect(mockToggleFullScreen).toHaveBeenCalled() }) }) @@ -446,43 +388,33 @@ describe('NewSegmentModal', () => { // Props describe('Props', () => { it('should pass actionType add to ActionButtons', () => { - // Arrange & Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('action-type')).toHaveTextContent('add') }) it('should pass isEditMode true to ChunkContent', () => { - // Arrange & Act render(<NewSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing') }) }) - // Edge cases describe('Edge Cases', () => { it('should handle keyword changes for ECONOMICAL indexing', () => { - // Arrange mockIndexingTechnique = IndexingType.ECONOMICAL render(<NewSegmentModal {...defaultProps} />) - // Act fireEvent.change(screen.getByTestId('keywords-input'), { target: { value: 'keyword1,keyword2' }, }) - // Assert expect(screen.getByTestId('keywords-input')).toHaveValue('keyword1,keyword2') }) it('should handle image upload', () => { - // Arrange render(<NewSegmentModal {...defaultProps} />) - // Act fireEvent.click(screen.getByTestId('upload-image-btn')) // Assert - image uploader should be rendered @@ -490,14 +422,230 @@ describe('NewSegmentModal', () => { }) it('should maintain structure when rerendered with different docForm', () => { - // Arrange const { rerender } = render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />) - // Act rerender(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />) - // Assert expect(screen.getByTestId('answer-input')).toBeInTheDocument() }) }) + + describe('CustomButton in success notification', () => { + it('should call viewNewlyAddedChunk when custom button is clicked', async () => { + const mockViewNewlyAddedChunk = vi.fn() + mockNotify.mockImplementation(() => {}) + + mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }) + + render( + <NewSegmentModal + {...defaultProps} + docForm={ChunkingMode.text} + viewNewlyAddedChunk={mockViewNewlyAddedChunk} + />, + ) + + // Enter content and save + fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Test content' } }) + fireEvent.click(screen.getByTestId('save-btn')) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + customComponent: expect.anything(), + }), + ) + }) + + // Extract customComponent from the notify call args + const notifyCallArgs = mockNotify.mock.calls[0][0] as { customComponent?: React.ReactElement } + expect(notifyCallArgs.customComponent).toBeDefined() + const customComponent = notifyCallArgs.customComponent! + const { container: btnContainer } = render(customComponent) + const viewButton = btnContainer.querySelector('.system-xs-semibold.text-text-accent') as HTMLElement + expect(viewButton).toBeInTheDocument() + fireEvent.click(viewButton) + + // Assert that viewNewlyAddedChunk was called via the onClick handler (lines 66-67) + expect(mockViewNewlyAddedChunk).toHaveBeenCalled() + }) + }) + + describe('QA mode save with content', () => { + it('should save with both question and answer in QA mode', async () => { + mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }) + + render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />) + + // Enter question and answer + fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'My Question' } }) + fireEvent.change(screen.getByTestId('answer-input'), { target: { value: 'My Answer' } }) + + // Act - save + fireEvent.click(screen.getByTestId('save-btn')) + + // Assert - should call addSegment with both content and answer + await waitFor(() => { + expect(mockAddSegment).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + content: 'My Question', + answer: 'My Answer', + }), + }), + expect.any(Object), + ) + }) + }) + }) + + describe('Keywords in save params', () => { + it('should include keywords in save params when keywords are provided', async () => { + mockIndexingTechnique = IndexingType.ECONOMICAL + mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }) + + render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />) + + // Enter content + fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Content with keywords' } }) + // Enter keywords + fireEvent.change(screen.getByTestId('keywords-input'), { target: { value: 'kw1,kw2' } }) + + // Act - save + fireEvent.click(screen.getByTestId('save-btn')) + + await waitFor(() => { + expect(mockAddSegment).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + content: 'Content with keywords', + keywords: ['kw1', 'kw2'], + }), + }), + expect.any(Object), + ) + }) + }) + }) + + describe('Save with attachments', () => { + it('should include attachment_ids in save params when images are uploaded', async () => { + mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }) + + render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.text} />) + + // Enter content + fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Content with images' } }) + // Upload an image + fireEvent.click(screen.getByTestId('upload-image-btn')) + + // Act - save + fireEvent.click(screen.getByTestId('save-btn')) + + await waitFor(() => { + expect(mockAddSegment).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + content: 'Content with images', + attachment_ids: ['img-1'], + }), + }), + expect.any(Object), + ) + }) + }) + }) + + describe('handleCancel with addAnother unchecked', () => { + it('should call onCancel when addAnother is unchecked and save succeeds', async () => { + const mockOnCancel = vi.fn() + mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }) + + render(<NewSegmentModal {...defaultProps} onCancel={mockOnCancel} docForm={ChunkingMode.text} />) + + // Uncheck "add another" + const checkbox = screen.getByTestId('add-another-checkbox') + fireEvent.click(checkbox) + + // Enter content and save + fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Test' } }) + fireEvent.click(screen.getByTestId('save-btn')) + + // Assert - should call onCancel since addAnother is false + await waitFor(() => { + expect(mockOnCancel).toHaveBeenCalled() + }) + }) + }) + + describe('onSave delayed call', () => { + it('should call onSave after timeout in success handler', async () => { + vi.useFakeTimers() + const mockOnSave = vi.fn() + mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { + options.onSuccess() + options.onSettled() + return Promise.resolve() + }) + + render(<NewSegmentModal {...defaultProps} onSave={mockOnSave} docForm={ChunkingMode.text} />) + + // Enter content and save + fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Test content' } }) + fireEvent.click(screen.getByTestId('save-btn')) + + // Fast-forward timer + vi.advanceTimersByTime(3000) + + expect(mockOnSave).toHaveBeenCalled() + vi.useRealTimers() + }) + }) + + describe('Word count display', () => { + it('should display character count for QA mode (question + answer)', () => { + render(<NewSegmentModal {...defaultProps} docForm={ChunkingMode.qa} />) + + // Enter question and answer + fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'abc' } }) + fireEvent.change(screen.getByTestId('answer-input'), { target: { value: 'de' } }) + + // Assert - should show count of 5 (3 + 2) + // The component uses formatNumber and shows "X characters" + expect(screen.getByText(/5/)).toBeInTheDocument() + }) + }) + + describe('Non-fullscreen footer', () => { + it('should render footer with AddAnother and ActionButtons when not in fullScreen', () => { + mockFullScreen = false + + render(<NewSegmentModal {...defaultProps} />) + + // Assert - footer should have both AddAnother and ActionButtons + expect(screen.getByTestId('add-another')).toBeInTheDocument() + expect(screen.getByTestId('action-buttons')).toBeInTheDocument() + }) + }) }) diff --git a/web/app/components/datasets/documents/detail/batch-modal/csv-downloader.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-downloader.spec.tsx similarity index 91% rename from web/app/components/datasets/documents/detail/batch-modal/csv-downloader.spec.tsx rename to web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-downloader.spec.tsx index 3326a36aa0..52353b856a 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/csv-downloader.spec.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-downloader.spec.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { LanguagesSupported } from '@/i18n-config/language' import { ChunkingMode } from '@/models/datasets' -import CSVDownload from './csv-downloader' +import CSVDownload from '../csv-downloader' // Mock useLocale let mockLocale = LanguagesSupported[0] // en-US @@ -37,18 +37,14 @@ describe('CSVDownloader', () => { mockLocale = LanguagesSupported[0] // Reset to English }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render structure title', () => { - // Arrange & Act render(<CSVDownload docForm={ChunkingMode.text} />) // Assert - i18n key format @@ -56,10 +52,8 @@ describe('CSVDownloader', () => { }) it('should render download template link', () => { - // Arrange & Act render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert expect(screen.getByTestId('csv-downloader-link')).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.template/i)).toBeInTheDocument() }) @@ -68,7 +62,6 @@ describe('CSVDownloader', () => { // Table structure for QA mode describe('QA Mode Table', () => { it('should render QA table with question and answer columns when docForm is qa', () => { - // Arrange & Act render(<CSVDownload docForm={ChunkingMode.qa} />) // Assert - Check for question/answer headers @@ -80,10 +73,8 @@ describe('CSVDownloader', () => { }) it('should render two data rows for QA mode', () => { - // Arrange & Act const { container } = render(<CSVDownload docForm={ChunkingMode.qa} />) - // Assert const tbody = container.querySelector('tbody') expect(tbody).toBeInTheDocument() const rows = tbody?.querySelectorAll('tr') @@ -94,7 +85,6 @@ describe('CSVDownloader', () => { // Table structure for Text mode describe('Text Mode Table', () => { it('should render text table with content column when docForm is text', () => { - // Arrange & Act render(<CSVDownload docForm={ChunkingMode.text} />) // Assert - Check for content header @@ -102,19 +92,15 @@ describe('CSVDownloader', () => { }) it('should not render question/answer columns in text mode', () => { - // Arrange & Act render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert expect(screen.queryByText(/list\.batchModal\.question/i)).not.toBeInTheDocument() expect(screen.queryByText(/list\.batchModal\.answer/i)).not.toBeInTheDocument() }) it('should render two data rows for text mode', () => { - // Arrange & Act const { container } = render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert const tbody = container.querySelector('tbody') expect(tbody).toBeInTheDocument() const rows = tbody?.querySelectorAll('tr') @@ -125,13 +111,10 @@ describe('CSVDownloader', () => { // CSV Template Data describe('CSV Template Data', () => { it('should provide English QA template when locale is English and docForm is qa', () => { - // Arrange mockLocale = LanguagesSupported[0] // en-US - // Act render(<CSVDownload docForm={ChunkingMode.qa} />) - // Assert const link = screen.getByTestId('csv-downloader-link') const data = JSON.parse(link.getAttribute('data-data') || '[]') expect(data).toEqual([ @@ -142,13 +125,10 @@ describe('CSVDownloader', () => { }) it('should provide English text template when locale is English and docForm is text', () => { - // Arrange mockLocale = LanguagesSupported[0] // en-US - // Act render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert const link = screen.getByTestId('csv-downloader-link') const data = JSON.parse(link.getAttribute('data-data') || '[]') expect(data).toEqual([ @@ -159,13 +139,10 @@ describe('CSVDownloader', () => { }) it('should provide Chinese QA template when locale is Chinese and docForm is qa', () => { - // Arrange mockLocale = LanguagesSupported[1] // zh-Hans - // Act render(<CSVDownload docForm={ChunkingMode.qa} />) - // Assert const link = screen.getByTestId('csv-downloader-link') const data = JSON.parse(link.getAttribute('data-data') || '[]') expect(data).toEqual([ @@ -176,13 +153,10 @@ describe('CSVDownloader', () => { }) it('should provide Chinese text template when locale is Chinese and docForm is text', () => { - // Arrange mockLocale = LanguagesSupported[1] // zh-Hans - // Act render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert const link = screen.getByTestId('csv-downloader-link') const data = JSON.parse(link.getAttribute('data-data') || '[]') expect(data).toEqual([ @@ -196,31 +170,24 @@ describe('CSVDownloader', () => { // CSVDownloader props describe('CSVDownloader Props', () => { it('should set filename to template', () => { - // Arrange & Act render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert const link = screen.getByTestId('csv-downloader-link') expect(link.getAttribute('data-filename')).toBe('template') }) it('should set type to Link', () => { - // Arrange & Act render(<CSVDownload docForm={ChunkingMode.text} />) - // Assert const link = screen.getByTestId('csv-downloader-link') expect(link.getAttribute('data-type')).toBe('link') }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rerendered with different docForm', () => { - // Arrange const { rerender } = render(<CSVDownload docForm={ChunkingMode.text} />) - // Act rerender(<CSVDownload docForm={ChunkingMode.qa} />) // Assert - should now show QA table @@ -228,10 +195,8 @@ describe('CSVDownloader', () => { }) it('should render correctly for non-English locales', () => { - // Arrange mockLocale = LanguagesSupported[1] // zh-Hans - // Act render(<CSVDownload docForm={ChunkingMode.qa} />) // Assert - Check that Chinese template is used diff --git a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx similarity index 68% rename from web/app/components/datasets/documents/detail/batch-modal/csv-uploader.spec.tsx rename to web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx index 54001c8736..7fb1de7cf9 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.spec.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/__tests__/csv-uploader.spec.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from 'react' import type { CustomFile, FileItem } from '@/models/datasets' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Theme } from '@/types/app' -import CSVUploader from './csv-uploader' +import CSVUploader from '../csv-uploader' // Mock upload service const mockUpload = vi.fn() @@ -24,7 +24,6 @@ vi.mock('@/hooks/use-theme', () => ({ default: () => ({ theme: Theme.light }), })) -// Mock ToastContext const mockNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ ToastContext: { @@ -52,40 +51,31 @@ describe('CSVUploader', () => { updateFile: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<CSVUploader {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render upload area when no file is present', () => { - // Arrange & Act render(<CSVUploader {...defaultProps} />) - // Assert expect(screen.getByText(/list\.batchModal\.csvUploadTitle/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.browse/i)).toBeInTheDocument() }) it('should render hidden file input', () => { - // Arrange & Act const { container } = render(<CSVUploader {...defaultProps} />) - // Assert const fileInput = container.querySelector('input[type="file"]') expect(fileInput).toBeInTheDocument() expect(fileInput).toHaveStyle({ display: 'none' }) }) it('should accept only CSV files', () => { - // Arrange & Act const { container } = render(<CSVUploader {...defaultProps} />) - // Assert const fileInput = container.querySelector('input[type="file"]') expect(fileInput).toHaveAttribute('accept', '.csv') }) @@ -94,69 +84,55 @@ describe('CSVUploader', () => { // File display tests describe('File Display', () => { it('should display file info when file is present', () => { - // Arrange const mockFile: FileItem = { fileID: 'file-1', file: new File(['content'], 'test-file.csv', { type: 'text/csv' }) as CustomFile, progress: 100, } - // Act render(<CSVUploader {...defaultProps} file={mockFile} />) - // Assert expect(screen.getByText('test-file')).toBeInTheDocument() expect(screen.getByText('.csv')).toBeInTheDocument() }) it('should not show upload area when file is present', () => { - // Arrange const mockFile: FileItem = { fileID: 'file-1', file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile, progress: 100, } - // Act render(<CSVUploader {...defaultProps} file={mockFile} />) - // Assert expect(screen.queryByText(/list\.batchModal\.csvUploadTitle/i)).not.toBeInTheDocument() }) it('should show change button when file is present', () => { - // Arrange const mockFile: FileItem = { fileID: 'file-1', file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile, progress: 100, } - // Act render(<CSVUploader {...defaultProps} file={mockFile} />) - // Assert expect(screen.getByText(/stepOne\.uploader\.change/i)).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should trigger file input click when browse is clicked', () => { - // Arrange const { container } = render(<CSVUploader {...defaultProps} />) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const clickSpy = vi.spyOn(fileInput, 'click') - // Act fireEvent.click(screen.getByText(/list\.batchModal\.browse/i)) - // Assert expect(clickSpy).toHaveBeenCalled() }) it('should call updateFile when file is selected', async () => { - // Arrange const mockUpdateFile = vi.fn() mockUpload.mockResolvedValueOnce({ id: 'uploaded-id' }) @@ -166,17 +142,14 @@ describe('CSVUploader', () => { const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.csv', { type: 'text/csv' }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) - // Assert await waitFor(() => { expect(mockUpdateFile).toHaveBeenCalled() }) }) it('should call updateFile with undefined when remove is clicked', () => { - // Arrange const mockUpdateFile = vi.fn() const mockFile: FileItem = { fileID: 'file-1', @@ -187,28 +160,22 @@ describe('CSVUploader', () => { <CSVUploader {...defaultProps} file={mockFile} updateFile={mockUpdateFile} />, ) - // Act const deleteButton = container.querySelector('.cursor-pointer') if (deleteButton) fireEvent.click(deleteButton) - // Assert expect(mockUpdateFile).toHaveBeenCalledWith() }) }) - // Validation tests describe('Validation', () => { it('should show error for non-CSV files', () => { - // Arrange const { container } = render(<CSVUploader {...defaultProps} />) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.txt', { type: 'text/plain' }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) - // Assert expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', @@ -217,7 +184,6 @@ describe('CSVUploader', () => { }) it('should show error for files exceeding size limit', () => { - // Arrange const { container } = render(<CSVUploader {...defaultProps} />) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement @@ -225,10 +191,8 @@ describe('CSVUploader', () => { const testFile = new File(['test'], 'large.csv', { type: 'text/csv' }) Object.defineProperty(testFile, 'size', { value: 16 * 1024 * 1024 }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) - // Assert expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', @@ -240,14 +204,12 @@ describe('CSVUploader', () => { // Upload progress tests describe('Upload Progress', () => { it('should show progress indicator when upload is in progress', () => { - // Arrange const mockFile: FileItem = { fileID: 'file-1', file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile, progress: 50, } - // Act const { container } = render(<CSVUploader {...defaultProps} file={mockFile} />) // Assert - SimplePieChart should be rendered for progress 0-99 @@ -256,14 +218,12 @@ describe('CSVUploader', () => { }) it('should not show progress for completed uploads', () => { - // Arrange const mockFile: FileItem = { fileID: 'file-1', file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile, progress: 100, } - // Act render(<CSVUploader {...defaultProps} file={mockFile} />) // Assert - File name should be displayed @@ -271,10 +231,8 @@ describe('CSVUploader', () => { }) }) - // Props tests describe('Props', () => { it('should call updateFile prop when provided', async () => { - // Arrange const mockUpdateFile = vi.fn() mockUpload.mockResolvedValueOnce({ id: 'test-id' }) @@ -284,53 +242,42 @@ describe('CSVUploader', () => { const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.csv', { type: 'text/csv' }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) - // Assert await waitFor(() => { expect(mockUpdateFile).toHaveBeenCalled() }) }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty file list', () => { - // Arrange const mockUpdateFile = vi.fn() const { container } = render( <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, ) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement - // Act fireEvent.change(fileInput, { target: { files: [] } }) - // Assert expect(mockUpdateFile).not.toHaveBeenCalled() }) it('should handle null file', () => { - // Arrange const mockUpdateFile = vi.fn() const { container } = render( <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, ) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement - // Act fireEvent.change(fileInput, { target: { files: null } }) - // Assert expect(mockUpdateFile).not.toHaveBeenCalled() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<CSVUploader {...defaultProps} />) - // Act const mockFile: FileItem = { fileID: 'file-1', file: new File(['content'], 'updated.csv', { type: 'text/csv' }) as CustomFile, @@ -338,12 +285,10 @@ describe('CSVUploader', () => { } rerender(<CSVUploader {...defaultProps} file={mockFile} />) - // Assert expect(screen.getByText('updated')).toBeInTheDocument() }) it('should handle upload error', async () => { - // Arrange const mockUpdateFile = vi.fn() mockUpload.mockRejectedValueOnce(new Error('Upload failed')) @@ -353,10 +298,8 @@ describe('CSVUploader', () => { const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.csv', { type: 'text/csv' }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -367,15 +310,12 @@ describe('CSVUploader', () => { }) it('should handle file without extension', () => { - // Arrange const { container } = render(<CSVUploader {...defaultProps} />) const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'noextension', { type: 'text/plain' }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) - // Assert expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', @@ -389,7 +329,6 @@ describe('CSVUploader', () => { // Testing these requires triggering native DOM events on the actual dropRef element. describe('Drag and Drop', () => { it('should render drop zone element', () => { - // Arrange & Act const { container } = render(<CSVUploader {...defaultProps} />) // Assert - drop zone should exist for drag and drop @@ -398,7 +337,6 @@ describe('CSVUploader', () => { }) it('should have drag overlay element that can appear during drag', () => { - // Arrange & Act const { container } = render(<CSVUploader {...defaultProps} />) // Assert - component structure supports dragging @@ -409,7 +347,6 @@ describe('CSVUploader', () => { // Upload progress callback tests describe('Upload Progress Callbacks', () => { it('should update progress during file upload', async () => { - // Arrange const mockUpdateFile = vi.fn() let progressCallback: ((e: ProgressEvent) => void) | undefined @@ -424,7 +361,6 @@ describe('CSVUploader', () => { const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.csv', { type: 'text/csv' }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) // Simulate progress event @@ -437,7 +373,6 @@ describe('CSVUploader', () => { progressCallback(progressEvent) } - // Assert await waitFor(() => { expect(mockUpdateFile).toHaveBeenCalledWith( expect.objectContaining({ @@ -448,7 +383,6 @@ describe('CSVUploader', () => { }) it('should handle progress event with lengthComputable false', async () => { - // Arrange const mockUpdateFile = vi.fn() let progressCallback: ((e: ProgressEvent) => void) | undefined @@ -463,7 +397,6 @@ describe('CSVUploader', () => { const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement const testFile = new File(['content'], 'test.csv', { type: 'text/csv' }) - // Act fireEvent.change(fileInput, { target: { files: [testFile] } }) // Simulate progress event with lengthComputable false @@ -482,4 +415,174 @@ describe('CSVUploader', () => { }) }) }) + + describe('Drag and Drop Events', () => { + // Helper to get the dropRef element (sibling of the hidden file input) + const getDropZone = (container: HTMLElement) => { + const fileInput = container.querySelector('input[type="file"]') + return fileInput?.nextElementSibling as HTMLElement + } + + it('should handle dragenter event and set dragging state', async () => { + const { container } = render(<CSVUploader {...defaultProps} />) + const dropZone = getDropZone(container) + + // Act - dispatch dragenter event wrapped in act to avoid state update warning + const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true }) + Object.defineProperty(dragEnterEvent, 'target', { value: dropZone }) + act(() => { + dropZone.dispatchEvent(dragEnterEvent) + }) + + // Assert - dragging class should be applied (border style changes) + await waitFor(() => { + const uploadArea = container.querySelector('.border-dashed') + expect(uploadArea || dropZone).toBeInTheDocument() + }) + }) + + it('should handle dragover event', () => { + const { container } = render(<CSVUploader {...defaultProps} />) + const dropZone = getDropZone(container) + + const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true }) + dropZone.dispatchEvent(dragOverEvent) + + // Assert - no error thrown + expect(dropZone).toBeInTheDocument() + }) + + it('should handle dragleave event', () => { + const { container } = render(<CSVUploader {...defaultProps} />) + const dropZone = getDropZone(container) + + // First set dragging to true + const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true }) + act(() => { + dropZone.dispatchEvent(dragEnterEvent) + }) + + // Act - dispatch dragleave + const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true }) + act(() => { + dropZone.dispatchEvent(dragLeaveEvent) + }) + + expect(dropZone).toBeInTheDocument() + }) + + it('should set dragging to false when dragleave target is the drag overlay', async () => { + const { container } = render(<CSVUploader {...defaultProps} />) + const dropZone = getDropZone(container) + + // Trigger dragenter to set dragging=true, which renders the overlay + const dragEnterEvent = new Event('dragenter', { bubbles: true, cancelable: true }) + act(() => { + dropZone.dispatchEvent(dragEnterEvent) + }) + + // Find the drag overlay element (rendered only when dragging=true) + await waitFor(() => { + expect(container.querySelector('.absolute.left-0.top-0')).toBeInTheDocument() + }) + const dragOverlay = container.querySelector('.absolute.left-0.top-0') as HTMLElement + + // Act - dispatch dragleave FROM the overlay so e.target === dragRef.current (line 121) + const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true }) + Object.defineProperty(dragLeaveEvent, 'target', { value: dragOverlay }) + act(() => { + dropZone.dispatchEvent(dragLeaveEvent) + }) + + // Assert - dragging should be set to false, overlay should disappear + await waitFor(() => { + expect(container.querySelector('.absolute.left-0.top-0')).not.toBeInTheDocument() + }) + }) + + it('should handle drop event with valid CSV file', async () => { + const mockUpdateFile = vi.fn() + mockUpload.mockResolvedValueOnce({ id: 'dropped-file-id' }) + + const { container } = render( + <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, + ) + const dropZone = getDropZone(container) + + // Create a drop event with a CSV file + const testFile = new File(['csv,data'], 'dropped.csv', { type: 'text/csv' }) + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as unknown as DragEvent + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { + files: [testFile], + }, + }) + + act(() => { + dropZone.dispatchEvent(dropEvent) + }) + + await waitFor(() => { + expect(mockUpdateFile).toHaveBeenCalled() + }) + }) + + it('should show error when dropping multiple files', () => { + const { container } = render(<CSVUploader {...defaultProps} />) + const dropZone = getDropZone(container) + + // Create a drop event with multiple files + const file1 = new File(['csv1'], 'file1.csv', { type: 'text/csv' }) + const file2 = new File(['csv2'], 'file2.csv', { type: 'text/csv' }) + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) as unknown as DragEvent + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { + files: [file1, file2], + }, + }) + + act(() => { + dropZone.dispatchEvent(dropEvent) + }) + + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) + }) + + it('should handle drop event without dataTransfer', () => { + const mockUpdateFile = vi.fn() + const { container } = render( + <CSVUploader {...defaultProps} updateFile={mockUpdateFile} />, + ) + const dropZone = getDropZone(container) + + // Create a drop event without dataTransfer + const dropEvent = new Event('drop', { bubbles: true, cancelable: true }) + + act(() => { + dropZone.dispatchEvent(dropEvent) + }) + + // Assert - should not call updateFile + expect(mockUpdateFile).not.toHaveBeenCalled() + }) + }) + + describe('getFileType edge cases', () => { + it('should handle file with multiple dots in name', () => { + const { container } = render(<CSVUploader {...defaultProps} />) + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement + const testFile = new File(['content'], 'my.data.file.csv', { type: 'text/csv' }) + + fireEvent.change(fileInput, { target: { files: [testFile] } }) + + // Assert - should be valid and trigger upload + expect(mockNotify).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }), + ) + }) + }) }) diff --git a/web/app/components/datasets/documents/detail/batch-modal/index.spec.tsx b/web/app/components/datasets/documents/detail/batch-modal/__tests__/index.spec.tsx similarity index 89% rename from web/app/components/datasets/documents/detail/batch-modal/index.spec.tsx rename to web/app/components/datasets/documents/detail/batch-modal/__tests__/index.spec.tsx index c056770158..11fa4bca38 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/__tests__/index.spec.tsx @@ -2,10 +2,9 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import BatchModal from './index' +import BatchModal from '../index' -// Mock child components -vi.mock('./csv-downloader', () => ({ +vi.mock('../csv-downloader', () => ({ default: ({ docForm }: { docForm: ChunkingMode }) => ( <div data-testid="csv-downloader" data-doc-form={docForm}> CSV Downloader @@ -13,7 +12,7 @@ vi.mock('./csv-downloader', () => ({ ), })) -vi.mock('./csv-uploader', () => ({ +vi.mock('../csv-uploader', () => ({ default: ({ file, updateFile }: { file: { file?: { id: string } } | undefined, updateFile: (file: { file: { id: string } } | undefined) => void }) => ( <div data-testid="csv-uploader"> <button @@ -45,18 +44,14 @@ describe('BatchModal', () => { onConfirm: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing when isShow is true', () => { - // Arrange & Act render(<BatchModal {...defaultProps} />) - // Assert expect(screen.getByText(/list\.batchModal\.title/i)).toBeInTheDocument() }) it('should not render content when isShow is false', () => { - // Arrange & Act render(<BatchModal {...defaultProps} isShow={false} />) // Assert - Modal is closed @@ -64,62 +59,47 @@ describe('BatchModal', () => { }) it('should render CSVDownloader component', () => { - // Arrange & Act render(<BatchModal {...defaultProps} />) - // Assert expect(screen.getByTestId('csv-downloader')).toBeInTheDocument() }) it('should render CSVUploader component', () => { - // Arrange & Act render(<BatchModal {...defaultProps} />) - // Assert expect(screen.getByTestId('csv-uploader')).toBeInTheDocument() }) it('should render cancel and run buttons', () => { - // Arrange & Act render(<BatchModal {...defaultProps} />) - // Assert expect(screen.getByText(/list\.batchModal\.cancel/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.run/i)).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call onCancel when cancel button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() render(<BatchModal {...defaultProps} onCancel={mockOnCancel} />) - // Act fireEvent.click(screen.getByText(/list\.batchModal\.cancel/i)) - // Assert expect(mockOnCancel).toHaveBeenCalledTimes(1) }) it('should disable run button when no file is uploaded', () => { - // Arrange & Act render(<BatchModal {...defaultProps} />) - // Assert const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button') expect(runButton).toBeDisabled() }) it('should enable run button after file is uploaded', async () => { - // Arrange render(<BatchModal {...defaultProps} />) - // Act fireEvent.click(screen.getByTestId('upload-btn')) - // Assert await waitFor(() => { const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button') expect(runButton).not.toBeDisabled() @@ -127,7 +107,6 @@ describe('BatchModal', () => { }) it('should call onConfirm with file when run button is clicked', async () => { - // Arrange const mockOnConfirm = vi.fn() const mockOnCancel = vi.fn() render(<BatchModal {...defaultProps} onConfirm={mockOnConfirm} onCancel={mockOnCancel} />) @@ -143,19 +122,15 @@ describe('BatchModal', () => { // Act - click run fireEvent.click(screen.getByText(/list\.batchModal\.run/i)) - // Assert expect(mockOnCancel).toHaveBeenCalledTimes(1) expect(mockOnConfirm).toHaveBeenCalledWith({ file: { id: 'test-file-id' } }) }) }) - // Props tests describe('Props', () => { it('should pass docForm to CSVDownloader', () => { - // Arrange & Act render(<BatchModal {...defaultProps} docForm={ChunkingMode.qa} />) - // Assert expect(screen.getByTestId('csv-downloader').getAttribute('data-doc-form')).toBe(ChunkingMode.qa) }) }) @@ -163,7 +138,6 @@ describe('BatchModal', () => { // State reset tests describe('State Reset', () => { it('should reset file when modal is closed and reopened', async () => { - // Arrange const { rerender } = render(<BatchModal {...defaultProps} />) // Upload a file @@ -172,7 +146,6 @@ describe('BatchModal', () => { expect(screen.getByTestId('file-info')).toBeInTheDocument() }) - // Close modal rerender(<BatchModal {...defaultProps} isShow={false} />) // Reopen modal @@ -183,10 +156,8 @@ describe('BatchModal', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should not call onConfirm when no file is present', () => { - // Arrange const mockOnConfirm = vi.fn() render(<BatchModal {...defaultProps} onConfirm={mockOnConfirm} />) @@ -195,23 +166,18 @@ describe('BatchModal', () => { if (runButton) fireEvent.click(runButton) - // Assert expect(mockOnConfirm).not.toHaveBeenCalled() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<BatchModal {...defaultProps} />) - // Act rerender(<BatchModal {...defaultProps} docForm={ChunkingMode.qa} />) - // Assert expect(screen.getByText(/list\.batchModal\.title/i)).toBeInTheDocument() }) it('should handle file cleared after upload', async () => { - // Arrange const mockOnConfirm = vi.fn() render(<BatchModal {...defaultProps} onConfirm={mockOnConfirm} />) diff --git a/web/app/components/datasets/documents/detail/completed/child-segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx similarity index 87% rename from web/app/components/datasets/documents/detail/completed/child-segment-detail.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx index 7709c15058..4e3c7acd2b 100644 --- a/web/app/components/datasets/documents/detail/completed/child-segment-detail.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-detail.spec.tsx @@ -2,12 +2,12 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import ChildSegmentDetail from './child-segment-detail' +import ChildSegmentDetail from '../child-segment-detail' // Mock segment list context let mockFullScreen = false const mockToggleFullScreen = vi.fn() -vi.mock('./index', () => ({ +vi.mock('../index', () => ({ useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => { const state = { fullScreen: mockFullScreen, @@ -29,8 +29,7 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -// Mock child components -vi.mock('./common/action-buttons', () => ({ +vi.mock('../common/action-buttons', () => ({ default: ({ handleCancel, handleSave, loading, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, isChildChunk?: boolean }) => ( <div data-testid="action-buttons"> <button onClick={handleCancel} data-testid="cancel-btn">Cancel</button> @@ -40,7 +39,7 @@ vi.mock('./common/action-buttons', () => ({ ), })) -vi.mock('./common/chunk-content', () => ({ +vi.mock('../common/chunk-content', () => ({ default: ({ question, onQuestionChange, isEditMode }: { question: string, onQuestionChange: (v: string) => void, isEditMode: boolean }) => ( <div data-testid="chunk-content"> <input @@ -53,11 +52,11 @@ vi.mock('./common/chunk-content', () => ({ ), })) -vi.mock('./common/dot', () => ({ +vi.mock('../common/dot', () => ({ default: () => <span data-testid="dot">•</span>, })) -vi.mock('./common/segment-index-tag', () => ({ +vi.mock('../common/segment-index-tag', () => ({ SegmentIndexTag: ({ positionId, labelPrefix }: { positionId?: string, labelPrefix?: string }) => ( <span data-testid="segment-index-tag"> {labelPrefix} @@ -89,97 +88,74 @@ describe('ChildSegmentDetail', () => { docForm: ChunkingMode.text, } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render edit child chunk title', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByText(/segment\.editChildChunk/i)).toBeInTheDocument() }) it('should render chunk content component', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('chunk-content')).toBeInTheDocument() }) it('should render segment index tag', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument() }) it('should render word count', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByText(/segment\.characters/i)).toBeInTheDocument() }) it('should render edit time', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByText(/segment\.editedAt/i)).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() const { container } = render( <ChildSegmentDetail {...defaultProps} onCancel={mockOnCancel} />, ) - // Act const closeButtons = container.querySelectorAll('.cursor-pointer') if (closeButtons.length > 1) fireEvent.click(closeButtons[1]) - // Assert expect(mockOnCancel).toHaveBeenCalled() }) it('should call toggleFullScreen when expand button is clicked', () => { - // Arrange const { container } = render(<ChildSegmentDetail {...defaultProps} />) - // Act const expandButtons = container.querySelectorAll('.cursor-pointer') if (expandButtons.length > 0) fireEvent.click(expandButtons[0]) - // Assert expect(mockToggleFullScreen).toHaveBeenCalled() }) it('should call onUpdate when save is clicked', () => { - // Arrange const mockOnUpdate = vi.fn() render(<ChildSegmentDetail {...defaultProps} onUpdate={mockOnUpdate} />) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert expect(mockOnUpdate).toHaveBeenCalledWith( 'chunk-1', 'child-chunk-1', @@ -188,15 +164,12 @@ describe('ChildSegmentDetail', () => { }) it('should update content when input changes', () => { - // Arrange render(<ChildSegmentDetail {...defaultProps} />) - // Act fireEvent.change(screen.getByTestId('content-input'), { target: { value: 'Updated content' }, }) - // Assert expect(screen.getByTestId('content-input')).toHaveValue('Updated content') }) }) @@ -204,21 +177,16 @@ describe('ChildSegmentDetail', () => { // Full screen mode describe('Full Screen Mode', () => { it('should show action buttons in header when fullScreen is true', () => { - // Arrange mockFullScreen = true - // Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('action-buttons')).toBeInTheDocument() }) it('should not show footer action buttons when fullScreen is true', () => { - // Arrange mockFullScreen = true - // Act render(<ChildSegmentDetail {...defaultProps} />) // Assert - footer with border-t-divider-subtle should not exist @@ -228,13 +196,10 @@ describe('ChildSegmentDetail', () => { }) it('should show footer action buttons when fullScreen is false', () => { - // Arrange mockFullScreen = false - // Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('action-buttons')).toBeInTheDocument() }) }) @@ -242,54 +207,41 @@ describe('ChildSegmentDetail', () => { // Props describe('Props', () => { it('should pass isChildChunk true to ActionButtons', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('is-child-chunk')).toHaveTextContent('true') }) it('should pass isEditMode true to ChunkContent', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing') }) }) - // Edge cases describe('Edge Cases', () => { it('should handle undefined childChunkInfo', () => { - // Arrange & Act const { container } = render( <ChildSegmentDetail {...defaultProps} childChunkInfo={undefined} />, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should handle empty content', () => { - // Arrange const emptyChildChunkInfo = { ...defaultChildChunkInfo, content: '' } - // Act render(<ChildSegmentDetail {...defaultProps} childChunkInfo={emptyChildChunkInfo} />) - // Assert expect(screen.getByTestId('content-input')).toHaveValue('') }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<ChildSegmentDetail {...defaultProps} />) - // Act const updatedInfo = { ...defaultChildChunkInfo, content: 'New content' } rerender(<ChildSegmentDetail {...defaultProps} childChunkInfo={updatedInfo} />) - // Assert expect(screen.getByTestId('content-input')).toBeInTheDocument() }) }) @@ -297,7 +249,6 @@ describe('ChildSegmentDetail', () => { // Event subscription tests describe('Event Subscription', () => { it('should register event subscription', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) // Assert - subscription callback should be registered @@ -305,7 +256,6 @@ describe('ChildSegmentDetail', () => { }) it('should have save button enabled by default', () => { - // Arrange & Act render(<ChildSegmentDetail {...defaultProps} />) // Assert - save button should be enabled initially @@ -316,14 +266,11 @@ describe('ChildSegmentDetail', () => { // Cancel behavior describe('Cancel Behavior', () => { it('should call onCancel when cancel button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() render(<ChildSegmentDetail {...defaultProps} onCancel={mockOnCancel} />) - // Act fireEvent.click(screen.getByTestId('cancel-btn')) - // Assert expect(mockOnCancel).toHaveBeenCalled() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/child-segment-list.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-list.spec.tsx similarity index 89% rename from web/app/components/datasets/documents/detail/completed/child-segment-list.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/child-segment-list.spec.tsx index f63910fccd..11ced823da 100644 --- a/web/app/components/datasets/documents/detail/completed/child-segment-list.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/child-segment-list.spec.tsx @@ -2,11 +2,11 @@ import type { ChildChunkDetail } from '@/models/datasets' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import ChildSegmentList from './child-segment-list' +import ChildSegmentList from '../child-segment-list' // Mock document context let mockParentMode = 'paragraph' -vi.mock('../context', () => ({ +vi.mock('../../context', () => ({ useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => { return selector({ parentMode: mockParentMode }) }, @@ -14,14 +14,13 @@ vi.mock('../context', () => ({ // Mock segment list context let mockCurrChildChunk: { childChunkInfo: { id: string } } | null = null -vi.mock('./index', () => ({ +vi.mock('../index', () => ({ useSegmentListContext: (selector: (state: { currChildChunk: { childChunkInfo: { id: string } } | null }) => unknown) => { return selector({ currChildChunk: mockCurrChildChunk }) }, })) -// Mock child components -vi.mock('./common/empty', () => ({ +vi.mock('../common/empty', () => ({ default: ({ onClearFilter }: { onClearFilter: () => void }) => ( <div data-testid="empty"> <button onClick={onClearFilter} data-testid="clear-filter-btn">Clear Filter</button> @@ -29,11 +28,11 @@ vi.mock('./common/empty', () => ({ ), })) -vi.mock('./skeleton/full-doc-list-skeleton', () => ({ +vi.mock('../skeleton/full-doc-list-skeleton', () => ({ default: () => <div data-testid="full-doc-skeleton">Loading...</div>, })) -vi.mock('../../../formatted-text/flavours/edit-slice', () => ({ +vi.mock('../../../../formatted-text/flavours/edit-slice', () => ({ EditSlice: ({ label, text, @@ -62,7 +61,7 @@ vi.mock('../../../formatted-text/flavours/edit-slice', () => ({ ), })) -vi.mock('../../../formatted-text/formatted', () => ({ +vi.mock('../../../../formatted-text/formatted', () => ({ FormattedText: ({ children, className }: { children: React.ReactNode, className: string }) => ( <div data-testid="formatted-text" className={className}>{children}</div> ), @@ -101,29 +100,22 @@ describe('ChildSegmentList', () => { focused: false, } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<ChildSegmentList {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render total count text', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} />) - // Assert expect(screen.getByText(/segment\.childChunks/i)).toBeInTheDocument() }) it('should render add button', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} />) - // Assert expect(screen.getByText(/operation\.add/i)).toBeInTheDocument() }) }) @@ -135,7 +127,6 @@ describe('ChildSegmentList', () => { }) it('should render collapsed by default in paragraph mode', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} />) // Assert - collapsed icon should be present @@ -143,7 +134,6 @@ describe('ChildSegmentList', () => { }) it('should expand when clicking toggle in paragraph mode', () => { - // Arrange render(<ChildSegmentList {...defaultProps} />) // Act - click on the collapse toggle @@ -156,7 +146,6 @@ describe('ChildSegmentList', () => { }) it('should collapse when clicking toggle again', () => { - // Arrange render(<ChildSegmentList {...defaultProps} />) // Act - click twice @@ -178,61 +167,47 @@ describe('ChildSegmentList', () => { }) it('should render input field in full-doc mode', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} />) - // Assert const input = screen.getByRole('textbox') expect(input).toBeInTheDocument() }) it('should render child chunks without collapse in full-doc mode', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} />) - // Assert expect(screen.getByTestId('formatted-text')).toBeInTheDocument() }) it('should call handleInputChange when input changes', () => { - // Arrange const mockHandleInputChange = vi.fn() render(<ChildSegmentList {...defaultProps} handleInputChange={mockHandleInputChange} />) - // Act const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'search term' } }) - // Assert expect(mockHandleInputChange).toHaveBeenCalledWith('search term') }) it('should show search results text when searching', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} inputValue="search" total={5} />) - // Assert expect(screen.getByText(/segment\.searchResults/i)).toBeInTheDocument() }) it('should show empty component when no results and searching', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} inputValue="search" childChunks={[]} total={0} />) - // Assert expect(screen.getByTestId('empty')).toBeInTheDocument() }) it('should show loading skeleton when isLoading is true', () => { - // Arrange & Act render(<ChildSegmentList {...defaultProps} isLoading={true} />) - // Assert expect(screen.getByTestId('full-doc-skeleton')).toBeInTheDocument() }) it('should handle undefined total in full-doc mode', () => { - // Arrange & Act const { container } = render(<ChildSegmentList {...defaultProps} total={undefined} />) // Assert - component should render without crashing @@ -240,57 +215,44 @@ describe('ChildSegmentList', () => { }) }) - // User Interactions describe('User Interactions', () => { it('should call handleAddNewChildChunk when add button is clicked', () => { - // Arrange mockParentMode = 'full-doc' const mockHandleAddNewChildChunk = vi.fn() render(<ChildSegmentList {...defaultProps} handleAddNewChildChunk={mockHandleAddNewChildChunk} />) - // Act fireEvent.click(screen.getByText(/operation\.add/i)) - // Assert expect(mockHandleAddNewChildChunk).toHaveBeenCalledWith('parent-1') }) it('should call onDelete when delete button is clicked', () => { - // Arrange mockParentMode = 'full-doc' const mockOnDelete = vi.fn() render(<ChildSegmentList {...defaultProps} onDelete={mockOnDelete} />) - // Act fireEvent.click(screen.getByTestId('delete-slice-btn')) - // Assert expect(mockOnDelete).toHaveBeenCalledWith('seg-1', 'child-1') }) it('should call onClickSlice when slice is clicked', () => { - // Arrange mockParentMode = 'full-doc' const mockOnClickSlice = vi.fn() render(<ChildSegmentList {...defaultProps} onClickSlice={mockOnClickSlice} />) - // Act fireEvent.click(screen.getByTestId('click-slice-btn')) - // Assert expect(mockOnClickSlice).toHaveBeenCalledWith(expect.objectContaining({ id: 'child-1' })) }) it('should call onClearFilter when clear filter button is clicked', () => { - // Arrange mockParentMode = 'full-doc' const mockOnClearFilter = vi.fn() render(<ChildSegmentList {...defaultProps} inputValue="search" childChunks={[]} onClearFilter={mockOnClearFilter} />) - // Act fireEvent.click(screen.getByTestId('clear-filter-btn')) - // Assert expect(mockOnClearFilter).toHaveBeenCalled() }) }) @@ -298,11 +260,9 @@ describe('ChildSegmentList', () => { // Focused state describe('Focused State', () => { it('should apply focused style when currChildChunk matches', () => { - // Arrange mockParentMode = 'full-doc' mockCurrChildChunk = { childChunkInfo: { id: 'child-1' } } - // Act render(<ChildSegmentList {...defaultProps} />) // Assert - check for focused class on label @@ -311,14 +271,11 @@ describe('ChildSegmentList', () => { }) it('should not apply focused style when currChildChunk does not match', () => { - // Arrange mockParentMode = 'full-doc' mockCurrChildChunk = { childChunkInfo: { id: 'other-child' } } - // Act render(<ChildSegmentList {...defaultProps} />) - // Assert const label = screen.getByTestId('slice-label') expect(label).not.toHaveClass('bg-state-accent-solid') }) @@ -327,28 +284,22 @@ describe('ChildSegmentList', () => { // Enabled/Disabled state describe('Enabled State', () => { it('should apply opacity when enabled is false', () => { - // Arrange & Act const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('opacity-50') }) it('should not apply opacity when enabled is true', () => { - // Arrange & Act const { container } = render(<ChildSegmentList {...defaultProps} enabled={true} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).not.toHaveClass('opacity-50') }) it('should not apply opacity when focused is true even if enabled is false', () => { - // Arrange & Act const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} focused={true} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).not.toHaveClass('opacity-50') }) @@ -357,14 +308,11 @@ describe('ChildSegmentList', () => { // Edited indicator describe('Edited Indicator', () => { it('should show edited indicator for edited chunks', () => { - // Arrange mockParentMode = 'full-doc' const editedChunk = createMockChildChunk('child-edited', 'Edited content', true) - // Act render(<ChildSegmentList {...defaultProps} childChunks={[editedChunk]} />) - // Assert const label = screen.getByTestId('slice-label') expect(label.textContent).toContain('segment.edited') }) @@ -373,7 +321,6 @@ describe('ChildSegmentList', () => { // Multiple chunks describe('Multiple Chunks', () => { it('should render multiple child chunks', () => { - // Arrange mockParentMode = 'full-doc' const chunks = [ createMockChildChunk('child-1', 'Content 1'), @@ -381,48 +328,36 @@ describe('ChildSegmentList', () => { createMockChildChunk('child-3', 'Content 3'), ] - // Act render(<ChildSegmentList {...defaultProps} childChunks={chunks} total={3} />) - // Assert expect(screen.getAllByTestId('edit-slice')).toHaveLength(3) }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty childChunks array', () => { - // Arrange mockParentMode = 'full-doc' - // Act const { container } = render(<ChildSegmentList {...defaultProps} childChunks={[]} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should maintain structure when rerendered', () => { - // Arrange mockParentMode = 'full-doc' const { rerender } = render(<ChildSegmentList {...defaultProps} />) - // Act const newChunks = [createMockChildChunk('new-child', 'New content')] rerender(<ChildSegmentList {...defaultProps} childChunks={newChunks} />) - // Assert expect(screen.getByText('New content')).toBeInTheDocument() }) it('should disable add button when loading', () => { - // Arrange mockParentMode = 'full-doc' - // Act render(<ChildSegmentList {...defaultProps} isLoading={true} />) - // Assert const addButton = screen.getByText(/operation\.add/i) expect(addButton).toBeDisabled() }) diff --git a/web/app/components/datasets/documents/detail/completed/display-toggle.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/display-toggle.spec.tsx similarity index 88% rename from web/app/components/datasets/documents/detail/completed/display-toggle.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/display-toggle.spec.tsx index e1004b1454..73d5ec920c 100644 --- a/web/app/components/datasets/documents/detail/completed/display-toggle.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/display-toggle.spec.tsx @@ -1,27 +1,22 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import DisplayToggle from './display-toggle' +import DisplayToggle from '../display-toggle' describe('DisplayToggle', () => { beforeEach(() => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should render button with proper styling', () => { - // Arrange & Act render(<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />) - // Assert const button = screen.getByRole('button') expect(button).toHaveClass('flex') expect(button).toHaveClass('items-center') @@ -30,10 +25,8 @@ describe('DisplayToggle', () => { }) }) - // Props tests describe('Props', () => { it('should render expand icon when isCollapsed is true', () => { - // Arrange & Act const { container } = render( <DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />, ) @@ -44,7 +37,6 @@ describe('DisplayToggle', () => { }) it('should render collapse icon when isCollapsed is false', () => { - // Arrange & Act const { container } = render( <DisplayToggle isCollapsed={false} toggleCollapsed={vi.fn()} />, ) @@ -55,32 +47,25 @@ describe('DisplayToggle', () => { }) }) - // User Interactions describe('User Interactions', () => { it('should call toggleCollapsed when button is clicked', () => { - // Arrange const mockToggle = vi.fn() render(<DisplayToggle isCollapsed={true} toggleCollapsed={mockToggle} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockToggle).toHaveBeenCalledTimes(1) }) it('should call toggleCollapsed on multiple clicks', () => { - // Arrange const mockToggle = vi.fn() render(<DisplayToggle isCollapsed={true} toggleCollapsed={mockToggle} />) - // Act const button = screen.getByRole('button') fireEvent.click(button) fireEvent.click(button) fireEvent.click(button) - // Assert expect(mockToggle).toHaveBeenCalledTimes(3) }) }) @@ -88,7 +73,6 @@ describe('DisplayToggle', () => { // Tooltip tests describe('Tooltip', () => { it('should render with tooltip wrapper', () => { - // Arrange & Act const { container } = render( <DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />, ) @@ -98,15 +82,12 @@ describe('DisplayToggle', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should toggle icon when isCollapsed prop changes', () => { - // Arrange const { rerender, container } = render( <DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />, ) - // Act rerender(<DisplayToggle isCollapsed={false} toggleCollapsed={vi.fn()} />) // Assert - icon should still be present @@ -115,15 +96,12 @@ describe('DisplayToggle', () => { }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render( <DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />, ) - // Act rerender(<DisplayToggle isCollapsed={false} toggleCollapsed={vi.fn()} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx similarity index 51% rename from web/app/components/datasets/documents/detail/completed/index.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx index fabce2decf..5802fb8b82 100644 --- a/web/app/components/datasets/documents/detail/completed/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx @@ -1,18 +1,11 @@ import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context' import type { ChildChunkDetail, ChunkingMode, ParentMode, SegmentDetailModel } from '@/models/datasets' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets' -import { useModalState } from './hooks/use-modal-state' -import { useSearchFilter } from './hooks/use-search-filter' -import { useSegmentSelection } from './hooks/use-segment-selection' -import Completed from './index' -import { SegmentListContext, useSegmentListContext } from './segment-list-context' - -// ============================================================================ -// Hoisted Mocks (must be before vi.mock calls) -// ============================================================================ +import Completed from '../index' +import { SegmentListContext, useSegmentListContext } from '../segment-list-context' const { mockDocForm, @@ -56,45 +49,11 @@ const { mockOnDelete: vi.fn(), })) -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { count?: number, ns?: string }) => { - if (key === 'segment.chunks') - return options?.count === 1 ? 'chunk' : 'chunks' - if (key === 'segment.parentChunks') - return options?.count === 1 ? 'parent chunk' : 'parent chunks' - if (key === 'segment.searchResults') - return 'search results' - if (key === 'list.index.all') - return 'All' - if (key === 'list.status.disabled') - return 'Disabled' - if (key === 'list.status.enabled') - return 'Enabled' - if (key === 'actionMsg.modifiedSuccessfully') - return 'Modified successfully' - if (key === 'actionMsg.modifiedUnsuccessfully') - return 'Modified unsuccessfully' - if (key === 'segment.contentEmpty') - return 'Content cannot be empty' - if (key === 'segment.questionEmpty') - return 'Question cannot be empty' - if (key === 'segment.answerEmpty') - return 'Answer cannot be empty' - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), -})) - -// Mock next/navigation vi.mock('next/navigation', () => ({ usePathname: () => '/datasets/test-dataset-id/documents/test-document-id', })) -// Mock document context -vi.mock('../context', () => ({ +vi.mock('../../context', () => ({ useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => { const value: DocumentContextValue = { datasetId: mockDatasetId.current, @@ -106,18 +65,15 @@ vi.mock('../context', () => ({ }, })) -// Mock toast context vi.mock('@/app/components/base/toast', () => ({ ToastContext: { Provider: ({ children }: { children: React.ReactNode }) => children, Consumer: () => null }, useToastContext: () => ({ notify: mockNotify }), })) -// Mock event emitter context vi.mock('@/context/event-emitter', () => ({ useEventEmitterContextContext: () => ({ eventEmitter: mockEventEmitter }), })) -// Mock segment service hooks vi.mock('@/service/knowledge/use-segment', () => ({ useSegmentList: () => ({ isLoading: false, @@ -140,10 +96,8 @@ vi.mock('@/service/knowledge/use-segment', () => ({ useUpdateChildSegment: () => ({ mutateAsync: vi.fn() }), })) -// Mock useInvalid - return trackable functions based on key vi.mock('@/service/use-base', () => ({ useInvalid: (key: unknown[]) => { - // Return specific mock functions based on key to track calls const keyStr = JSON.stringify(key) if (keyStr.includes('"enabled":"all"')) return mockInvalidChunkListAll @@ -155,14 +109,9 @@ vi.mock('@/service/use-base', () => ({ }, })) -// Note: useSegmentSelection is NOT mocked globally to allow direct hook testing -// Batch action tests will use a different approach - -// Mock useChildSegmentData to capture refreshChunkListDataWithDetailChanged let capturedRefreshCallback: (() => void) | null = null -vi.mock('./hooks/use-child-segment-data', () => ({ +vi.mock('../hooks/use-child-segment-data', () => ({ useChildSegmentData: (options: { refreshChunkListDataWithDetailChanged?: () => void }) => { - // Capture the callback for later testing if (options.refreshChunkListDataWithDetailChanged) capturedRefreshCallback = options.refreshChunkListDataWithDetailChanged @@ -181,11 +130,8 @@ vi.mock('./hooks/use-child-segment-data', () => ({ }, })) -// Note: useSearchFilter is NOT mocked globally to allow direct hook testing -// Individual tests that need to control selectedStatus will use different approaches - // Mock child components to simplify testing -vi.mock('./components', () => ({ +vi.mock('../components', () => ({ MenuBar: ({ totalText, onInputChange, inputValue, isLoading, onSelectedAll, onChangeStatus }: { totalText: string onInputChange: (value: string) => void @@ -219,7 +165,7 @@ vi.mock('./components', () => ({ GeneralModeContent: () => <div data-testid="general-mode-content" />, })) -vi.mock('./common/batch-action', () => ({ +vi.mock('../common/batch-action', () => ({ default: ({ selectedIds, onCancel, onBatchEnable, onBatchDisable, onBatchDelete }: { selectedIds: string[] onCancel: () => void @@ -257,10 +203,6 @@ vi.mock('@/app/components/base/pagination', () => ({ ), })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - const createMockSegmentDetail = (overrides: Partial<SegmentDetailModel> = {}): SegmentDetailModel => ({ id: `segment-${Math.random().toString(36).substr(2, 9)}`, position: 1, @@ -289,7 +231,7 @@ const createMockSegmentDetail = (overrides: Partial<SegmentDetailModel> = {}): S ...overrides, }) -const createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildChunkDetail => ({ +const _createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildChunkDetail => ({ id: `child-${Math.random().toString(36).substr(2, 9)}`, position: 1, segment_id: 'segment-1', @@ -301,10 +243,6 @@ const createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildC ...overrides, }) -// ============================================================================ -// Test Utilities -// ============================================================================ - const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -321,767 +259,6 @@ const createWrapper = () => { ) } -// ============================================================================ -// useSearchFilter Hook Tests -// ============================================================================ - -describe('useSearchFilter', () => { - const mockOnPageChange = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - describe('Initial State', () => { - it('should initialize with default values', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - expect(result.current.inputValue).toBe('') - expect(result.current.searchValue).toBe('') - expect(result.current.selectedStatus).toBe('all') - expect(result.current.selectDefaultValue).toBe('all') - }) - - it('should have status list with all options', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - expect(result.current.statusList).toHaveLength(3) - expect(result.current.statusList[0].value).toBe('all') - expect(result.current.statusList[1].value).toBe(0) - expect(result.current.statusList[2].value).toBe(1) - }) - }) - - describe('handleInputChange', () => { - it('should update inputValue immediately', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.handleInputChange('test') - }) - - expect(result.current.inputValue).toBe('test') - }) - - it('should update searchValue after debounce', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.handleInputChange('test') - }) - - expect(result.current.searchValue).toBe('') - - act(() => { - vi.advanceTimersByTime(500) - }) - - expect(result.current.searchValue).toBe('test') - }) - - it('should call onPageChange(1) after debounce', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.handleInputChange('test') - vi.advanceTimersByTime(500) - }) - - expect(mockOnPageChange).toHaveBeenCalledWith(1) - }) - }) - - describe('onChangeStatus', () => { - it('should set selectedStatus to "all" when value is "all"', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.onChangeStatus({ value: 'all', name: 'All' }) - }) - - expect(result.current.selectedStatus).toBe('all') - }) - - it('should set selectedStatus to true when value is truthy', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.onChangeStatus({ value: 1, name: 'Enabled' }) - }) - - expect(result.current.selectedStatus).toBe(true) - }) - - it('should set selectedStatus to false when value is falsy (0)', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.onChangeStatus({ value: 0, name: 'Disabled' }) - }) - - expect(result.current.selectedStatus).toBe(false) - }) - - it('should call onPageChange(1) when status changes', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.onChangeStatus({ value: 1, name: 'Enabled' }) - }) - - expect(mockOnPageChange).toHaveBeenCalledWith(1) - }) - }) - - describe('onClearFilter', () => { - it('should reset all filter values', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - // Set some values first - act(() => { - result.current.handleInputChange('test') - vi.advanceTimersByTime(500) - result.current.onChangeStatus({ value: 1, name: 'Enabled' }) - }) - - // Clear filters - act(() => { - result.current.onClearFilter() - }) - - expect(result.current.inputValue).toBe('') - expect(result.current.searchValue).toBe('') - expect(result.current.selectedStatus).toBe('all') - }) - - it('should call onPageChange(1) when clearing', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - mockOnPageChange.mockClear() - - act(() => { - result.current.onClearFilter() - }) - - expect(mockOnPageChange).toHaveBeenCalledWith(1) - }) - }) - - describe('selectDefaultValue', () => { - it('should return "all" when selectedStatus is "all"', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - expect(result.current.selectDefaultValue).toBe('all') - }) - - it('should return 1 when selectedStatus is true', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.onChangeStatus({ value: 1, name: 'Enabled' }) - }) - - expect(result.current.selectDefaultValue).toBe(1) - }) - - it('should return 0 when selectedStatus is false', () => { - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.onChangeStatus({ value: 0, name: 'Disabled' }) - }) - - expect(result.current.selectDefaultValue).toBe(0) - }) - }) - - describe('Callback Stability', () => { - it('should maintain stable callback references', () => { - const { result, rerender } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - const initialHandleInputChange = result.current.handleInputChange - const initialOnChangeStatus = result.current.onChangeStatus - const initialOnClearFilter = result.current.onClearFilter - const initialResetPage = result.current.resetPage - - rerender() - - expect(result.current.handleInputChange).toBe(initialHandleInputChange) - expect(result.current.onChangeStatus).toBe(initialOnChangeStatus) - expect(result.current.onClearFilter).toBe(initialOnClearFilter) - expect(result.current.resetPage).toBe(initialResetPage) - }) - }) -}) - -// ============================================================================ -// useSegmentSelection Hook Tests -// ============================================================================ - -describe('useSegmentSelection', () => { - const mockSegments: SegmentDetailModel[] = [ - createMockSegmentDetail({ id: 'seg-1' }), - createMockSegmentDetail({ id: 'seg-2' }), - createMockSegmentDetail({ id: 'seg-3' }), - ] - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Initial State', () => { - it('should initialize with empty selection', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - expect(result.current.selectedSegmentIds).toEqual([]) - expect(result.current.isAllSelected).toBe(false) - expect(result.current.isSomeSelected).toBe(false) - }) - }) - - describe('onSelected', () => { - it('should add segment to selection when not selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - }) - - expect(result.current.selectedSegmentIds).toContain('seg-1') - }) - - it('should remove segment from selection when already selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - }) - - expect(result.current.selectedSegmentIds).toContain('seg-1') - - act(() => { - result.current.onSelected('seg-1') - }) - - expect(result.current.selectedSegmentIds).not.toContain('seg-1') - }) - - it('should allow multiple selections', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - result.current.onSelected('seg-2') - }) - - expect(result.current.selectedSegmentIds).toContain('seg-1') - expect(result.current.selectedSegmentIds).toContain('seg-2') - }) - }) - - describe('isAllSelected', () => { - it('should return false when no segments selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - expect(result.current.isAllSelected).toBe(false) - }) - - it('should return false when some segments selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - }) - - expect(result.current.isAllSelected).toBe(false) - }) - - it('should return true when all segments selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - mockSegments.forEach(seg => result.current.onSelected(seg.id)) - }) - - expect(result.current.isAllSelected).toBe(true) - }) - - it('should return false when segments array is empty', () => { - const { result } = renderHook(() => useSegmentSelection([])) - - expect(result.current.isAllSelected).toBe(false) - }) - }) - - describe('isSomeSelected', () => { - it('should return false when no segments selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - expect(result.current.isSomeSelected).toBe(false) - }) - - it('should return true when some segments selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - }) - - expect(result.current.isSomeSelected).toBe(true) - }) - - it('should return true when all segments selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - mockSegments.forEach(seg => result.current.onSelected(seg.id)) - }) - - expect(result.current.isSomeSelected).toBe(true) - }) - }) - - describe('onSelectedAll', () => { - it('should select all segments when none selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelectedAll() - }) - - expect(result.current.isAllSelected).toBe(true) - expect(result.current.selectedSegmentIds).toHaveLength(3) - }) - - it('should deselect all segments when all selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - // Select all first - act(() => { - result.current.onSelectedAll() - }) - - expect(result.current.isAllSelected).toBe(true) - - // Deselect all - act(() => { - result.current.onSelectedAll() - }) - - expect(result.current.isAllSelected).toBe(false) - expect(result.current.selectedSegmentIds).toHaveLength(0) - }) - - it('should select remaining segments when some selected', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - }) - - act(() => { - result.current.onSelectedAll() - }) - - expect(result.current.isAllSelected).toBe(true) - }) - - it('should preserve selection of segments not in current list', () => { - const { result, rerender } = renderHook( - ({ segments }) => useSegmentSelection(segments), - { initialProps: { segments: mockSegments } }, - ) - - // Select segment from initial list - act(() => { - result.current.onSelected('seg-1') - }) - - // Update segments list (simulating pagination) - const newSegments = [ - createMockSegmentDetail({ id: 'seg-4' }), - createMockSegmentDetail({ id: 'seg-5' }), - ] - - rerender({ segments: newSegments }) - - // Select all in new list - act(() => { - result.current.onSelectedAll() - }) - - // Should have seg-1 from old list plus seg-4 and seg-5 from new list - expect(result.current.selectedSegmentIds).toContain('seg-1') - expect(result.current.selectedSegmentIds).toContain('seg-4') - expect(result.current.selectedSegmentIds).toContain('seg-5') - }) - }) - - describe('onCancelBatchOperation', () => { - it('should clear all selections', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - result.current.onSelected('seg-2') - }) - - expect(result.current.selectedSegmentIds).toHaveLength(2) - - act(() => { - result.current.onCancelBatchOperation() - }) - - expect(result.current.selectedSegmentIds).toHaveLength(0) - }) - }) - - describe('clearSelection', () => { - it('should clear all selections', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - act(() => { - result.current.onSelected('seg-1') - }) - - act(() => { - result.current.clearSelection() - }) - - expect(result.current.selectedSegmentIds).toHaveLength(0) - }) - }) - - describe('Callback Stability', () => { - it('should maintain stable callback references for state-independent callbacks', () => { - const { result, rerender } = renderHook(() => useSegmentSelection(mockSegments)) - - const initialOnSelected = result.current.onSelected - const initialOnCancelBatchOperation = result.current.onCancelBatchOperation - const initialClearSelection = result.current.clearSelection - - // Trigger a state change - act(() => { - result.current.onSelected('seg-1') - }) - - rerender() - - // These callbacks don't depend on state, so they should be stable - expect(result.current.onSelected).toBe(initialOnSelected) - expect(result.current.onCancelBatchOperation).toBe(initialOnCancelBatchOperation) - expect(result.current.clearSelection).toBe(initialClearSelection) - }) - - it('should update onSelectedAll when isAllSelected changes', () => { - const { result } = renderHook(() => useSegmentSelection(mockSegments)) - - const initialOnSelectedAll = result.current.onSelectedAll - - // Select all segments to change isAllSelected - act(() => { - mockSegments.forEach(seg => result.current.onSelected(seg.id)) - }) - - // onSelectedAll depends on isAllSelected, so it should change - expect(result.current.onSelectedAll).not.toBe(initialOnSelectedAll) - }) - }) -}) - -// ============================================================================ -// useModalState Hook Tests -// ============================================================================ - -describe('useModalState', () => { - const mockOnNewSegmentModalChange = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Initial State', () => { - it('should initialize with all modals closed', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - expect(result.current.currSegment.showModal).toBe(false) - expect(result.current.currChildChunk.showModal).toBe(false) - expect(result.current.showNewChildSegmentModal).toBe(false) - expect(result.current.isRegenerationModalOpen).toBe(false) - expect(result.current.fullScreen).toBe(false) - expect(result.current.isCollapsed).toBe(true) - }) - - it('should initialize currChunkId as empty string', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - expect(result.current.currChunkId).toBe('') - }) - }) - - describe('Segment Detail Modal', () => { - it('should open segment detail modal with correct data', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - const mockSegment = createMockSegmentDetail({ id: 'test-seg' }) - - act(() => { - result.current.onClickCard(mockSegment) - }) - - expect(result.current.currSegment.showModal).toBe(true) - expect(result.current.currSegment.segInfo).toEqual(mockSegment) - expect(result.current.currSegment.isEditMode).toBe(false) - }) - - it('should open segment detail modal in edit mode', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - const mockSegment = createMockSegmentDetail({ id: 'test-seg' }) - - act(() => { - result.current.onClickCard(mockSegment, true) - }) - - expect(result.current.currSegment.isEditMode).toBe(true) - }) - - it('should close segment detail modal and reset fullScreen', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - const mockSegment = createMockSegmentDetail({ id: 'test-seg' }) - - act(() => { - result.current.onClickCard(mockSegment) - result.current.setFullScreen(true) - }) - - expect(result.current.currSegment.showModal).toBe(true) - expect(result.current.fullScreen).toBe(true) - - act(() => { - result.current.onCloseSegmentDetail() - }) - - expect(result.current.currSegment.showModal).toBe(false) - expect(result.current.fullScreen).toBe(false) - }) - }) - - describe('Child Segment Detail Modal', () => { - it('should open child segment detail modal with correct data', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - const mockChildChunk = createMockChildChunk({ id: 'child-1', segment_id: 'parent-1' }) - - act(() => { - result.current.onClickSlice(mockChildChunk) - }) - - expect(result.current.currChildChunk.showModal).toBe(true) - expect(result.current.currChildChunk.childChunkInfo).toEqual(mockChildChunk) - expect(result.current.currChunkId).toBe('parent-1') - }) - - it('should close child segment detail modal and reset fullScreen', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - const mockChildChunk = createMockChildChunk() - - act(() => { - result.current.onClickSlice(mockChildChunk) - result.current.setFullScreen(true) - }) - - act(() => { - result.current.onCloseChildSegmentDetail() - }) - - expect(result.current.currChildChunk.showModal).toBe(false) - expect(result.current.fullScreen).toBe(false) - }) - }) - - describe('New Segment Modal', () => { - it('should call onNewSegmentModalChange and reset fullScreen when closing', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - act(() => { - result.current.setFullScreen(true) - }) - - act(() => { - result.current.onCloseNewSegmentModal() - }) - - expect(mockOnNewSegmentModalChange).toHaveBeenCalledWith(false) - expect(result.current.fullScreen).toBe(false) - }) - }) - - describe('New Child Segment Modal', () => { - it('should open new child segment modal and set currChunkId', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - act(() => { - result.current.handleAddNewChildChunk('parent-chunk-id') - }) - - expect(result.current.showNewChildSegmentModal).toBe(true) - expect(result.current.currChunkId).toBe('parent-chunk-id') - }) - - it('should close new child segment modal and reset fullScreen', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - act(() => { - result.current.handleAddNewChildChunk('parent-chunk-id') - result.current.setFullScreen(true) - }) - - act(() => { - result.current.onCloseNewChildChunkModal() - }) - - expect(result.current.showNewChildSegmentModal).toBe(false) - expect(result.current.fullScreen).toBe(false) - }) - }) - - describe('Display State', () => { - it('should toggle fullScreen', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - expect(result.current.fullScreen).toBe(false) - - act(() => { - result.current.toggleFullScreen() - }) - - expect(result.current.fullScreen).toBe(true) - - act(() => { - result.current.toggleFullScreen() - }) - - expect(result.current.fullScreen).toBe(false) - }) - - it('should set fullScreen directly', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - act(() => { - result.current.setFullScreen(true) - }) - - expect(result.current.fullScreen).toBe(true) - }) - - it('should toggle isCollapsed', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - expect(result.current.isCollapsed).toBe(true) - - act(() => { - result.current.toggleCollapsed() - }) - - expect(result.current.isCollapsed).toBe(false) - - act(() => { - result.current.toggleCollapsed() - }) - - expect(result.current.isCollapsed).toBe(true) - }) - }) - - describe('Regeneration Modal', () => { - it('should set isRegenerationModalOpen', () => { - const { result } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - act(() => { - result.current.setIsRegenerationModalOpen(true) - }) - - expect(result.current.isRegenerationModalOpen).toBe(true) - - act(() => { - result.current.setIsRegenerationModalOpen(false) - }) - - expect(result.current.isRegenerationModalOpen).toBe(false) - }) - }) - - describe('Callback Stability', () => { - it('should maintain stable callback references', () => { - const { result, rerender } = renderHook(() => - useModalState({ onNewSegmentModalChange: mockOnNewSegmentModalChange }), - ) - - const initialCallbacks = { - onClickCard: result.current.onClickCard, - onCloseSegmentDetail: result.current.onCloseSegmentDetail, - onClickSlice: result.current.onClickSlice, - onCloseChildSegmentDetail: result.current.onCloseChildSegmentDetail, - handleAddNewChildChunk: result.current.handleAddNewChildChunk, - onCloseNewChildChunkModal: result.current.onCloseNewChildChunkModal, - toggleFullScreen: result.current.toggleFullScreen, - toggleCollapsed: result.current.toggleCollapsed, - } - - rerender() - - expect(result.current.onClickCard).toBe(initialCallbacks.onClickCard) - expect(result.current.onCloseSegmentDetail).toBe(initialCallbacks.onCloseSegmentDetail) - expect(result.current.onClickSlice).toBe(initialCallbacks.onClickSlice) - expect(result.current.onCloseChildSegmentDetail).toBe(initialCallbacks.onCloseChildSegmentDetail) - expect(result.current.handleAddNewChildChunk).toBe(initialCallbacks.handleAddNewChildChunk) - expect(result.current.onCloseNewChildChunkModal).toBe(initialCallbacks.onCloseNewChildChunkModal) - expect(result.current.toggleFullScreen).toBe(initialCallbacks.toggleFullScreen) - expect(result.current.toggleCollapsed).toBe(initialCallbacks.toggleCollapsed) - }) - }) -}) - -// ============================================================================ -// SegmentListContext Tests -// ============================================================================ - describe('SegmentListContext', () => { describe('Default Values', () => { it('should have correct default context values', () => { @@ -1195,9 +372,7 @@ describe('SegmentListContext', () => { }) }) -// ============================================================================ // Completed Component Tests -// ============================================================================ describe('Completed Component', () => { const defaultProps = { @@ -1340,59 +515,6 @@ describe('Completed Component', () => { }) }) -// ============================================================================ -// MenuBar Component Tests (via mock verification) -// ============================================================================ - -describe('MenuBar Component', () => { - const defaultProps = { - embeddingAvailable: true, - showNewSegmentModal: false, - onNewSegmentModalChange: vi.fn(), - importStatus: undefined, - archived: false, - } - - beforeEach(() => { - vi.clearAllMocks() - mockDocForm.current = ChunkingModeEnum.text - mockParentMode.current = 'paragraph' - }) - - it('should pass correct props to MenuBar', () => { - render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) - - const menuBar = screen.getByTestId('menu-bar') - expect(menuBar).toBeInTheDocument() - - // Total text should be displayed - const totalText = screen.getByTestId('total-text') - expect(totalText).toHaveTextContent('chunks') - }) - - it('should handle search input changes', async () => { - render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) - - const searchInput = screen.getByTestId('search-input') - fireEvent.change(searchInput, { target: { value: 'test search' } }) - - expect(searchInput).toHaveValue('test search') - }) - - it('should disable search input when loading', () => { - // Loading state is controlled by the segment list hook - render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) - - const searchInput = screen.getByTestId('search-input') - // When not loading, input should not be disabled - expect(searchInput).not.toBeDisabled() - }) -}) - -// ============================================================================ -// Edge Cases and Error Handling -// ============================================================================ - describe('Edge Cases', () => { const defaultProps = { embeddingAvailable: true, @@ -1469,10 +591,6 @@ describe('Edge Cases', () => { }) }) -// ============================================================================ -// Integration Tests -// ============================================================================ - describe('Integration Tests', () => { const defaultProps = { embeddingAvailable: true, @@ -1522,26 +640,7 @@ describe('Integration Tests', () => { }) }) -// ============================================================================ -// useSearchFilter - resetPage Tests -// ============================================================================ - -describe('useSearchFilter - resetPage', () => { - it('should call onPageChange with 1 when resetPage is called', () => { - const mockOnPageChange = vi.fn() - const { result } = renderHook(() => useSearchFilter({ onPageChange: mockOnPageChange })) - - act(() => { - result.current.resetPage() - }) - - expect(mockOnPageChange).toHaveBeenCalledWith(1) - }) -}) - -// ============================================================================ // Batch Action Tests -// ============================================================================ describe('Batch Action Callbacks', () => { const defaultProps = { @@ -1597,7 +696,6 @@ describe('Batch Action Callbacks', () => { it('should render batch actions after selecting all segments', async () => { render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) - // Click the select all button to select all segments const selectAllButton = screen.getByTestId('select-all-button') fireEvent.click(selectAllButton) @@ -1619,7 +717,6 @@ describe('Batch Action Callbacks', () => { expect(screen.getByTestId('batch-action')).toBeInTheDocument() }) - // Click the enable button const enableButton = screen.getByTestId('batch-enable') fireEvent.click(enableButton) @@ -1638,7 +735,6 @@ describe('Batch Action Callbacks', () => { expect(screen.getByTestId('batch-action')).toBeInTheDocument() }) - // Click the disable button const disableButton = screen.getByTestId('batch-disable') fireEvent.click(disableButton) @@ -1657,7 +753,6 @@ describe('Batch Action Callbacks', () => { expect(screen.getByTestId('batch-action')).toBeInTheDocument() }) - // Click the delete button const deleteButton = screen.getByTestId('batch-delete') fireEvent.click(deleteButton) @@ -1665,9 +760,7 @@ describe('Batch Action Callbacks', () => { }) }) -// ============================================================================ // refreshChunkListDataWithDetailChanged Tests -// ============================================================================ describe('refreshChunkListDataWithDetailChanged callback', () => { const defaultProps = { @@ -1774,9 +867,7 @@ describe('refreshChunkListDataWithDetailChanged callback', () => { }) }) -// ============================================================================ // refreshChunkListDataWithDetailChanged Branch Coverage Tests -// ============================================================================ describe('refreshChunkListDataWithDetailChanged branch coverage', () => { // This test simulates the behavior of refreshChunkListDataWithDetailChanged @@ -1823,9 +914,7 @@ describe('refreshChunkListDataWithDetailChanged branch coverage', () => { }) }) -// ============================================================================ // Batch Action Callback Coverage Tests -// ============================================================================ describe('Batch Action callback simulation', () => { // This test simulates the batch action callback behavior @@ -1861,3 +950,191 @@ describe('Batch Action callback simulation', () => { expect(mockOnDelete).toHaveBeenCalledWith('') }) }) + +// Additional Coverage Tests for Inline Callbacks (lines 56-66, 78-83, 254) + +describe('Inline callback and hook initialization coverage', () => { + const defaultProps = { + embeddingAvailable: true, + showNewSegmentModal: false, + onNewSegmentModalChange: vi.fn(), + importStatus: undefined, + archived: false, + } + + beforeEach(() => { + vi.clearAllMocks() + capturedRefreshCallback = null + mockDocForm.current = ChunkingModeEnum.text + mockParentMode.current = 'paragraph' + mockDatasetId.current = 'test-dataset-id' + mockDocumentId.current = 'test-document-id' + mockSegmentListData.data = [ + createMockSegmentDetail({ id: 'seg-cov-1' }), + createMockSegmentDetail({ id: 'seg-cov-2' }), + ] + mockSegmentListData.total = 2 + }) + + // Covers lines 56-58: useSearchFilter({ onPageChange: setCurrentPage }) + it('should reset current page when status filter changes', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('next-page')) + await waitFor(() => { + expect(screen.getByTestId('current-page')).toHaveTextContent('1') + }) + + fireEvent.click(screen.getByTestId('status-enabled')) + + await waitFor(() => { + expect(screen.getByTestId('current-page')).toHaveTextContent('0') + }) + }) + + // Covers lines 61-63: useModalState({ onNewSegmentModalChange }) + it('should pass onNewSegmentModalChange to modal state hook', () => { + const mockOnChange = vi.fn() + render( + <Completed {...defaultProps} onNewSegmentModalChange={mockOnChange} />, + { wrapper: createWrapper() }, + ) + expect(screen.getByTestId('drawer-group')).toBeInTheDocument() + }) + + // Covers lines 74-90: refreshChunkListDataWithDetailChanged with status true + it('should invoke correct invalidation for enabled status', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('status-enabled')) + + await waitFor(() => { + expect(capturedRefreshCallback).toBeDefined() + }) + + mockInvalidChunkListAll.mockClear() + mockInvalidChunkListDisabled.mockClear() + mockInvalidChunkListEnabled.mockClear() + + capturedRefreshCallback!() + + expect(mockInvalidChunkListAll).toHaveBeenCalled() + expect(mockInvalidChunkListDisabled).toHaveBeenCalled() + }) + + // Covers lines 74-90: refreshChunkListDataWithDetailChanged with status false + it('should invoke correct invalidation for disabled status', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('status-disabled')) + + await waitFor(() => { + expect(capturedRefreshCallback).toBeDefined() + }) + + mockInvalidChunkListAll.mockClear() + mockInvalidChunkListDisabled.mockClear() + mockInvalidChunkListEnabled.mockClear() + + capturedRefreshCallback!() + + expect(mockInvalidChunkListAll).toHaveBeenCalled() + expect(mockInvalidChunkListEnabled).toHaveBeenCalled() + }) + + // Covers line 101: clearSelection callback + it('should clear selection via batch cancel', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('select-all-button')) + + await waitFor(() => { + expect(screen.getByTestId('batch-action')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('cancel-batch')) + + await waitFor(() => { + expect(screen.queryByTestId('batch-action')).not.toBeInTheDocument() + }) + }) + + // Covers line 252-254: batch action callbacks + it('should call batch enable through real callback chain', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('select-all-button')) + await waitFor(() => { + expect(screen.getByTestId('batch-action')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('batch-enable')) + await waitFor(() => { + expect(mockOnChangeSwitch).toHaveBeenCalled() + }) + }) + + it('should call batch disable through real callback chain', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('select-all-button')) + await waitFor(() => { + expect(screen.getByTestId('batch-action')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('batch-disable')) + await waitFor(() => { + expect(mockOnChangeSwitch).toHaveBeenCalled() + }) + }) + + it('should call batch delete through real callback chain', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('select-all-button')) + await waitFor(() => { + expect(screen.getByTestId('batch-action')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('batch-delete')) + await waitFor(() => { + expect(mockOnDelete).toHaveBeenCalled() + }) + }) + + // Covers line 133-135: handlePageChange + it('should handle multiple page changes', async () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + fireEvent.click(screen.getByTestId('next-page')) + await waitFor(() => { + expect(screen.getByTestId('current-page')).toHaveTextContent('1') + }) + + fireEvent.click(screen.getByTestId('next-page')) + await waitFor(() => { + expect(screen.getByTestId('current-page')).toHaveTextContent('2') + }) + }) + + // Covers paginationTotal in full-doc mode + it('should compute pagination total from child chunk data in full-doc mode', () => { + mockDocForm.current = ChunkingModeEnum.parentChild + mockParentMode.current = 'full-doc' + mockChildSegmentListData.total = 42 + + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + expect(screen.getByTestId('total-items')).toHaveTextContent('42') + }) + + // Covers search input change + it('should handle search input change', () => { + render(<Completed {...defaultProps} />, { wrapper: createWrapper() }) + + const searchInput = screen.getByTestId('search-input') + fireEvent.change(searchInput, { target: { value: 'test query' } }) + + expect(searchInput).toHaveValue('test query') + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/new-child-segment.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/detail/completed/new-child-segment.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx index 8e936a2c4a..1b26a15b65 100644 --- a/web/app/components/datasets/documents/detail/completed/new-child-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx @@ -1,9 +1,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import NewChildSegmentModal from './new-child-segment' +import NewChildSegmentModal from '../new-child-segment' -// Mock next/navigation vi.mock('next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id', @@ -11,7 +10,6 @@ vi.mock('next/navigation', () => ({ }), })) -// Mock ToastContext const mockNotify = vi.fn() vi.mock('use-context-selector', async (importOriginal) => { const actual = await importOriginal() as Record<string, unknown> @@ -23,7 +21,7 @@ vi.mock('use-context-selector', async (importOriginal) => { // Mock document context let mockParentMode = 'paragraph' -vi.mock('../context', () => ({ +vi.mock('../../context', () => ({ useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => { return selector({ parentMode: mockParentMode }) }, @@ -32,7 +30,7 @@ vi.mock('../context', () => ({ // Mock segment list context let mockFullScreen = false const mockToggleFullScreen = vi.fn() -vi.mock('./index', () => ({ +vi.mock('../index', () => ({ useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => { const state = { fullScreen: mockFullScreen, @@ -55,8 +53,7 @@ vi.mock('@/app/components/app/store', () => ({ useStore: () => ({ appSidebarExpand: 'expand' }), })) -// Mock child components -vi.mock('./common/action-buttons', () => ({ +vi.mock('../common/action-buttons', () => ({ default: ({ handleCancel, handleSave, loading, actionType, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string, isChildChunk?: boolean }) => ( <div data-testid="action-buttons"> <button onClick={handleCancel} data-testid="cancel-btn">Cancel</button> @@ -69,7 +66,7 @@ vi.mock('./common/action-buttons', () => ({ ), })) -vi.mock('./common/add-another', () => ({ +vi.mock('../common/add-another', () => ({ default: ({ isChecked, onCheck, className }: { isChecked: boolean, onCheck: () => void, className?: string }) => ( <div data-testid="add-another" className={className}> <input @@ -82,7 +79,7 @@ vi.mock('./common/add-another', () => ({ ), })) -vi.mock('./common/chunk-content', () => ({ +vi.mock('../common/chunk-content', () => ({ default: ({ question, onQuestionChange, isEditMode }: { question: string, onQuestionChange: (v: string) => void, isEditMode: boolean }) => ( <div data-testid="chunk-content"> <input @@ -95,11 +92,11 @@ vi.mock('./common/chunk-content', () => ({ ), })) -vi.mock('./common/dot', () => ({ +vi.mock('../common/dot', () => ({ default: () => <span data-testid="dot">•</span>, })) -vi.mock('./common/segment-index-tag', () => ({ +vi.mock('../common/segment-index-tag', () => ({ SegmentIndexTag: ({ label }: { label: string }) => <span data-testid="segment-index-tag">{label}</span>, })) @@ -117,102 +114,78 @@ describe('NewChildSegmentModal', () => { viewNewlyAddedChildChunk: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render add child chunk title', () => { - // Arrange & Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByText(/segment\.addChildChunk/i)).toBeInTheDocument() }) it('should render chunk content component', () => { - // Arrange & Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('chunk-content')).toBeInTheDocument() }) it('should render segment index tag with new child chunk label', () => { - // Arrange & Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument() }) it('should render add another checkbox', () => { - // Arrange & Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('add-another')).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() const { container } = render( <NewChildSegmentModal {...defaultProps} onCancel={mockOnCancel} />, ) - // Act const closeButtons = container.querySelectorAll('.cursor-pointer') if (closeButtons.length > 1) fireEvent.click(closeButtons[1]) - // Assert expect(mockOnCancel).toHaveBeenCalled() }) it('should call toggleFullScreen when expand button is clicked', () => { - // Arrange const { container } = render(<NewChildSegmentModal {...defaultProps} />) - // Act const expandButtons = container.querySelectorAll('.cursor-pointer') if (expandButtons.length > 0) fireEvent.click(expandButtons[0]) - // Assert expect(mockToggleFullScreen).toHaveBeenCalled() }) it('should update content when input changes', () => { - // Arrange render(<NewChildSegmentModal {...defaultProps} />) - // Act fireEvent.change(screen.getByTestId('content-input'), { target: { value: 'New content' }, }) - // Assert expect(screen.getByTestId('content-input')).toHaveValue('New content') }) it('should toggle add another checkbox', () => { - // Arrange render(<NewChildSegmentModal {...defaultProps} />) const checkbox = screen.getByTestId('add-another-checkbox') - // Act fireEvent.click(checkbox) - // Assert expect(checkbox).toBeInTheDocument() }) }) @@ -220,13 +193,10 @@ describe('NewChildSegmentModal', () => { // Save validation describe('Save Validation', () => { it('should show error when content is empty', async () => { - // Arrange render(<NewChildSegmentModal {...defaultProps} />) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -240,7 +210,6 @@ describe('NewChildSegmentModal', () => { // Successful save describe('Successful Save', () => { it('should call addChildSegment when valid content is provided', async () => { - // Arrange mockAddChildSegment.mockImplementation((_params, options) => { options.onSuccess({ data: { id: 'new-child-id' } }) options.onSettled() @@ -252,10 +221,8 @@ describe('NewChildSegmentModal', () => { target: { value: 'Valid content' }, }) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockAddChildSegment).toHaveBeenCalledWith( expect.objectContaining({ @@ -272,7 +239,6 @@ describe('NewChildSegmentModal', () => { }) it('should show success notification after save', async () => { - // Arrange mockAddChildSegment.mockImplementation((_params, options) => { options.onSuccess({ data: { id: 'new-child-id' } }) options.onSettled() @@ -284,10 +250,8 @@ describe('NewChildSegmentModal', () => { target: { value: 'Valid content' }, }) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -301,24 +265,18 @@ describe('NewChildSegmentModal', () => { // Full screen mode describe('Full Screen Mode', () => { it('should show action buttons in header when fullScreen', () => { - // Arrange mockFullScreen = true - // Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('action-buttons')).toBeInTheDocument() }) it('should show add another in header when fullScreen', () => { - // Arrange mockFullScreen = true - // Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('add-another')).toBeInTheDocument() }) }) @@ -326,51 +284,38 @@ describe('NewChildSegmentModal', () => { // Props describe('Props', () => { it('should pass actionType add to ActionButtons', () => { - // Arrange & Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('action-type')).toHaveTextContent('add') }) it('should pass isChildChunk true to ActionButtons', () => { - // Arrange & Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('is-child-chunk')).toHaveTextContent('true') }) it('should pass isEditMode true to ChunkContent', () => { - // Arrange & Act render(<NewChildSegmentModal {...defaultProps} />) - // Assert expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing') }) }) - // Edge cases describe('Edge Cases', () => { it('should handle undefined viewNewlyAddedChildChunk', () => { - // Arrange const props = { ...defaultProps, viewNewlyAddedChildChunk: undefined } - // Act const { container } = render(<NewChildSegmentModal {...props} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<NewChildSegmentModal {...defaultProps} />) - // Act rerender(<NewChildSegmentModal {...defaultProps} chunkId="chunk-2" />) - // Assert expect(screen.getByTestId('chunk-content')).toBeInTheDocument() }) }) @@ -378,7 +323,6 @@ describe('NewChildSegmentModal', () => { // Add another behavior describe('Add Another Behavior', () => { it('should close modal when add another is unchecked after save', async () => { - // Arrange const mockOnCancel = vi.fn() mockAddChildSegment.mockImplementation((_params, options) => { options.onSuccess({ data: { id: 'new-child-id' } }) @@ -396,7 +340,6 @@ describe('NewChildSegmentModal', () => { target: { value: 'Valid content' }, }) - // Act fireEvent.click(screen.getByTestId('save-btn')) // Assert - modal should close @@ -406,7 +349,6 @@ describe('NewChildSegmentModal', () => { }) it('should not close modal when add another is checked after save', async () => { - // Arrange const mockOnCancel = vi.fn() mockAddChildSegment.mockImplementation((_params, options) => { options.onSuccess({ data: { id: 'new-child-id' } }) @@ -421,7 +363,6 @@ describe('NewChildSegmentModal', () => { target: { value: 'Valid content' }, }) - // Act fireEvent.click(screen.getByTestId('save-btn')) // Assert - modal should not close, only content cleared @@ -434,7 +375,6 @@ describe('NewChildSegmentModal', () => { // View newly added chunk describe('View Newly Added Chunk', () => { it('should show custom button in full-doc mode after save', async () => { - // Arrange mockParentMode = 'full-doc' mockAddChildSegment.mockImplementation((_params, options) => { options.onSuccess({ data: { id: 'new-child-id' } }) @@ -449,7 +389,6 @@ describe('NewChildSegmentModal', () => { target: { value: 'Valid content' }, }) - // Act fireEvent.click(screen.getByTestId('save-btn')) // Assert - success notification with custom component @@ -464,7 +403,6 @@ describe('NewChildSegmentModal', () => { }) it('should not show custom button in paragraph mode after save', async () => { - // Arrange mockParentMode = 'paragraph' const mockOnSave = vi.fn() mockAddChildSegment.mockImplementation((_params, options) => { @@ -480,7 +418,6 @@ describe('NewChildSegmentModal', () => { target: { value: 'Valid content' }, }) - // Act fireEvent.click(screen.getByTestId('save-btn')) // Assert - onSave should be called with data @@ -493,14 +430,11 @@ describe('NewChildSegmentModal', () => { // Cancel behavior describe('Cancel Behavior', () => { it('should call onCancel when close button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() render(<NewChildSegmentModal {...defaultProps} onCancel={mockOnCancel} />) - // Act fireEvent.click(screen.getByTestId('cancel-btn')) - // Assert expect(mockOnCancel).toHaveBeenCalled() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/segment-detail.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx similarity index 89% rename from web/app/components/datasets/documents/detail/completed/segment-detail.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx index 479958ea2d..dbce9b7f22 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-detail.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/segment-detail.spec.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode } from '@/models/datasets' -import SegmentDetail from './segment-detail' +import SegmentDetail from '../segment-detail' // Mock dataset detail context let mockIndexingTechnique = IndexingType.QUALIFIED @@ -21,7 +21,7 @@ vi.mock('@/context/dataset-detail', () => ({ // Mock document context let mockParentMode = 'paragraph' -vi.mock('../context', () => ({ +vi.mock('../../context', () => ({ useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => { return selector({ parentMode: mockParentMode }) }, @@ -30,7 +30,7 @@ vi.mock('../context', () => ({ // Mock segment list context let mockFullScreen = false const mockToggleFullScreen = vi.fn() -vi.mock('./index', () => ({ +vi.mock('../index', () => ({ useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => { const state = { fullScreen: mockFullScreen, @@ -49,8 +49,7 @@ vi.mock('@/context/event-emitter', () => ({ }), })) -// Mock child components -vi.mock('./common/action-buttons', () => ({ +vi.mock('../common/action-buttons', () => ({ default: ({ handleCancel, handleSave, handleRegeneration, loading, showRegenerationButton }: { handleCancel: () => void, handleSave: () => void, handleRegeneration?: () => void, loading: boolean, showRegenerationButton?: boolean }) => ( <div data-testid="action-buttons"> <button onClick={handleCancel} data-testid="cancel-btn">Cancel</button> @@ -62,7 +61,7 @@ vi.mock('./common/action-buttons', () => ({ ), })) -vi.mock('./common/chunk-content', () => ({ +vi.mock('../common/chunk-content', () => ({ default: ({ docForm, question, answer, onQuestionChange, onAnswerChange, isEditMode }: { docForm: string, question: string, answer: string, onQuestionChange: (v: string) => void, onAnswerChange: (v: string) => void, isEditMode: boolean }) => ( <div data-testid="chunk-content"> <input @@ -82,11 +81,11 @@ vi.mock('./common/chunk-content', () => ({ ), })) -vi.mock('./common/dot', () => ({ +vi.mock('../common/dot', () => ({ default: () => <span data-testid="dot">•</span>, })) -vi.mock('./common/keywords', () => ({ +vi.mock('../common/keywords', () => ({ default: ({ keywords, onKeywordsChange, _isEditMode, actionType }: { keywords: string[], onKeywordsChange: (v: string[]) => void, _isEditMode?: boolean, actionType: string }) => ( <div data-testid="keywords"> <span data-testid="keywords-action">{actionType}</span> @@ -99,7 +98,7 @@ vi.mock('./common/keywords', () => ({ ), })) -vi.mock('./common/segment-index-tag', () => ({ +vi.mock('../common/segment-index-tag', () => ({ SegmentIndexTag: ({ positionId, label, labelPrefix }: { positionId?: string, label?: string, labelPrefix?: string }) => ( <span data-testid="segment-index-tag"> {labelPrefix} @@ -111,7 +110,7 @@ vi.mock('./common/segment-index-tag', () => ({ ), })) -vi.mock('./common/regeneration-modal', () => ({ +vi.mock('../common/regeneration-modal', () => ({ default: ({ isShow, onConfirm, onCancel, onClose }: { isShow: boolean, onConfirm: () => void, onCancel: () => void, onClose: () => void }) => ( isShow ? ( @@ -171,53 +170,40 @@ describe('SegmentDetail', () => { onModalStateChange: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<SegmentDetail {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render title for view mode', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} isEditMode={false} />) - // Assert expect(screen.getByText(/segment\.chunkDetail/i)).toBeInTheDocument() }) it('should render title for edit mode', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByText(/segment\.editChunk/i)).toBeInTheDocument() }) it('should render chunk content component', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('chunk-content')).toBeInTheDocument() }) it('should render image uploader', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('image-uploader')).toBeInTheDocument() }) it('should render segment index tag', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument() }) }) @@ -225,42 +211,32 @@ describe('SegmentDetail', () => { // Edit mode vs View mode describe('Edit/View Mode', () => { it('should pass isEditMode to ChunkContent', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing') }) it('should disable image uploader in view mode', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} isEditMode={false} />) - // Assert expect(screen.getByTestId('uploader-disabled')).toHaveTextContent('disabled') }) it('should enable image uploader in edit mode', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByTestId('uploader-disabled')).toHaveTextContent('enabled') }) it('should show action buttons in edit mode', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByTestId('action-buttons')).toBeInTheDocument() }) it('should not show action buttons in view mode (non-fullscreen)', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} isEditMode={false} />) - // Assert expect(screen.queryByTestId('action-buttons')).not.toBeInTheDocument() }) }) @@ -268,88 +244,66 @@ describe('SegmentDetail', () => { // Keywords display describe('Keywords', () => { it('should show keywords component when indexing is ECONOMICAL', () => { - // Arrange mockIndexingTechnique = IndexingType.ECONOMICAL - // Act render(<SegmentDetail {...defaultProps} />) - // Assert expect(screen.getByTestId('keywords')).toBeInTheDocument() }) it('should not show keywords when indexing is QUALIFIED', () => { - // Arrange mockIndexingTechnique = IndexingType.QUALIFIED - // Act render(<SegmentDetail {...defaultProps} />) - // Assert expect(screen.queryByTestId('keywords')).not.toBeInTheDocument() }) it('should pass view action type when not in edit mode', () => { - // Arrange mockIndexingTechnique = IndexingType.ECONOMICAL - // Act render(<SegmentDetail {...defaultProps} isEditMode={false} />) - // Assert expect(screen.getByTestId('keywords-action')).toHaveTextContent('view') }) it('should pass edit action type when in edit mode', () => { - // Arrange mockIndexingTechnique = IndexingType.ECONOMICAL - // Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByTestId('keywords-action')).toHaveTextContent('edit') }) }) - // User Interactions describe('User Interactions', () => { it('should call onCancel when close button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() const { container } = render(<SegmentDetail {...defaultProps} onCancel={mockOnCancel} />) - // Act const closeButtons = container.querySelectorAll('.cursor-pointer') if (closeButtons.length > 1) fireEvent.click(closeButtons[1]) - // Assert expect(mockOnCancel).toHaveBeenCalled() }) it('should call toggleFullScreen when expand button is clicked', () => { - // Arrange const { container } = render(<SegmentDetail {...defaultProps} />) - // Act const expandButtons = container.querySelectorAll('.cursor-pointer') if (expandButtons.length > 0) fireEvent.click(expandButtons[0]) - // Assert expect(mockToggleFullScreen).toHaveBeenCalled() }) it('should call onUpdate when save is clicked', () => { - // Arrange const mockOnUpdate = vi.fn() render(<SegmentDetail {...defaultProps} isEditMode={true} onUpdate={mockOnUpdate} />) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert expect(mockOnUpdate).toHaveBeenCalledWith( 'segment-1', expect.any(String), @@ -362,15 +316,12 @@ describe('SegmentDetail', () => { }) it('should update question when input changes', () => { - // Arrange render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Act fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Updated content' }, }) - // Assert expect(screen.getByTestId('question-input')).toHaveValue('Updated content') }) }) @@ -378,40 +329,30 @@ describe('SegmentDetail', () => { // Regeneration Modal describe('Regeneration Modal', () => { it('should show regeneration button when runtimeMode is general', () => { - // Arrange mockRuntimeMode = 'general' - // Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument() }) it('should not show regeneration button when runtimeMode is not general', () => { - // Arrange mockRuntimeMode = 'pipeline' - // Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.queryByTestId('regenerate-btn')).not.toBeInTheDocument() }) it('should show regeneration modal when regenerate is clicked', () => { - // Arrange render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Act fireEvent.click(screen.getByTestId('regenerate-btn')) - // Assert expect(screen.getByTestId('regeneration-modal')).toBeInTheDocument() }) it('should call onModalStateChange when regeneration modal opens', () => { - // Arrange const mockOnModalStateChange = vi.fn() render( <SegmentDetail @@ -421,15 +362,12 @@ describe('SegmentDetail', () => { />, ) - // Act fireEvent.click(screen.getByTestId('regenerate-btn')) - // Assert expect(mockOnModalStateChange).toHaveBeenCalledWith(true) }) it('should close modal when cancel is clicked', () => { - // Arrange const mockOnModalStateChange = vi.fn() render( <SegmentDetail @@ -440,10 +378,8 @@ describe('SegmentDetail', () => { ) fireEvent.click(screen.getByTestId('regenerate-btn')) - // Act fireEvent.click(screen.getByTestId('cancel-regeneration')) - // Assert expect(mockOnModalStateChange).toHaveBeenCalledWith(false) expect(screen.queryByTestId('regeneration-modal')).not.toBeInTheDocument() }) @@ -452,66 +388,50 @@ describe('SegmentDetail', () => { // Full screen mode describe('Full Screen Mode', () => { it('should show action buttons in header when fullScreen and editMode', () => { - // Arrange mockFullScreen = true - // Act render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByTestId('action-buttons')).toBeInTheDocument() }) it('should apply full screen styling when fullScreen is true', () => { - // Arrange mockFullScreen = true - // Act const { container } = render(<SegmentDetail {...defaultProps} />) - // Assert const header = container.querySelector('.border-divider-subtle') expect(header).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle segInfo with minimal data', () => { - // Arrange const minimalSegInfo = { id: 'segment-minimal', position: 1, word_count: 0, } - // Act const { container } = render(<SegmentDetail {...defaultProps} segInfo={minimalSegInfo} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should handle empty keywords array', () => { - // Arrange mockIndexingTechnique = IndexingType.ECONOMICAL const segInfo = { ...defaultSegInfo, keywords: [] } - // Act render(<SegmentDetail {...defaultProps} segInfo={segInfo} />) - // Assert expect(screen.getByTestId('keywords-input')).toHaveValue('') }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<SegmentDetail {...defaultProps} isEditMode={false} />) - // Act rerender(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Assert expect(screen.getByTestId('action-buttons')).toBeInTheDocument() }) }) @@ -519,28 +439,22 @@ describe('SegmentDetail', () => { // Attachments describe('Attachments', () => { it('should update attachments when onChange is called', () => { - // Arrange render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Act fireEvent.click(screen.getByTestId('add-attachment-btn')) - // Assert expect(screen.getByTestId('attachments-count')).toHaveTextContent('1') }) it('should pass attachments to onUpdate when save is clicked', () => { - // Arrange const mockOnUpdate = vi.fn() render(<SegmentDetail {...defaultProps} isEditMode={true} onUpdate={mockOnUpdate} />) // Add an attachment fireEvent.click(screen.getByTestId('add-attachment-btn')) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert expect(mockOnUpdate).toHaveBeenCalledWith( 'segment-1', expect.any(String), @@ -553,7 +467,6 @@ describe('SegmentDetail', () => { }) it('should initialize attachments from segInfo', () => { - // Arrange const segInfoWithAttachments = { ...defaultSegInfo, attachments: [ @@ -561,10 +474,8 @@ describe('SegmentDetail', () => { ], } - // Act render(<SegmentDetail {...defaultProps} segInfo={segInfoWithAttachments} isEditMode={true} />) - // Assert expect(screen.getByTestId('attachments-count')).toHaveTextContent('1') }) }) @@ -572,17 +483,14 @@ describe('SegmentDetail', () => { // Regeneration confirmation describe('Regeneration Confirmation', () => { it('should call onUpdate with needRegenerate true when confirm regeneration is clicked', () => { - // Arrange const mockOnUpdate = vi.fn() render(<SegmentDetail {...defaultProps} isEditMode={true} onUpdate={mockOnUpdate} />) // Open regeneration modal fireEvent.click(screen.getByTestId('regenerate-btn')) - // Act fireEvent.click(screen.getByTestId('confirm-regeneration')) - // Assert expect(mockOnUpdate).toHaveBeenCalledWith( 'segment-1', expect.any(String), @@ -595,7 +503,6 @@ describe('SegmentDetail', () => { }) it('should close modal and edit drawer when close after regeneration is clicked', () => { - // Arrange const mockOnCancel = vi.fn() const mockOnModalStateChange = vi.fn() render( @@ -610,10 +517,8 @@ describe('SegmentDetail', () => { // Open regeneration modal fireEvent.click(screen.getByTestId('regenerate-btn')) - // Act fireEvent.click(screen.getByTestId('close-regeneration')) - // Assert expect(mockOnModalStateChange).toHaveBeenCalledWith(false) expect(mockOnCancel).toHaveBeenCalled() }) @@ -622,28 +527,22 @@ describe('SegmentDetail', () => { // QA mode describe('QA Mode', () => { it('should render answer input in QA mode', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} docForm={ChunkingMode.qa} isEditMode={true} />) - // Assert expect(screen.getByTestId('answer-input')).toBeInTheDocument() }) it('should update answer when input changes', () => { - // Arrange render(<SegmentDetail {...defaultProps} docForm={ChunkingMode.qa} isEditMode={true} />) - // Act fireEvent.change(screen.getByTestId('answer-input'), { target: { value: 'Updated answer' }, }) - // Assert expect(screen.getByTestId('answer-input')).toHaveValue('Updated answer') }) it('should calculate word count correctly in QA mode', () => { - // Arrange & Act render(<SegmentDetail {...defaultProps} docForm={ChunkingMode.qa} isEditMode={true} />) // Assert - should show combined length of question and answer @@ -654,13 +553,10 @@ describe('SegmentDetail', () => { // Full doc mode describe('Full Doc Mode', () => { it('should show label in full-doc parent-child mode', () => { - // Arrange mockParentMode = 'full-doc' - // Act render(<SegmentDetail {...defaultProps} docForm={ChunkingMode.parentChild} />) - // Assert expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument() }) }) @@ -668,16 +564,13 @@ describe('SegmentDetail', () => { // Keywords update describe('Keywords Update', () => { it('should update keywords when changed in edit mode', () => { - // Arrange mockIndexingTechnique = IndexingType.ECONOMICAL render(<SegmentDetail {...defaultProps} isEditMode={true} />) - // Act fireEvent.change(screen.getByTestId('keywords-input'), { target: { value: 'new,keywords' }, }) - // Assert expect(screen.getByTestId('keywords-input')).toHaveValue('new,keywords') }) }) diff --git a/web/app/components/datasets/documents/detail/completed/segment-list.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/segment-list.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/detail/completed/segment-list.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/segment-list.spec.tsx index 1716059883..caab14a8e9 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-list.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/segment-list.spec.tsx @@ -3,12 +3,12 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import SegmentList from './segment-list' +import SegmentList from '../segment-list' // Mock document context let mockDocForm = ChunkingMode.text let mockParentMode = 'paragraph' -vi.mock('../context', () => ({ +vi.mock('../../context', () => ({ useDocumentContext: (selector: (state: { docForm: ChunkingMode, parentMode: string }) => unknown) => { return selector({ docForm: mockDocForm, @@ -20,7 +20,7 @@ vi.mock('../context', () => ({ // Mock segment list context let mockCurrSegment: { segInfo: { id: string } } | null = null let mockCurrChildChunk: { childChunkInfo: { segment_id: string } } | null = null -vi.mock('./index', () => ({ +vi.mock('../index', () => ({ useSegmentListContext: (selector: (state: { currSegment: { segInfo: { id: string } } | null, currChildChunk: { childChunkInfo: { segment_id: string } } | null }) => unknown) => { return selector({ currSegment: mockCurrSegment, @@ -29,8 +29,7 @@ vi.mock('./index', () => ({ }, })) -// Mock child components -vi.mock('./common/empty', () => ({ +vi.mock('../common/empty', () => ({ default: ({ onClearFilter }: { onClearFilter: () => void }) => ( <div data-testid="empty"> <button onClick={onClearFilter} data-testid="clear-filter-btn">Clear Filter</button> @@ -38,7 +37,7 @@ vi.mock('./common/empty', () => ({ ), })) -vi.mock('./segment-card', () => ({ +vi.mock('../segment-card', () => ({ default: ({ detail, onClick, @@ -81,11 +80,11 @@ vi.mock('./segment-card', () => ({ ), })) -vi.mock('./skeleton/general-list-skeleton', () => ({ +vi.mock('../skeleton/general-list-skeleton', () => ({ default: () => <div data-testid="general-skeleton">Loading...</div>, })) -vi.mock('./skeleton/paragraph-list-skeleton', () => ({ +vi.mock('../skeleton/paragraph-list-skeleton', () => ({ default: () => <div data-testid="paragraph-skeleton">Loading Paragraph...</div>, })) @@ -137,73 +136,55 @@ describe('SegmentList', () => { onClearFilter: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<SegmentList {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render segment cards for each item', () => { - // Arrange const items = [ createMockSegment('seg-1', 'Content 1'), createMockSegment('seg-2', 'Content 2'), ] - // Act render(<SegmentList {...defaultProps} items={items} />) - // Assert expect(screen.getAllByTestId('segment-card')).toHaveLength(2) }) it('should render empty component when items is empty', () => { - // Arrange & Act render(<SegmentList {...defaultProps} items={[]} />) - // Assert expect(screen.getByTestId('empty')).toBeInTheDocument() }) }) - // Loading state describe('Loading State', () => { it('should render general skeleton when loading and docForm is text', () => { - // Arrange mockDocForm = ChunkingMode.text - // Act render(<SegmentList {...defaultProps} isLoading={true} />) - // Assert expect(screen.getByTestId('general-skeleton')).toBeInTheDocument() }) it('should render paragraph skeleton when loading and docForm is parentChild with paragraph mode', () => { - // Arrange mockDocForm = ChunkingMode.parentChild mockParentMode = 'paragraph' - // Act render(<SegmentList {...defaultProps} isLoading={true} />) - // Assert expect(screen.getByTestId('paragraph-skeleton')).toBeInTheDocument() }) it('should render general skeleton when loading and docForm is parentChild with full-doc mode', () => { - // Arrange mockDocForm = ChunkingMode.parentChild mockParentMode = 'full-doc' - // Act render(<SegmentList {...defaultProps} isLoading={true} />) - // Assert expect(screen.getByTestId('general-skeleton')).toBeInTheDocument() }) }) @@ -211,18 +192,14 @@ describe('SegmentList', () => { // Props passing describe('Props Passing', () => { it('should pass archived prop to SegmentCard', () => { - // Arrange & Act render(<SegmentList {...defaultProps} archived={true} />) - // Assert expect(screen.getByTestId('archived')).toHaveTextContent('true') }) it('should pass embeddingAvailable prop to SegmentCard', () => { - // Arrange & Act render(<SegmentList {...defaultProps} embeddingAvailable={false} />) - // Assert expect(screen.getByTestId('embedding-available')).toHaveTextContent('false') }) }) @@ -230,35 +207,26 @@ describe('SegmentList', () => { // Focused state describe('Focused State', () => { it('should set focused index when currSegment matches', () => { - // Arrange mockCurrSegment = { segInfo: { id: 'seg-1' } } - // Act render(<SegmentList {...defaultProps} />) - // Assert expect(screen.getByTestId('focused-index')).toHaveTextContent('true') }) it('should set focused content when currSegment matches', () => { - // Arrange mockCurrSegment = { segInfo: { id: 'seg-1' } } - // Act render(<SegmentList {...defaultProps} />) - // Assert expect(screen.getByTestId('focused-content')).toHaveTextContent('true') }) it('should set focused when currChildChunk parent matches', () => { - // Arrange mockCurrChildChunk = { childChunkInfo: { segment_id: 'seg-1' } } - // Act render(<SegmentList {...defaultProps} />) - // Assert expect(screen.getByTestId('focused-index')).toHaveTextContent('true') }) }) @@ -266,50 +234,39 @@ describe('SegmentList', () => { // Clear filter describe('Clear Filter', () => { it('should call onClearFilter when clear filter button is clicked', async () => { - // Arrange const mockOnClearFilter = vi.fn() render(<SegmentList {...defaultProps} items={[]} onClearFilter={mockOnClearFilter} />) - // Act screen.getByTestId('clear-filter-btn').click() - // Assert expect(mockOnClearFilter).toHaveBeenCalled() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle single item without divider', () => { - // Arrange & Act render(<SegmentList {...defaultProps} items={[createMockSegment('seg-1', 'Content')]} />) - // Assert expect(screen.getByTestId('segment-card')).toBeInTheDocument() }) it('should handle multiple items with dividers', () => { - // Arrange const items = [ createMockSegment('seg-1', 'Content 1'), createMockSegment('seg-2', 'Content 2'), createMockSegment('seg-3', 'Content 3'), ] - // Act render(<SegmentList {...defaultProps} items={items} />) - // Assert expect(screen.getAllByTestId('segment-card')).toHaveLength(3) }) it('should maintain structure when rerendered with different items', () => { - // Arrange const { rerender } = render( <SegmentList {...defaultProps} items={[createMockSegment('seg-1', 'Content 1')]} />, ) - // Act rerender( <SegmentList {...defaultProps} @@ -320,7 +277,6 @@ describe('SegmentList', () => { />, ) - // Assert expect(screen.getAllByTestId('segment-card')).toHaveLength(2) }) }) @@ -328,7 +284,6 @@ describe('SegmentList', () => { // Checkbox Selection describe('Checkbox Selection', () => { it('should render checkbox for each segment', () => { - // Arrange & Act const { container } = render(<SegmentList {...defaultProps} />) // Assert - Checkbox component should exist @@ -337,7 +292,6 @@ describe('SegmentList', () => { }) it('should pass selectedSegmentIds to check state', () => { - // Arrange & Act const { container } = render(<SegmentList {...defaultProps} selectedSegmentIds={['seg-1']} />) // Assert - component should render with selected state @@ -345,7 +299,6 @@ describe('SegmentList', () => { }) it('should handle empty selectedSegmentIds', () => { - // Arrange & Act const { container } = render(<SegmentList {...defaultProps} selectedSegmentIds={[]} />) // Assert - component should render @@ -356,83 +309,63 @@ describe('SegmentList', () => { // Card Actions describe('Card Actions', () => { it('should call onClick when card is clicked', () => { - // Arrange const mockOnClick = vi.fn() render(<SegmentList {...defaultProps} onClick={mockOnClick} />) - // Act fireEvent.click(screen.getByTestId('card-click')) - // Assert expect(mockOnClick).toHaveBeenCalled() }) it('should call onChangeSwitch when switch button is clicked', async () => { - // Arrange const mockOnChangeSwitch = vi.fn().mockResolvedValue(undefined) render(<SegmentList {...defaultProps} onChangeSwitch={mockOnChangeSwitch} />) - // Act fireEvent.click(screen.getByTestId('switch-btn')) - // Assert expect(mockOnChangeSwitch).toHaveBeenCalledWith(true, 'seg-1') }) it('should call onDelete when delete button is clicked', async () => { - // Arrange const mockOnDelete = vi.fn().mockResolvedValue(undefined) render(<SegmentList {...defaultProps} onDelete={mockOnDelete} />) - // Act fireEvent.click(screen.getByTestId('delete-btn')) - // Assert expect(mockOnDelete).toHaveBeenCalledWith('seg-1') }) it('should call onDeleteChildChunk when delete child button is clicked', async () => { - // Arrange const mockOnDeleteChildChunk = vi.fn().mockResolvedValue(undefined) render(<SegmentList {...defaultProps} onDeleteChildChunk={mockOnDeleteChildChunk} />) - // Act fireEvent.click(screen.getByTestId('delete-child-btn')) - // Assert expect(mockOnDeleteChildChunk).toHaveBeenCalledWith('seg-1', 'child-1') }) it('should call handleAddNewChildChunk when add child button is clicked', () => { - // Arrange const mockHandleAddNewChildChunk = vi.fn() render(<SegmentList {...defaultProps} handleAddNewChildChunk={mockHandleAddNewChildChunk} />) - // Act fireEvent.click(screen.getByTestId('add-child-btn')) - // Assert expect(mockHandleAddNewChildChunk).toHaveBeenCalledWith('seg-1') }) it('should call onClickSlice when click slice button is clicked', () => { - // Arrange const mockOnClickSlice = vi.fn() render(<SegmentList {...defaultProps} onClickSlice={mockOnClickSlice} />) - // Act fireEvent.click(screen.getByTestId('click-slice-btn')) - // Assert expect(mockOnClickSlice).toHaveBeenCalledWith({ id: 'slice-1' }) }) it('should call onClick with edit mode when edit button is clicked', () => { - // Arrange const mockOnClick = vi.fn() render(<SegmentList {...defaultProps} onClick={mockOnClick} />) - // Act fireEvent.click(screen.getByTestId('edit-btn')) // Assert - onClick is called from onClickEdit with isEditMode=true diff --git a/web/app/components/datasets/documents/detail/completed/status-item.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/status-item.spec.tsx similarity index 86% rename from web/app/components/datasets/documents/detail/completed/status-item.spec.tsx rename to web/app/components/datasets/documents/detail/completed/__tests__/status-item.spec.tsx index a9114ffe79..da7e301e4d 100644 --- a/web/app/components/datasets/documents/detail/completed/status-item.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/status-item.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import StatusItem from './status-item' +import StatusItem from '../status-item' describe('StatusItem', () => { const defaultItem = { @@ -8,29 +8,22 @@ describe('StatusItem', () => { name: 'Test Status', } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<StatusItem item={defaultItem} selected={false} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render item name', () => { - // Arrange & Act render(<StatusItem item={defaultItem} selected={false} />) - // Assert expect(screen.getByText('Test Status')).toBeInTheDocument() }) it('should render with correct styling classes', () => { - // Arrange & Act const { container } = render(<StatusItem item={defaultItem} selected={false} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('items-center') @@ -38,10 +31,8 @@ describe('StatusItem', () => { }) }) - // Props tests describe('Props', () => { it('should show check icon when selected is true', () => { - // Arrange & Act const { container } = render(<StatusItem item={defaultItem} selected={true} />) // Assert - RiCheckLine icon should be present @@ -50,7 +41,6 @@ describe('StatusItem', () => { }) it('should not show check icon when selected is false', () => { - // Arrange & Act const { container } = render(<StatusItem item={defaultItem} selected={false} />) // Assert - RiCheckLine icon should not be present @@ -59,59 +49,44 @@ describe('StatusItem', () => { }) it('should render different item names', () => { - // Arrange & Act const item = { value: '2', name: 'Different Status' } render(<StatusItem item={item} selected={false} />) - // Assert expect(screen.getByText('Different Status')).toBeInTheDocument() }) }) - // Memoization tests describe('Memoization', () => { it('should render consistently with same props', () => { - // Arrange & Act const { container: container1 } = render(<StatusItem item={defaultItem} selected={true} />) const { container: container2 } = render(<StatusItem item={defaultItem} selected={true} />) - // Assert expect(container1.textContent).toBe(container2.textContent) }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty item name', () => { - // Arrange const emptyItem = { value: '1', name: '' } - // Act const { container } = render(<StatusItem item={emptyItem} selected={false} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should handle special characters in item name', () => { - // Arrange const specialItem = { value: '1', name: 'Status <>&"' } - // Act render(<StatusItem item={specialItem} selected={false} />) - // Assert expect(screen.getByText('Status <>&"')).toBeInTheDocument() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<StatusItem item={defaultItem} selected={false} />) - // Act rerender(<StatusItem item={defaultItem} selected={true} />) - // Assert expect(screen.getByText('Test Status')).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/action-buttons.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx similarity index 93% rename from web/app/components/datasets/documents/detail/completed/common/action-buttons.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx index a2fd94ee31..edf4b30922 100644 --- a/web/app/components/datasets/documents/detail/completed/common/action-buttons.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/action-buttons.spec.tsx @@ -1,10 +1,11 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import { DocumentContext } from '../../context' -import ActionButtons from './action-buttons' +import { DocumentContext } from '../../../context' +import ActionButtons from '../action-buttons' -// Mock useKeyPress from ahooks to capture and test callback functions +// Mock useKeyPress: required because tests capture registered callbacks +// via mockUseKeyPress to verify ESC and Ctrl+S keyboard shortcut behavior. const mockUseKeyPress = vi.fn() vi.mock('ahooks', () => ({ useKeyPress: (keys: string | string[], callback: (e: KeyboardEvent) => void, options?: object) => { @@ -51,10 +52,8 @@ describe('ActionButtons', () => { mockUseKeyPress.mockClear() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render( <ActionButtons handleCancel={vi.fn()} @@ -64,12 +63,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render cancel button', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -79,12 +76,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Assert expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() }) it('should render save button', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -94,12 +89,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Assert expect(screen.getByText(/operation\.save/i)).toBeInTheDocument() }) it('should render ESC keyboard hint on cancel button', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -109,12 +102,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Assert expect(screen.getByText('ESC')).toBeInTheDocument() }) it('should render S keyboard hint on save button', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -124,15 +115,12 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Assert expect(screen.getByText('S')).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call handleCancel when cancel button is clicked', () => { - // Arrange const mockHandleCancel = vi.fn() render( <ActionButtons @@ -143,16 +131,13 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Act const cancelButton = screen.getAllByRole('button')[0] fireEvent.click(cancelButton) - // Assert expect(mockHandleCancel).toHaveBeenCalledTimes(1) }) it('should call handleSave when save button is clicked', () => { - // Arrange const mockHandleSave = vi.fn() render( <ActionButtons @@ -163,17 +148,14 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Act const buttons = screen.getAllByRole('button') const saveButton = buttons[buttons.length - 1] // Save button is last fireEvent.click(saveButton) - // Assert expect(mockHandleSave).toHaveBeenCalledTimes(1) }) it('should disable save button when loading is true', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -183,7 +165,6 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Assert const buttons = screen.getAllByRole('button') const saveButton = buttons[buttons.length - 1] expect(saveButton).toBeDisabled() @@ -193,7 +174,6 @@ describe('ActionButtons', () => { // Regeneration button tests describe('Regeneration Button', () => { it('should show regeneration button in parent-child paragraph mode for edit action', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -207,12 +187,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) }, ) - // Assert expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument() }) it('should not show regeneration button when isChildChunk is true', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -226,12 +204,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) }, ) - // Assert expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument() }) it('should not show regeneration button when showRegenerationButton is false', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -245,12 +221,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) }, ) - // Assert expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument() }) it('should not show regeneration button when actionType is add', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -264,12 +238,10 @@ describe('ActionButtons', () => { { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) }, ) - // Assert expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument() }) it('should call handleRegeneration when regeneration button is clicked', () => { - // Arrange const mockHandleRegeneration = vi.fn() render( <ActionButtons @@ -284,17 +256,14 @@ describe('ActionButtons', () => { { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) }, ) - // Act const regenerationButton = screen.getByText(/operation\.saveAndRegenerate/i).closest('button') if (regenerationButton) fireEvent.click(regenerationButton) - // Assert expect(mockHandleRegeneration).toHaveBeenCalledTimes(1) }) it('should disable regeneration button when loading is true', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -308,7 +277,6 @@ describe('ActionButtons', () => { { wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) }, ) - // Assert const regenerationButton = screen.getByText(/operation\.saveAndRegenerate/i).closest('button') expect(regenerationButton).toBeDisabled() }) @@ -370,7 +338,6 @@ describe('ActionButtons', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should handle missing context values gracefully', () => { // Arrange & Act & Assert - should not throw @@ -387,7 +354,6 @@ describe('ActionButtons', () => { }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render( <ActionButtons handleCancel={vi.fn()} @@ -397,7 +363,6 @@ describe('ActionButtons', () => { { wrapper: createWrapper({}) }, ) - // Act rerender( <DocumentContext.Provider value={{}}> <ActionButtons @@ -408,7 +373,6 @@ describe('ActionButtons', () => { </DocumentContext.Provider>, ) - // Assert expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() expect(screen.getByText(/operation\.save/i)).toBeInTheDocument() }) @@ -417,7 +381,6 @@ describe('ActionButtons', () => { // Keyboard shortcuts tests via useKeyPress callbacks describe('Keyboard Shortcuts', () => { it('should display ctrl key hint on save button', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} @@ -433,7 +396,6 @@ describe('ActionButtons', () => { }) it('should call handleCancel and preventDefault when ESC key is pressed', () => { - // Arrange const mockHandleCancel = vi.fn() const mockPreventDefault = vi.fn() render( @@ -450,13 +412,11 @@ describe('ActionButtons', () => { expect(escCallback).toBeDefined() escCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent) - // Assert expect(mockPreventDefault).toHaveBeenCalledTimes(1) expect(mockHandleCancel).toHaveBeenCalledTimes(1) }) it('should call handleSave and preventDefault when Ctrl+S is pressed and not loading', () => { - // Arrange const mockHandleSave = vi.fn() const mockPreventDefault = vi.fn() render( @@ -473,13 +433,11 @@ describe('ActionButtons', () => { expect(ctrlSCallback).toBeDefined() ctrlSCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent) - // Assert expect(mockPreventDefault).toHaveBeenCalledTimes(1) expect(mockHandleSave).toHaveBeenCalledTimes(1) }) it('should not call handleSave when Ctrl+S is pressed while loading', () => { - // Arrange const mockHandleSave = vi.fn() const mockPreventDefault = vi.fn() render( @@ -496,13 +454,11 @@ describe('ActionButtons', () => { expect(ctrlSCallback).toBeDefined() ctrlSCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent) - // Assert expect(mockPreventDefault).toHaveBeenCalledTimes(1) expect(mockHandleSave).not.toHaveBeenCalled() }) it('should register useKeyPress with correct options for Ctrl+S', () => { - // Arrange & Act render( <ActionButtons handleCancel={vi.fn()} diff --git a/web/app/components/datasets/documents/detail/completed/common/add-another.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/add-another.spec.tsx similarity index 89% rename from web/app/components/datasets/documents/detail/completed/common/add-another.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/add-another.spec.tsx index 6f76fb4f79..852119b854 100644 --- a/web/app/components/datasets/documents/detail/completed/common/add-another.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/add-another.spec.tsx @@ -1,26 +1,22 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import AddAnother from './add-another' +import AddAnother from '../add-another' describe('AddAnother', () => { beforeEach(() => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={false} onCheck={vi.fn()} />, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the checkbox', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={false} onCheck={vi.fn()} />, ) @@ -31,7 +27,6 @@ describe('AddAnother', () => { }) it('should render the add another text', () => { - // Arrange & Act render(<AddAnother isChecked={false} onCheck={vi.fn()} />) // Assert - i18n key format @@ -39,12 +34,10 @@ describe('AddAnother', () => { }) it('should render with correct base styling classes', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={false} onCheck={vi.fn()} />, ) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('items-center') @@ -53,10 +46,8 @@ describe('AddAnother', () => { }) }) - // Props tests describe('Props', () => { it('should render unchecked state when isChecked is false', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={false} onCheck={vi.fn()} />, ) @@ -67,7 +58,6 @@ describe('AddAnother', () => { }) it('should render checked state when isChecked is true', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={true} onCheck={vi.fn()} />, ) @@ -78,7 +68,6 @@ describe('AddAnother', () => { }) it('should apply custom className', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={false} @@ -87,16 +76,13 @@ describe('AddAnother', () => { />, ) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-class') }) }) - // User Interactions describe('User Interactions', () => { it('should call onCheck when checkbox is clicked', () => { - // Arrange const mockOnCheck = vi.fn() const { container } = render( <AddAnother isChecked={false} onCheck={mockOnCheck} />, @@ -107,12 +93,10 @@ describe('AddAnother', () => { if (checkbox) fireEvent.click(checkbox) - // Assert expect(mockOnCheck).toHaveBeenCalledTimes(1) }) it('should toggle checked state on multiple clicks', () => { - // Arrange const mockOnCheck = vi.fn() const { container, rerender } = render( <AddAnother isChecked={false} onCheck={mockOnCheck} />, @@ -126,68 +110,55 @@ describe('AddAnother', () => { fireEvent.click(checkbox) } - // Assert expect(mockOnCheck).toHaveBeenCalledTimes(2) }) }) - // Structure tests describe('Structure', () => { it('should render text with tertiary text color', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={false} onCheck={vi.fn()} />, ) - // Assert const textElement = container.querySelector('.text-text-tertiary') expect(textElement).toBeInTheDocument() }) it('should render text with xs medium font styling', () => { - // Arrange & Act const { container } = render( <AddAnother isChecked={false} onCheck={vi.fn()} />, ) - // Assert const textElement = container.querySelector('.system-xs-medium') expect(textElement).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rerendered', () => { - // Arrange const mockOnCheck = vi.fn() const { rerender, container } = render( <AddAnother isChecked={false} onCheck={mockOnCheck} />, ) - // Act rerender(<AddAnother isChecked={true} onCheck={mockOnCheck} />) - // Assert const checkbox = container.querySelector('.shrink-0') expect(checkbox).toBeInTheDocument() }) it('should handle rapid state changes', () => { - // Arrange const mockOnCheck = vi.fn() const { container } = render( <AddAnother isChecked={false} onCheck={mockOnCheck} />, ) - // Act const checkbox = container.querySelector('.shrink-0') if (checkbox) { for (let i = 0; i < 5; i++) fireEvent.click(checkbox) } - // Assert expect(mockOnCheck).toHaveBeenCalledTimes(5) }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/batch-action.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/batch-action.spec.tsx similarity index 86% rename from web/app/components/datasets/documents/detail/completed/common/batch-action.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/batch-action.spec.tsx index 0c0190ed5d..eda7d3845c 100644 --- a/web/app/components/datasets/documents/detail/completed/common/batch-action.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/batch-action.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import BatchAction from './batch-action' +import BatchAction from '../batch-action' describe('BatchAction', () => { beforeEach(() => { @@ -15,100 +15,75 @@ describe('BatchAction', () => { onCancel: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<BatchAction {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should display selected count', () => { - // Arrange & Act render(<BatchAction {...defaultProps} />) - // Assert expect(screen.getByText('3')).toBeInTheDocument() }) it('should render enable button', () => { - // Arrange & Act render(<BatchAction {...defaultProps} />) - // Assert expect(screen.getByText(/batchAction\.enable/i)).toBeInTheDocument() }) it('should render disable button', () => { - // Arrange & Act render(<BatchAction {...defaultProps} />) - // Assert expect(screen.getByText(/batchAction\.disable/i)).toBeInTheDocument() }) it('should render delete button', () => { - // Arrange & Act render(<BatchAction {...defaultProps} />) - // Assert expect(screen.getByText(/batchAction\.delete/i)).toBeInTheDocument() }) it('should render cancel button', () => { - // Arrange & Act render(<BatchAction {...defaultProps} />) - // Assert expect(screen.getByText(/batchAction\.cancel/i)).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call onBatchEnable when enable button is clicked', () => { - // Arrange const mockOnBatchEnable = vi.fn() render(<BatchAction {...defaultProps} onBatchEnable={mockOnBatchEnable} />) - // Act fireEvent.click(screen.getByText(/batchAction\.enable/i)) - // Assert expect(mockOnBatchEnable).toHaveBeenCalledTimes(1) }) it('should call onBatchDisable when disable button is clicked', () => { - // Arrange const mockOnBatchDisable = vi.fn() render(<BatchAction {...defaultProps} onBatchDisable={mockOnBatchDisable} />) - // Act fireEvent.click(screen.getByText(/batchAction\.disable/i)) - // Assert expect(mockOnBatchDisable).toHaveBeenCalledTimes(1) }) it('should call onCancel when cancel button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() render(<BatchAction {...defaultProps} onCancel={mockOnCancel} />) - // Act fireEvent.click(screen.getByText(/batchAction\.cancel/i)) - // Assert expect(mockOnCancel).toHaveBeenCalledTimes(1) }) it('should show delete confirmation dialog when delete button is clicked', () => { - // Arrange render(<BatchAction {...defaultProps} />) - // Act fireEvent.click(screen.getByText(/batchAction\.delete/i)) // Assert - Confirm dialog should appear @@ -116,7 +91,6 @@ describe('BatchAction', () => { }) it('should call onBatchDelete when confirm is clicked in delete dialog', async () => { - // Arrange const mockOnBatchDelete = vi.fn().mockResolvedValue(undefined) render(<BatchAction {...defaultProps} onBatchDelete={mockOnBatchDelete} />) @@ -127,7 +101,6 @@ describe('BatchAction', () => { const confirmButton = screen.getByText(/operation\.sure/i) fireEvent.click(confirmButton) - // Assert await waitFor(() => { expect(mockOnBatchDelete).toHaveBeenCalledTimes(1) }) @@ -137,98 +110,74 @@ describe('BatchAction', () => { // Optional props tests describe('Optional Props', () => { it('should render download button when onBatchDownload is provided', () => { - // Arrange & Act render(<BatchAction {...defaultProps} onBatchDownload={vi.fn()} />) - // Assert expect(screen.getByText(/batchAction\.download/i)).toBeInTheDocument() }) it('should not render download button when onBatchDownload is not provided', () => { - // Arrange & Act render(<BatchAction {...defaultProps} />) - // Assert expect(screen.queryByText(/batchAction\.download/i)).not.toBeInTheDocument() }) it('should render archive button when onArchive is provided', () => { - // Arrange & Act render(<BatchAction {...defaultProps} onArchive={vi.fn()} />) - // Assert expect(screen.getByText(/batchAction\.archive/i)).toBeInTheDocument() }) it('should render metadata button when onEditMetadata is provided', () => { - // Arrange & Act render(<BatchAction {...defaultProps} onEditMetadata={vi.fn()} />) - // Assert expect(screen.getByText(/metadata\.metadata/i)).toBeInTheDocument() }) it('should render re-index button when onBatchReIndex is provided', () => { - // Arrange & Act render(<BatchAction {...defaultProps} onBatchReIndex={vi.fn()} />) - // Assert expect(screen.getByText(/batchAction\.reIndex/i)).toBeInTheDocument() }) it('should call onBatchDownload when download button is clicked', () => { - // Arrange const mockOnBatchDownload = vi.fn() render(<BatchAction {...defaultProps} onBatchDownload={mockOnBatchDownload} />) - // Act fireEvent.click(screen.getByText(/batchAction\.download/i)) - // Assert expect(mockOnBatchDownload).toHaveBeenCalledTimes(1) }) it('should call onArchive when archive button is clicked', () => { - // Arrange const mockOnArchive = vi.fn() render(<BatchAction {...defaultProps} onArchive={mockOnArchive} />) - // Act fireEvent.click(screen.getByText(/batchAction\.archive/i)) - // Assert expect(mockOnArchive).toHaveBeenCalledTimes(1) }) it('should call onEditMetadata when metadata button is clicked', () => { - // Arrange const mockOnEditMetadata = vi.fn() render(<BatchAction {...defaultProps} onEditMetadata={mockOnEditMetadata} />) - // Act fireEvent.click(screen.getByText(/metadata\.metadata/i)) - // Assert expect(mockOnEditMetadata).toHaveBeenCalledTimes(1) }) it('should call onBatchReIndex when re-index button is clicked', () => { - // Arrange const mockOnBatchReIndex = vi.fn() render(<BatchAction {...defaultProps} onBatchReIndex={mockOnBatchReIndex} />) - // Act fireEvent.click(screen.getByText(/batchAction\.reIndex/i)) - // Assert expect(mockOnBatchReIndex).toHaveBeenCalledTimes(1) }) it('should apply custom className', () => { - // Arrange & Act const { container } = render(<BatchAction {...defaultProps} className="custom-class" />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-class') }) @@ -237,40 +186,30 @@ describe('BatchAction', () => { // Selected count display tests describe('Selected Count', () => { it('should display correct count for single selection', () => { - // Arrange & Act render(<BatchAction {...defaultProps} selectedIds={['1']} />) - // Assert expect(screen.getByText('1')).toBeInTheDocument() }) it('should display correct count for multiple selections', () => { - // Arrange & Act render(<BatchAction {...defaultProps} selectedIds={['1', '2', '3', '4', '5']} />) - // Assert expect(screen.getByText('5')).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<BatchAction {...defaultProps} />) - // Act rerender(<BatchAction {...defaultProps} selectedIds={['1', '2']} />) - // Assert expect(screen.getByText('2')).toBeInTheDocument() }) it('should handle empty selectedIds array', () => { - // Arrange & Act render(<BatchAction {...defaultProps} selectedIds={[]} />) - // Assert expect(screen.getByText('0')).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/chunk-content.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/chunk-content.spec.tsx similarity index 91% rename from web/app/components/datasets/documents/detail/completed/common/chunk-content.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/chunk-content.spec.tsx index 01c1be919c..115db9ad61 100644 --- a/web/app/components/datasets/documents/detail/completed/common/chunk-content.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/chunk-content.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { ChunkingMode } from '@/models/datasets' -import ChunkContent from './chunk-content' +import ChunkContent from '../chunk-content' // Mock ResizeObserver const OriginalResizeObserver = globalThis.ResizeObserver @@ -30,27 +30,21 @@ describe('ChunkContent', () => { docForm: ChunkingMode.text, } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<ChunkContent {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render textarea in edit mode with text docForm', () => { - // Arrange & Act render(<ChunkContent {...defaultProps} isEditMode={true} />) - // Assert const textarea = screen.getByRole('textbox') expect(textarea).toBeInTheDocument() }) it('should render Markdown content in view mode with text docForm', () => { - // Arrange & Act const { container } = render(<ChunkContent {...defaultProps} isEditMode={false} />) // Assert - In view mode, textarea should not be present, Markdown renders instead @@ -61,7 +55,6 @@ describe('ChunkContent', () => { // QA mode tests describe('QA Mode', () => { it('should render QA layout when docForm is qa', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -78,7 +71,6 @@ describe('ChunkContent', () => { }) it('should display question value in QA mode', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -90,13 +82,11 @@ describe('ChunkContent', () => { />, ) - // Assert const textareas = screen.getAllByRole('textbox') expect(textareas[0]).toHaveValue('My question') }) it('should display answer value in QA mode', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -108,16 +98,13 @@ describe('ChunkContent', () => { />, ) - // Assert const textareas = screen.getAllByRole('textbox') expect(textareas[1]).toHaveValue('My answer') }) }) - // User Interactions describe('User Interactions', () => { it('should call onQuestionChange when textarea value changes in text mode', () => { - // Arrange const mockOnQuestionChange = vi.fn() render( <ChunkContent @@ -127,16 +114,13 @@ describe('ChunkContent', () => { />, ) - // Act const textarea = screen.getByRole('textbox') fireEvent.change(textarea, { target: { value: 'New content' } }) - // Assert expect(mockOnQuestionChange).toHaveBeenCalledWith('New content') }) it('should call onQuestionChange when question textarea changes in QA mode', () => { - // Arrange const mockOnQuestionChange = vi.fn() render( <ChunkContent @@ -148,16 +132,13 @@ describe('ChunkContent', () => { />, ) - // Act const textareas = screen.getAllByRole('textbox') fireEvent.change(textareas[0], { target: { value: 'New question' } }) - // Assert expect(mockOnQuestionChange).toHaveBeenCalledWith('New question') }) it('should call onAnswerChange when answer textarea changes in QA mode', () => { - // Arrange const mockOnAnswerChange = vi.fn() render( <ChunkContent @@ -169,16 +150,13 @@ describe('ChunkContent', () => { />, ) - // Act const textareas = screen.getAllByRole('textbox') fireEvent.change(textareas[1], { target: { value: 'New answer' } }) - // Assert expect(mockOnAnswerChange).toHaveBeenCalledWith('New answer') }) it('should disable textarea when isEditMode is false in text mode', () => { - // Arrange & Act const { container } = render( <ChunkContent {...defaultProps} isEditMode={false} />, ) @@ -188,7 +166,6 @@ describe('ChunkContent', () => { }) it('should disable textareas when isEditMode is false in QA mode', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -199,7 +176,6 @@ describe('ChunkContent', () => { />, ) - // Assert const textareas = screen.getAllByRole('textbox') textareas.forEach((textarea) => { expect(textarea).toBeDisabled() @@ -210,15 +186,12 @@ describe('ChunkContent', () => { // DocForm variations describe('DocForm Variations', () => { it('should handle ChunkingMode.text', () => { - // Arrange & Act render(<ChunkContent {...defaultProps} docForm={ChunkingMode.text} isEditMode={true} />) - // Assert expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should handle ChunkingMode.qa', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -235,7 +208,6 @@ describe('ChunkContent', () => { }) it('should handle ChunkingMode.parentChild similar to text mode', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -249,10 +221,8 @@ describe('ChunkContent', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty question', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -261,13 +231,11 @@ describe('ChunkContent', () => { />, ) - // Assert const textarea = screen.getByRole('textbox') expect(textarea).toHaveValue('') }) it('should handle empty answer in QA mode', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -279,13 +247,11 @@ describe('ChunkContent', () => { />, ) - // Assert const textareas = screen.getAllByRole('textbox') expect(textareas[1]).toHaveValue('') }) it('should handle undefined answer in QA mode', () => { - // Arrange & Act render( <ChunkContent {...defaultProps} @@ -299,17 +265,14 @@ describe('ChunkContent', () => { }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render( <ChunkContent {...defaultProps} question="Initial" isEditMode={true} />, ) - // Act rerender( <ChunkContent {...defaultProps} question="Updated" isEditMode={true} />, ) - // Assert const textarea = screen.getByRole('textbox') expect(textarea).toHaveValue('Updated') }) diff --git a/web/app/components/datasets/documents/detail/completed/common/dot.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/dot.spec.tsx similarity index 82% rename from web/app/components/datasets/documents/detail/completed/common/dot.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/dot.spec.tsx index af8c981bf5..2b8b43fae9 100644 --- a/web/app/components/datasets/documents/detail/completed/common/dot.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/dot.spec.tsx @@ -1,59 +1,45 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import Dot from './dot' +import Dot from '../dot' describe('Dot', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<Dot />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the dot character', () => { - // Arrange & Act render(<Dot />) - // Assert expect(screen.getByText('·')).toBeInTheDocument() }) it('should render with correct styling classes', () => { - // Arrange & Act const { container } = render(<Dot />) - // Assert const dotElement = container.firstChild as HTMLElement expect(dotElement).toHaveClass('system-xs-medium') expect(dotElement).toHaveClass('text-text-quaternary') }) }) - // Memoization tests describe('Memoization', () => { it('should render consistently across multiple renders', () => { - // Arrange & Act const { container: container1 } = render(<Dot />) const { container: container2 } = render(<Dot />) - // Assert expect(container1.firstChild?.textContent).toBe(container2.firstChild?.textContent) }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<Dot />) - // Act rerender(<Dot />) - // Assert expect(screen.getByText('·')).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx new file mode 100644 index 0000000000..d9a87ea3e4 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/drawer.spec.tsx @@ -0,0 +1,135 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Drawer from '../drawer' + +let capturedKeyPressCallback: ((e: KeyboardEvent) => void) | undefined + +// Mock useKeyPress: required because tests capture the registered callback +// and invoke it directly to verify ESC key handling behavior. +vi.mock('ahooks', () => ({ + useKeyPress: vi.fn((_key: string, cb: (e: KeyboardEvent) => void) => { + capturedKeyPressCallback = cb + }), +})) + +vi.mock('../..', () => ({ + useSegmentListContext: (selector: (state: { + currSegment: { showModal: boolean } + currChildChunk: { showModal: boolean } + }) => unknown) => + selector({ + currSegment: { showModal: false }, + currChildChunk: { showModal: false }, + }), +})) + +describe('Drawer', () => { + const defaultProps = { + open: true, + onClose: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + capturedKeyPressCallback = undefined + }) + + describe('Rendering', () => { + it('should return null when open is false', () => { + const { container } = render( + <Drawer open={false} onClose={vi.fn()}> + <span>Content</span> + </Drawer>, + ) + + expect(container.innerHTML).toBe('') + expect(screen.queryByText('Content')).not.toBeInTheDocument() + }) + + it('should render children in portal when open is true', () => { + render( + <Drawer {...defaultProps}> + <span>Drawer content</span> + </Drawer>, + ) + + expect(screen.getByText('Drawer content')).toBeInTheDocument() + }) + + it('should render dialog with role="dialog"', () => { + render( + <Drawer {...defaultProps}> + <span>Content</span> + </Drawer>, + ) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + }) + + // Overlay visibility + describe('Overlay', () => { + it('should show overlay when showOverlay is true', () => { + render( + <Drawer {...defaultProps} showOverlay={true}> + <span>Content</span> + </Drawer>, + ) + + const overlay = document.querySelector('[aria-hidden="true"]') + expect(overlay).toBeInTheDocument() + }) + + it('should hide overlay when showOverlay is false', () => { + render( + <Drawer {...defaultProps} showOverlay={false}> + <span>Content</span> + </Drawer>, + ) + + const overlay = document.querySelector('[aria-hidden="true"]') + expect(overlay).not.toBeInTheDocument() + }) + }) + + // aria-modal attribute + describe('aria-modal', () => { + it('should set aria-modal="true" when modal is true', () => { + render( + <Drawer {...defaultProps} modal={true}> + <span>Content</span> + </Drawer>, + ) + + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true') + }) + + it('should set aria-modal="false" when modal is false', () => { + render( + <Drawer {...defaultProps} modal={false}> + <span>Content</span> + </Drawer>, + ) + + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'false') + }) + }) + + // ESC key handling + describe('ESC Key', () => { + it('should call onClose when ESC is pressed and drawer is open', () => { + const onClose = vi.fn() + render( + <Drawer open={true} onClose={onClose}> + <span>Content</span> + </Drawer>, + ) + + expect(capturedKeyPressCallback).toBeDefined() + const fakeEvent = { preventDefault: vi.fn() } as unknown as KeyboardEvent + capturedKeyPressCallback!(fakeEvent) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/common/empty.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/empty.spec.tsx similarity index 86% rename from web/app/components/datasets/documents/detail/completed/common/empty.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/empty.spec.tsx index 6feb9ea4c0..f957789926 100644 --- a/web/app/components/datasets/documents/detail/completed/common/empty.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/empty.spec.tsx @@ -1,24 +1,20 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Empty from './empty' +import Empty from '../empty' describe('Empty', () => { beforeEach(() => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<Empty onClearFilter={vi.fn()} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the file list icon', () => { - // Arrange & Act const { container } = render(<Empty onClearFilter={vi.fn()} />) // Assert - RiFileList2Line icon should be rendered @@ -27,7 +23,6 @@ describe('Empty', () => { }) it('should render empty message text', () => { - // Arrange & Act render(<Empty onClearFilter={vi.fn()} />) // Assert - i18n key format: datasetDocuments:segment.empty @@ -35,15 +30,12 @@ describe('Empty', () => { }) it('should render clear filter button', () => { - // Arrange & Act render(<Empty onClearFilter={vi.fn()} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should render background empty cards', () => { - // Arrange & Act const { container } = render(<Empty onClearFilter={vi.fn()} />) // Assert - should have 10 background cards @@ -52,25 +44,19 @@ describe('Empty', () => { }) }) - // User Interactions describe('User Interactions', () => { it('should call onClearFilter when clear filter button is clicked', () => { - // Arrange const mockOnClearFilter = vi.fn() render(<Empty onClearFilter={mockOnClearFilter} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnClearFilter).toHaveBeenCalledTimes(1) }) }) - // Structure tests describe('Structure', () => { it('should render the decorative lines', () => { - // Arrange & Act const { container } = render(<Empty onClearFilter={vi.fn()} />) // Assert - there should be 4 Line components (SVG elements) @@ -79,73 +65,56 @@ describe('Empty', () => { }) it('should render mask overlay', () => { - // Arrange & Act const { container } = render(<Empty onClearFilter={vi.fn()} />) - // Assert const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg') expect(maskElement).toBeInTheDocument() }) it('should render icon container with proper styling', () => { - // Arrange & Act const { container } = render(<Empty onClearFilter={vi.fn()} />) - // Assert const iconContainer = container.querySelector('.shadow-lg') expect(iconContainer).toBeInTheDocument() }) it('should render clear filter button with accent text styling', () => { - // Arrange & Act render(<Empty onClearFilter={vi.fn()} />) - // Assert const button = screen.getByRole('button') expect(button).toHaveClass('text-text-accent') }) }) - // Props tests describe('Props', () => { it('should accept onClearFilter callback prop', () => { - // Arrange const mockCallback = vi.fn() - // Act render(<Empty onClearFilter={mockCallback} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockCallback).toHaveBeenCalled() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle multiple clicks on clear filter button', () => { - // Arrange const mockOnClearFilter = vi.fn() render(<Empty onClearFilter={mockOnClearFilter} />) - // Act const button = screen.getByRole('button') fireEvent.click(button) fireEvent.click(button) fireEvent.click(button) - // Assert expect(mockOnClearFilter).toHaveBeenCalledTimes(3) }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender, container } = render(<Empty onClearFilter={vi.fn()} />) - // Act rerender(<Empty onClearFilter={vi.fn()} />) - // Assert const emptyCards = container.querySelectorAll('.bg-background-section-burn') expect(emptyCards).toHaveLength(10) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx index 24def69f7a..ae870c8e1c 100644 --- a/web/app/components/datasets/documents/detail/completed/common/full-screen-drawer.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/full-screen-drawer.spec.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from 'react' import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import FullScreenDrawer from './full-screen-drawer' +import FullScreenDrawer from '../full-screen-drawer' // Mock the Drawer component since it has high complexity -vi.mock('./drawer', () => ({ +vi.mock('../drawer', () => ({ default: ({ children, open, panelClassName, panelContentClassName, showOverlay, needCheckChunks, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, showOverlay: boolean, needCheckChunks: boolean, modal: boolean }) => { if (!open) return null @@ -28,147 +28,123 @@ describe('FullScreenDrawer', () => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing when open', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert expect(screen.getByTestId('drawer-mock')).toBeInTheDocument() }) it('should not render when closed', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={false} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument() }) it('should render children content', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Test Content</div> </FullScreenDrawer>, ) - // Assert expect(screen.getByText('Test Content')).toBeInTheDocument() }) }) - // Props tests describe('Props', () => { it('should pass fullScreen=true to Drawer with full width class', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={true}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-panel-class')).toContain('w-full') }) it('should pass fullScreen=false to Drawer with fixed width class', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-panel-class')).toContain('w-[568px]') }) it('should pass showOverlay prop with default true', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-show-overlay')).toBe('true') }) it('should pass showOverlay=false when specified', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false} showOverlay={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-show-overlay')).toBe('false') }) it('should pass needCheckChunks prop with default false', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-need-check-chunks')).toBe('false') }) it('should pass needCheckChunks=true when specified', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false} needCheckChunks={true}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-need-check-chunks')).toBe('true') }) it('should pass modal prop with default false', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-modal')).toBe('false') }) it('should pass modal=true when specified', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false} modal={true}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') expect(drawer.getAttribute('data-modal')).toBe('true') }) @@ -177,14 +153,12 @@ describe('FullScreenDrawer', () => { // Styling tests describe('Styling', () => { it('should apply panel content classes for non-fullScreen mode', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') const contentClass = drawer.getAttribute('data-panel-content-class') expect(contentClass).toContain('bg-components-panel-bg') @@ -192,14 +166,12 @@ describe('FullScreenDrawer', () => { }) it('should apply panel content classes without border for fullScreen mode', () => { - // Arrange & Act render( <FullScreenDrawer isOpen={true} fullScreen={true}> <div>Content</div> </FullScreenDrawer>, ) - // Assert const drawer = screen.getByTestId('drawer-mock') const contentClass = drawer.getAttribute('data-panel-content-class') expect(contentClass).toContain('bg-components-panel-bg') @@ -207,7 +179,6 @@ describe('FullScreenDrawer', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should handle undefined onClose gracefully', () => { // Arrange & Act & Assert - should not throw @@ -221,26 +192,22 @@ describe('FullScreenDrawer', () => { }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Act rerender( <FullScreenDrawer isOpen={true} fullScreen={true}> <div>Updated Content</div> </FullScreenDrawer>, ) - // Assert expect(screen.getByText('Updated Content')).toBeInTheDocument() }) it('should handle toggle between open and closed states', () => { - // Arrange const { rerender } = render( <FullScreenDrawer isOpen={true} fullScreen={false}> <div>Content</div> @@ -248,14 +215,12 @@ describe('FullScreenDrawer', () => { ) expect(screen.getByTestId('drawer-mock')).toBeInTheDocument() - // Act rerender( <FullScreenDrawer isOpen={false} fullScreen={false}> <div>Content</div> </FullScreenDrawer>, ) - // Assert expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/keywords.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/keywords.spec.tsx similarity index 91% rename from web/app/components/datasets/documents/detail/completed/common/keywords.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/keywords.spec.tsx index a11f98e3bb..32165e3278 100644 --- a/web/app/components/datasets/documents/detail/completed/common/keywords.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/keywords.spec.tsx @@ -1,16 +1,14 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Keywords from './keywords' +import Keywords from '../keywords' describe('Keywords', () => { beforeEach(() => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render( <Keywords keywords={['test']} @@ -18,12 +16,10 @@ describe('Keywords', () => { />, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the keywords label', () => { - // Arrange & Act render( <Keywords keywords={['test']} @@ -36,7 +32,6 @@ describe('Keywords', () => { }) it('should render with correct container classes', () => { - // Arrange & Act const { container } = render( <Keywords keywords={['test']} @@ -44,17 +39,14 @@ describe('Keywords', () => { />, ) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('flex-col') }) }) - // Props tests describe('Props', () => { it('should display dash when no keywords and actionType is view', () => { - // Arrange & Act render( <Keywords segInfo={{ id: '1', keywords: [] }} @@ -64,12 +56,10 @@ describe('Keywords', () => { />, ) - // Assert expect(screen.getByText('-')).toBeInTheDocument() }) it('should not display dash when actionType is edit', () => { - // Arrange & Act render( <Keywords segInfo={{ id: '1', keywords: [] }} @@ -79,12 +69,10 @@ describe('Keywords', () => { />, ) - // Assert expect(screen.queryByText('-')).not.toBeInTheDocument() }) it('should not display dash when actionType is add', () => { - // Arrange & Act render( <Keywords segInfo={{ id: '1', keywords: [] }} @@ -94,12 +82,10 @@ describe('Keywords', () => { />, ) - // Assert expect(screen.queryByText('-')).not.toBeInTheDocument() }) it('should apply custom className', () => { - // Arrange & Act const { container } = render( <Keywords keywords={['test']} @@ -108,13 +94,11 @@ describe('Keywords', () => { />, ) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-class') }) it('should use default actionType of view', () => { - // Arrange & Act render( <Keywords segInfo={{ id: '1', keywords: [] }} @@ -128,10 +112,8 @@ describe('Keywords', () => { }) }) - // Structure tests describe('Structure', () => { it('should render label with uppercase styling', () => { - // Arrange & Act const { container } = render( <Keywords keywords={['test']} @@ -139,13 +121,11 @@ describe('Keywords', () => { />, ) - // Assert const labelElement = container.querySelector('.system-xs-medium-uppercase') expect(labelElement).toBeInTheDocument() }) it('should render keywords container with overflow handling', () => { - // Arrange & Act const { container } = render( <Keywords keywords={['test']} @@ -153,13 +133,11 @@ describe('Keywords', () => { />, ) - // Assert const keywordsContainer = container.querySelector('.overflow-auto') expect(keywordsContainer).toBeInTheDocument() }) it('should render keywords container with max height', () => { - // Arrange & Act const { container } = render( <Keywords keywords={['test']} @@ -167,7 +145,6 @@ describe('Keywords', () => { />, ) - // Assert const keywordsContainer = container.querySelector('.max-h-\\[200px\\]') expect(keywordsContainer).toBeInTheDocument() }) @@ -176,7 +153,6 @@ describe('Keywords', () => { // Edit mode tests describe('Edit Mode', () => { it('should render TagInput component when keywords exist', () => { - // Arrange & Act const { container } = render( <Keywords segInfo={{ id: '1', keywords: ['keyword1', 'keyword2'] }} @@ -192,10 +168,8 @@ describe('Keywords', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty keywords array in view mode without segInfo keywords', () => { - // Arrange & Act const { container } = render( <Keywords keywords={[]} @@ -209,7 +183,6 @@ describe('Keywords', () => { }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender, container } = render( <Keywords segInfo={{ id: '1', keywords: ['test'] }} @@ -218,7 +191,6 @@ describe('Keywords', () => { />, ) - // Act rerender( <Keywords segInfo={{ id: '1', keywords: ['test', 'new'] }} @@ -227,12 +199,10 @@ describe('Keywords', () => { />, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should handle segInfo with undefined keywords showing dash in view mode', () => { - // Arrange & Act render( <Keywords segInfo={{ id: '1' }} @@ -250,7 +220,6 @@ describe('Keywords', () => { // TagInput callback tests describe('TagInput Callback', () => { it('should call onKeywordsChange when keywords are modified', () => { - // Arrange const mockOnKeywordsChange = vi.fn() render( <Keywords @@ -267,7 +236,6 @@ describe('Keywords', () => { }) it('should disable add when isEditMode is false', () => { - // Arrange & Act const { container } = render( <Keywords segInfo={{ id: '1', keywords: ['test'] }} @@ -283,7 +251,6 @@ describe('Keywords', () => { }) it('should disable remove when only one keyword exists in edit mode', () => { - // Arrange & Act const { container } = render( <Keywords segInfo={{ id: '1', keywords: ['only-one'] }} @@ -299,7 +266,6 @@ describe('Keywords', () => { }) it('should allow remove when multiple keywords exist in edit mode', () => { - // Arrange & Act const { container } = render( <Keywords segInfo={{ id: '1', keywords: ['first', 'second'] }} diff --git a/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/regeneration-modal.spec.tsx similarity index 91% rename from web/app/components/datasets/documents/detail/completed/common/regeneration-modal.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/regeneration-modal.spec.tsx index bd46dfdd62..719e2867b7 100644 --- a/web/app/components/datasets/documents/detail/completed/common/regeneration-modal.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/regeneration-modal.spec.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { EventEmitterContextProvider, useEventEmitterContextContext } from '@/context/event-emitter' -import RegenerationModal from './regeneration-modal' +import RegenerationModal from '../regeneration-modal' // Store emit function for triggering events in tests let emitFunction: ((v: string) => void) | null = null @@ -44,18 +44,14 @@ describe('RegenerationModal', () => { onClose: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing when isShow is true', () => { - // Arrange & Act render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Assert expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument() }) it('should not render content when isShow is false', () => { - // Arrange & Act render(<RegenerationModal {...defaultProps} isShow={false} />, { wrapper: createWrapper() }) // Assert - Modal container might exist but content should not be visible @@ -63,53 +59,40 @@ describe('RegenerationModal', () => { }) it('should render confirmation message', () => { - // Arrange & Act render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Assert expect(screen.getByText(/segment\.regenerationConfirmMessage/i)).toBeInTheDocument() }) it('should render cancel button in default state', () => { - // Arrange & Act render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Assert expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() }) it('should render regenerate button in default state', () => { - // Arrange & Act render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Assert expect(screen.getByText(/operation\.regenerate/i)).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call onCancel when cancel button is clicked', () => { - // Arrange const mockOnCancel = vi.fn() render(<RegenerationModal {...defaultProps} onCancel={mockOnCancel} />, { wrapper: createWrapper() }) - // Act fireEvent.click(screen.getByText(/operation\.cancel/i)) - // Assert expect(mockOnCancel).toHaveBeenCalledTimes(1) }) it('should call onConfirm when regenerate button is clicked', () => { - // Arrange const mockOnConfirm = vi.fn() render(<RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} />, { wrapper: createWrapper() }) - // Act fireEvent.click(screen.getByText(/operation\.regenerate/i)) - // Assert expect(mockOnConfirm).toHaveBeenCalledTimes(1) }) }) @@ -117,45 +100,37 @@ describe('RegenerationModal', () => { // Modal content states - these would require event emitter manipulation describe('Modal States', () => { it('should show default content initially', () => { - // Arrange & Act render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Assert expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument() expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle toggling isShow prop', () => { - // Arrange const { rerender } = render( <RegenerationModal {...defaultProps} isShow={true} />, { wrapper: createWrapper() }, ) expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument() - // Act rerender( <TestWrapper> <RegenerationModal {...defaultProps} isShow={false} /> </TestWrapper>, ) - // Assert expect(screen.queryByText(/segment\.regenerationConfirmTitle/i)).not.toBeInTheDocument() }) it('should maintain handlers when rerendered', () => { - // Arrange const mockOnConfirm = vi.fn() const { rerender } = render( <RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} />, { wrapper: createWrapper() }, ) - // Act rerender( <TestWrapper> <RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} /> @@ -163,56 +138,45 @@ describe('RegenerationModal', () => { ) fireEvent.click(screen.getByText(/operation\.regenerate/i)) - // Assert expect(mockOnConfirm).toHaveBeenCalledTimes(1) }) }) - // Loading state describe('Loading State', () => { it('should show regenerating content when update-segment event is emitted', async () => { - // Arrange render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Act act(() => { if (emitFunction) emitFunction('update-segment') }) - // Assert await waitFor(() => { expect(screen.getByText(/segment\.regeneratingTitle/i)).toBeInTheDocument() }) }) it('should show regenerating message during loading', async () => { - // Arrange render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Act act(() => { if (emitFunction) emitFunction('update-segment') }) - // Assert await waitFor(() => { expect(screen.getByText(/segment\.regeneratingMessage/i)).toBeInTheDocument() }) }) it('should disable regenerate button during loading', async () => { - // Arrange render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Act act(() => { if (emitFunction) emitFunction('update-segment') }) - // Assert await waitFor(() => { const button = screen.getByText(/operation\.regenerate/i).closest('button') expect(button).toBeDisabled() @@ -223,7 +187,6 @@ describe('RegenerationModal', () => { // Success state describe('Success State', () => { it('should show success content when update-segment-success event is emitted followed by done', async () => { - // Arrange render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) // Act - trigger loading then success then done @@ -235,17 +198,14 @@ describe('RegenerationModal', () => { } }) - // Assert await waitFor(() => { expect(screen.getByText(/segment\.regenerationSuccessTitle/i)).toBeInTheDocument() }) }) it('should show success message when completed', async () => { - // Arrange render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Act act(() => { if (emitFunction) { emitFunction('update-segment') @@ -254,17 +214,14 @@ describe('RegenerationModal', () => { } }) - // Assert await waitFor(() => { expect(screen.getByText(/segment\.regenerationSuccessMessage/i)).toBeInTheDocument() }) }) it('should show close button with countdown in success state', async () => { - // Arrange render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) - // Act act(() => { if (emitFunction) { emitFunction('update-segment') @@ -273,18 +230,15 @@ describe('RegenerationModal', () => { } }) - // Assert await waitFor(() => { expect(screen.getByText(/operation\.close/i)).toBeInTheDocument() }) }) it('should call onClose when close button is clicked in success state', async () => { - // Arrange const mockOnClose = vi.fn() render(<RegenerationModal {...defaultProps} onClose={mockOnClose} />, { wrapper: createWrapper() }) - // Act act(() => { if (emitFunction) { emitFunction('update-segment') @@ -299,7 +253,6 @@ describe('RegenerationModal', () => { fireEvent.click(screen.getByText(/operation\.close/i)) - // Assert expect(mockOnClose).toHaveBeenCalled() }) }) @@ -307,7 +260,6 @@ describe('RegenerationModal', () => { // State transitions describe('State Transitions', () => { it('should return to default content when update fails (no success event)', async () => { - // Arrange render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() }) // Act - trigger loading then done without success diff --git a/web/app/components/datasets/documents/detail/completed/common/segment-index-tag.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/segment-index-tag.spec.tsx similarity index 85% rename from web/app/components/datasets/documents/detail/completed/common/segment-index-tag.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/segment-index-tag.spec.tsx index 8d0bf89636..4e73c86209 100644 --- a/web/app/components/datasets/documents/detail/completed/common/segment-index-tag.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/segment-index-tag.spec.tsx @@ -1,42 +1,33 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import SegmentIndexTag from './segment-index-tag' +import SegmentIndexTag from '../segment-index-tag' describe('SegmentIndexTag', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<SegmentIndexTag positionId={1} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the Chunk icon', () => { - // Arrange & Act const { container } = render(<SegmentIndexTag positionId={1} />) - // Assert const icon = container.querySelector('.h-3.w-3') expect(icon).toBeInTheDocument() }) it('should render with correct container classes', () => { - // Arrange & Act const { container } = render(<SegmentIndexTag positionId={1} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('items-center') }) }) - // Props tests describe('Props', () => { it('should render position ID with default prefix', () => { - // Arrange & Act render(<SegmentIndexTag positionId={5} />) // Assert - default prefix is 'Chunk' @@ -44,148 +35,116 @@ describe('SegmentIndexTag', () => { }) it('should render position ID without padding for two-digit numbers', () => { - // Arrange & Act render(<SegmentIndexTag positionId={15} />) - // Assert expect(screen.getByText('Chunk-15')).toBeInTheDocument() }) it('should render position ID without padding for three-digit numbers', () => { - // Arrange & Act render(<SegmentIndexTag positionId={123} />) - // Assert expect(screen.getByText('Chunk-123')).toBeInTheDocument() }) it('should render custom label when provided', () => { - // Arrange & Act render(<SegmentIndexTag positionId={1} label="Custom Label" />) - // Assert expect(screen.getByText('Custom Label')).toBeInTheDocument() }) it('should use custom labelPrefix', () => { - // Arrange & Act render(<SegmentIndexTag positionId={3} labelPrefix="Segment" />) - // Assert expect(screen.getByText('Segment-03')).toBeInTheDocument() }) it('should apply custom className', () => { - // Arrange & Act const { container } = render( <SegmentIndexTag positionId={1} className="custom-class" />, ) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('custom-class') }) it('should apply custom iconClassName', () => { - // Arrange & Act const { container } = render( <SegmentIndexTag positionId={1} iconClassName="custom-icon-class" />, ) - // Assert const icon = container.querySelector('.custom-icon-class') expect(icon).toBeInTheDocument() }) it('should apply custom labelClassName', () => { - // Arrange & Act const { container } = render( <SegmentIndexTag positionId={1} labelClassName="custom-label-class" />, ) - // Assert const label = container.querySelector('.custom-label-class') expect(label).toBeInTheDocument() }) it('should handle string positionId', () => { - // Arrange & Act render(<SegmentIndexTag positionId="7" />) - // Assert expect(screen.getByText('Chunk-07')).toBeInTheDocument() }) }) - // Memoization tests describe('Memoization', () => { it('should compute localPositionId based on positionId and labelPrefix', () => { - // Arrange & Act const { rerender } = render(<SegmentIndexTag positionId={1} />) expect(screen.getByText('Chunk-01')).toBeInTheDocument() // Act - change positionId rerender(<SegmentIndexTag positionId={2} />) - // Assert expect(screen.getByText('Chunk-02')).toBeInTheDocument() }) it('should update when labelPrefix changes', () => { - // Arrange & Act const { rerender } = render(<SegmentIndexTag positionId={1} labelPrefix="Chunk" />) expect(screen.getByText('Chunk-01')).toBeInTheDocument() // Act - change labelPrefix rerender(<SegmentIndexTag positionId={1} labelPrefix="Part" />) - // Assert expect(screen.getByText('Part-01')).toBeInTheDocument() }) }) - // Structure tests describe('Structure', () => { it('should render icon with tertiary text color', () => { - // Arrange & Act const { container } = render(<SegmentIndexTag positionId={1} />) - // Assert const icon = container.querySelector('.text-text-tertiary') expect(icon).toBeInTheDocument() }) it('should render label with xs medium font styling', () => { - // Arrange & Act const { container } = render(<SegmentIndexTag positionId={1} />) - // Assert const label = container.querySelector('.system-xs-medium') expect(label).toBeInTheDocument() }) it('should render icon with margin-right spacing', () => { - // Arrange & Act const { container } = render(<SegmentIndexTag positionId={1} />) - // Assert const icon = container.querySelector('.mr-0\\.5') expect(icon).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle positionId of 0', () => { - // Arrange & Act render(<SegmentIndexTag positionId={0} />) - // Assert expect(screen.getByText('Chunk-00')).toBeInTheDocument() }) it('should handle undefined positionId', () => { - // Arrange & Act render(<SegmentIndexTag />) // Assert - should display 'Chunk-undefined' or similar @@ -193,22 +152,17 @@ describe('SegmentIndexTag', () => { }) it('should prioritize label over computed positionId', () => { - // Arrange & Act render(<SegmentIndexTag positionId={99} label="Override" />) - // Assert expect(screen.getByText('Override')).toBeInTheDocument() expect(screen.queryByText('Chunk-99')).not.toBeInTheDocument() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender, container } = render(<SegmentIndexTag positionId={1} />) - // Act rerender(<SegmentIndexTag positionId={1} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-label.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-label.spec.tsx new file mode 100644 index 0000000000..0615b9790d --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-label.spec.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import SummaryLabel from '../summary-label' + +describe('SummaryLabel', () => { + it('should render summary heading', () => { + render(<SummaryLabel summary="This is a summary" />) + expect(screen.getByText('datasetDocuments.segment.summary')).toBeInTheDocument() + }) + + it('should render summary text', () => { + render(<SummaryLabel summary="This is a summary" />) + expect(screen.getByText('This is a summary')).toBeInTheDocument() + }) + + it('should render without summary text', () => { + render(<SummaryLabel />) + expect(screen.getByText('datasetDocuments.segment.summary')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-status.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-status.spec.tsx new file mode 100644 index 0000000000..76724f3480 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-status.spec.tsx @@ -0,0 +1,27 @@ +import type * as React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import SummaryStatus from '../summary-status' + +vi.mock('@/app/components/base/badge', () => ({ + default: ({ children }: { children: React.ReactNode }) => <span data-testid="badge">{children}</span>, +})) +vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({ + SearchLinesSparkle: (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="sparkle-icon" {...props} />, +})) +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, +})) + +describe('SummaryStatus', () => { + it('should render badge for SUMMARIZING status', () => { + render(<SummaryStatus status="SUMMARIZING" />) + expect(screen.getByTestId('badge')).toBeInTheDocument() + expect(screen.getByText('datasetDocuments.list.summary.generating')).toBeInTheDocument() + }) + + it('should not render badge for other statuses', () => { + render(<SummaryStatus status="COMPLETED" />) + expect(screen.queryByTestId('badge')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-text.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-text.spec.tsx new file mode 100644 index 0000000000..f4478f6b37 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary-text.spec.tsx @@ -0,0 +1,42 @@ +import type * as React from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import SummaryText from '../summary-text' + +vi.mock('react-textarea-autosize', () => ({ + default: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea data-testid="textarea" {...props} />, +})) + +describe('SummaryText', () => { + const onChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render summary heading', () => { + render(<SummaryText />) + expect(screen.getByText('datasetDocuments.segment.summary')).toBeInTheDocument() + }) + + it('should render value in textarea', () => { + render(<SummaryText value="My summary" onChange={onChange} />) + expect(screen.getByTestId('textarea')).toHaveValue('My summary') + }) + + it('should render empty string when value is undefined', () => { + render(<SummaryText onChange={onChange} />) + expect(screen.getByTestId('textarea')).toHaveValue('') + }) + + it('should call onChange when text changes', () => { + render(<SummaryText value="" onChange={onChange} />) + fireEvent.change(screen.getByTestId('textarea'), { target: { value: 'new summary' } }) + expect(onChange).toHaveBeenCalledWith('new summary') + }) + + it('should disable textarea when disabled', () => { + render(<SummaryText value="text" disabled />) + expect(screen.getByTestId('textarea')).toBeDisabled() + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/common/__tests__/summary.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary.spec.tsx new file mode 100644 index 0000000000..c6176eeefa --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/summary.spec.tsx @@ -0,0 +1,233 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import SummaryLabel from '../summary-label' +import SummaryStatus from '../summary-status' +import SummaryText from '../summary-text' + +describe('SummaryLabel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verifies the component renders with its heading and summary text + describe('Rendering', () => { + it('should render without crashing', () => { + render(<SummaryLabel />) + expect(screen.getByText(/segment\.summary/)).toBeInTheDocument() + }) + + it('should render the summary heading with divider', () => { + render(<SummaryLabel summary="Test summary" />) + expect(screen.getByText(/segment\.summary/)).toBeInTheDocument() + }) + + it('should render summary text when provided', () => { + render(<SummaryLabel summary="My summary content" />) + expect(screen.getByText('My summary content')).toBeInTheDocument() + }) + }) + + // Props: tests different prop combinations + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render(<SummaryLabel summary="test" className="custom-class" />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('custom-class') + expect(wrapper).toHaveClass('space-y-1') + }) + + it('should render without className prop', () => { + const { container } = render(<SummaryLabel summary="test" />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('space-y-1') + }) + }) + + // Edge Cases: tests undefined/empty/special values + describe('Edge Cases', () => { + it('should handle undefined summary', () => { + render(<SummaryLabel />) + // Heading should still render + expect(screen.getByText(/segment\.summary/)).toBeInTheDocument() + }) + + it('should handle empty string summary', () => { + render(<SummaryLabel summary="" />) + expect(screen.getByText(/segment\.summary/)).toBeInTheDocument() + }) + + it('should handle summary with special characters', () => { + const summary = '<b>bold</b> & "quotes"' + render(<SummaryLabel summary={summary} />) + expect(screen.getByText(summary)).toBeInTheDocument() + }) + + it('should handle very long summary', () => { + const longSummary = 'A'.repeat(1000) + render(<SummaryLabel summary={longSummary} />) + expect(screen.getByText(longSummary)).toBeInTheDocument() + }) + + it('should handle both className and summary as undefined', () => { + const { container } = render(<SummaryLabel />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('space-y-1') + }) + }) +}) + +describe('SummaryStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verifies badge rendering based on status + describe('Rendering', () => { + it('should render without crashing', () => { + render(<SummaryStatus status="COMPLETED" />) + // Should not crash even for non-SUMMARIZING status + }) + + it('should render badge when status is SUMMARIZING', () => { + render(<SummaryStatus status="SUMMARIZING" />) + expect(screen.getByText(/list\.summary\.generating/)).toBeInTheDocument() + }) + + it('should not render badge when status is not SUMMARIZING', () => { + render(<SummaryStatus status="COMPLETED" />) + expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument() + }) + }) + + // Props: tests tooltip content based on status + describe('Props', () => { + it('should show tooltip with generating summary message when SUMMARIZING', () => { + render(<SummaryStatus status="SUMMARIZING" />) + // The tooltip popupContent is set to the i18n key for generatingSummary + expect(screen.getByText(/list\.summary\.generating/)).toBeInTheDocument() + }) + }) + + // Edge Cases: tests different status values + describe('Edge Cases', () => { + it('should not render badge for empty string status', () => { + render(<SummaryStatus status="" />) + expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument() + }) + + it('should not render badge for lowercase summarizing', () => { + render(<SummaryStatus status="summarizing" />) + expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument() + }) + + it('should not render badge for DONE status', () => { + render(<SummaryStatus status="DONE" />) + expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument() + }) + + it('should not render badge for FAILED status', () => { + render(<SummaryStatus status="FAILED" />) + expect(screen.queryByText(/list\.summary\.generating/)).not.toBeInTheDocument() + }) + }) +}) + +describe('SummaryText', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verifies the label and textarea render correctly + describe('Rendering', () => { + it('should render without crashing', () => { + render(<SummaryText />) + expect(screen.getByText(/segment\.summary/)).toBeInTheDocument() + }) + + it('should render the summary label', () => { + render(<SummaryText value="hello" />) + expect(screen.getByText(/segment\.summary/)).toBeInTheDocument() + }) + + it('should render textarea with placeholder', () => { + render(<SummaryText />) + const textarea = screen.getByRole('textbox') + expect(textarea).toBeInTheDocument() + expect(textarea).toHaveAttribute('placeholder', expect.stringContaining('segment.summaryPlaceholder')) + }) + }) + + // Props: tests value, onChange, and disabled behavior + describe('Props', () => { + it('should display the value prop in textarea', () => { + render(<SummaryText value="My summary" />) + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue('My summary') + }) + + it('should display empty string when value is undefined', () => { + render(<SummaryText />) + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue('') + }) + + it('should call onChange when textarea value changes', () => { + const onChange = vi.fn() + render(<SummaryText value="" onChange={onChange} />) + const textarea = screen.getByRole('textbox') + + fireEvent.change(textarea, { target: { value: 'new value' } }) + + expect(onChange).toHaveBeenCalledWith('new value') + }) + + it('should disable textarea when disabled is true', () => { + render(<SummaryText value="test" disabled={true} />) + const textarea = screen.getByRole('textbox') + expect(textarea).toBeDisabled() + }) + + it('should enable textarea when disabled is false', () => { + render(<SummaryText value="test" disabled={false} />) + const textarea = screen.getByRole('textbox') + expect(textarea).not.toBeDisabled() + }) + + it('should enable textarea when disabled is undefined', () => { + render(<SummaryText value="test" />) + const textarea = screen.getByRole('textbox') + expect(textarea).not.toBeDisabled() + }) + }) + + // Edge Cases: tests missing onChange and edge value scenarios + describe('Edge Cases', () => { + it('should not throw when onChange is undefined and user types', () => { + render(<SummaryText value="" />) + const textarea = screen.getByRole('textbox') + expect(() => { + fireEvent.change(textarea, { target: { value: 'typed' } }) + }).not.toThrow() + }) + + it('should handle empty string value', () => { + render(<SummaryText value="" />) + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue('') + }) + + it('should handle very long value', () => { + const longValue = 'B'.repeat(5000) + render(<SummaryText value={longValue} />) + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue(longValue) + }) + + it('should handle value with special characters', () => { + const special = '<script>alert("x")</script>' + render(<SummaryText value={special} />) + const textarea = screen.getByRole('textbox') + expect(textarea).toHaveValue(special) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/common/tag.spec.tsx b/web/app/components/datasets/documents/detail/completed/common/__tests__/tag.spec.tsx similarity index 84% rename from web/app/components/datasets/documents/detail/completed/common/tag.spec.tsx rename to web/app/components/datasets/documents/detail/completed/common/__tests__/tag.spec.tsx index 8456652126..17966ad3b2 100644 --- a/web/app/components/datasets/documents/detail/completed/common/tag.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/common/__tests__/tag.spec.tsx @@ -1,39 +1,30 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import Tag from './tag' +import Tag from '../tag' describe('Tag', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<Tag text="test" />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the hash symbol', () => { - // Arrange & Act render(<Tag text="test" />) - // Assert expect(screen.getByText('#')).toBeInTheDocument() }) it('should render the text content', () => { - // Arrange & Act render(<Tag text="keyword" />) - // Assert expect(screen.getByText('keyword')).toBeInTheDocument() }) it('should render with correct base styling classes', () => { - // Arrange & Act const { container } = render(<Tag text="test" />) - // Assert const tagElement = container.firstChild as HTMLElement expect(tagElement).toHaveClass('inline-flex') expect(tagElement).toHaveClass('items-center') @@ -41,87 +32,67 @@ describe('Tag', () => { }) }) - // Props tests describe('Props', () => { it('should apply custom className', () => { - // Arrange & Act const { container } = render(<Tag text="test" className="custom-class" />) - // Assert const tagElement = container.firstChild as HTMLElement expect(tagElement).toHaveClass('custom-class') }) it('should render different text values', () => { - // Arrange & Act const { rerender } = render(<Tag text="first" />) expect(screen.getByText('first')).toBeInTheDocument() - // Act rerender(<Tag text="second" />) - // Assert expect(screen.getByText('second')).toBeInTheDocument() }) }) - // Structure tests describe('Structure', () => { it('should render hash with quaternary text color', () => { - // Arrange & Act const { container } = render(<Tag text="test" />) - // Assert const hashSpan = container.querySelector('.text-text-quaternary') expect(hashSpan).toBeInTheDocument() expect(hashSpan).toHaveTextContent('#') }) it('should render text with tertiary text color', () => { - // Arrange & Act const { container } = render(<Tag text="test" />) - // Assert const textSpan = container.querySelector('.text-text-tertiary') expect(textSpan).toBeInTheDocument() expect(textSpan).toHaveTextContent('test') }) it('should have truncate class for text overflow', () => { - // Arrange & Act const { container } = render(<Tag text="very-long-text-that-might-overflow" />) - // Assert const textSpan = container.querySelector('.truncate') expect(textSpan).toBeInTheDocument() }) it('should have max-width constraint on text', () => { - // Arrange & Act const { container } = render(<Tag text="test" />) - // Assert const textSpan = container.querySelector('.max-w-12') expect(textSpan).toBeInTheDocument() }) }) - // Memoization tests describe('Memoization', () => { it('should render consistently with same props', () => { - // Arrange & Act const { container: container1 } = render(<Tag text="test" />) const { container: container2 } = render(<Tag text="test" />) - // Assert expect(container1.firstChild?.textContent).toBe(container2.firstChild?.textContent) }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty text', () => { - // Arrange & Act render(<Tag text="" />) // Assert - should still render the hash symbol @@ -129,21 +100,16 @@ describe('Tag', () => { }) it('should handle special characters in text', () => { - // Arrange & Act render(<Tag text="test-tag_1" />) - // Assert expect(screen.getByText('test-tag_1')).toBeInTheDocument() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<Tag text="test" />) - // Act rerender(<Tag text="test" />) - // Assert expect(screen.getByText('#')).toBeInTheDocument() expect(screen.getByText('test')).toBeInTheDocument() }) diff --git a/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx b/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx new file mode 100644 index 0000000000..dfcb02215c --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/components/__tests__/drawer-group.spec.tsx @@ -0,0 +1,106 @@ +import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ChunkingMode } from '@/models/datasets' +import DrawerGroup from '../drawer-group' + +vi.mock('../../common/full-screen-drawer', () => ({ + default: ({ isOpen, children }: { isOpen: boolean, children: React.ReactNode }) => ( + isOpen ? <div data-testid="full-screen-drawer">{children}</div> : null + ), +})) + +vi.mock('../../segment-detail', () => ({ + default: () => <div data-testid="segment-detail" />, +})) + +vi.mock('../../child-segment-detail', () => ({ + default: () => <div data-testid="child-segment-detail" />, +})) + +vi.mock('../../new-child-segment', () => ({ + default: () => <div data-testid="new-child-segment" />, +})) + +vi.mock('@/app/components/datasets/documents/detail/new-segment', () => ({ + default: () => <div data-testid="new-segment" />, +})) + +describe('DrawerGroup', () => { + const defaultProps = { + currSegment: { segInfo: undefined, showModal: false, isEditMode: false }, + onCloseSegmentDetail: vi.fn(), + onUpdateSegment: vi.fn(), + isRegenerationModalOpen: false, + setIsRegenerationModalOpen: vi.fn(), + showNewSegmentModal: false, + onCloseNewSegmentModal: vi.fn(), + onSaveNewSegment: vi.fn(), + viewNewlyAddedChunk: vi.fn(), + currChildChunk: { childChunkInfo: undefined, showModal: false }, + currChunkId: 'chunk-1', + onCloseChildSegmentDetail: vi.fn(), + onUpdateChildChunk: vi.fn(), + showNewChildSegmentModal: false, + onCloseNewChildChunkModal: vi.fn(), + onSaveNewChildChunk: vi.fn(), + viewNewlyAddedChildChunk: vi.fn(), + fullScreen: false, + docForm: ChunkingMode.text, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render nothing when all modals are closed', () => { + const { container } = render(<DrawerGroup {...defaultProps} />) + expect(container.querySelector('[data-testid="full-screen-drawer"]')).toBeNull() + }) + + it('should render segment detail when segment modal is open', () => { + render( + <DrawerGroup + {...defaultProps} + currSegment={{ segInfo: { id: 'seg-1' } as SegmentDetailModel, showModal: true, isEditMode: true }} + />, + ) + expect(screen.getByTestId('segment-detail')).toBeInTheDocument() + }) + + it('should render new segment modal when showNewSegmentModal is true', () => { + render( + <DrawerGroup {...defaultProps} showNewSegmentModal={true} />, + ) + expect(screen.getByTestId('new-segment')).toBeInTheDocument() + }) + + it('should render child segment detail when child chunk modal is open', () => { + render( + <DrawerGroup + {...defaultProps} + currChildChunk={{ childChunkInfo: { id: 'child-1' } as ChildChunkDetail, showModal: true }} + />, + ) + expect(screen.getByTestId('child-segment-detail')).toBeInTheDocument() + }) + + it('should render new child segment modal when showNewChildSegmentModal is true', () => { + render( + <DrawerGroup {...defaultProps} showNewChildSegmentModal={true} />, + ) + expect(screen.getByTestId('new-child-segment')).toBeInTheDocument() + }) + + it('should render multiple drawers simultaneously', () => { + render( + <DrawerGroup + {...defaultProps} + currSegment={{ segInfo: { id: 'seg-1' } as SegmentDetailModel, showModal: true }} + showNewChildSegmentModal={true} + />, + ) + expect(screen.getByTestId('segment-detail')).toBeInTheDocument() + expect(screen.getByTestId('new-child-segment')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx b/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx new file mode 100644 index 0000000000..0cc6c28d52 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/components/__tests__/menu-bar.spec.tsx @@ -0,0 +1,95 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import MenuBar from '../menu-bar' + +vi.mock('../../display-toggle', () => ({ + default: ({ isCollapsed, toggleCollapsed }: { isCollapsed: boolean, toggleCollapsed: () => void }) => ( + <button data-testid="display-toggle" onClick={toggleCollapsed}> + {isCollapsed ? 'collapsed' : 'expanded'} + </button> + ), +})) + +vi.mock('../../status-item', () => ({ + default: ({ item }: { item: { name: string } }) => <div data-testid="status-item">{item.name}</div>, +})) + +describe('MenuBar', () => { + const defaultProps = { + isAllSelected: false, + isSomeSelected: false, + onSelectedAll: vi.fn(), + isLoading: false, + totalText: '10 Chunks', + statusList: [ + { value: 'all', name: 'All' }, + { value: 0, name: 'Enabled' }, + { value: 1, name: 'Disabled' }, + ], + selectDefaultValue: 'all' as const, + onChangeStatus: vi.fn(), + inputValue: '', + onInputChange: vi.fn(), + isCollapsed: false, + toggleCollapsed: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render total text', () => { + render(<MenuBar {...defaultProps} />) + expect(screen.getByText('10 Chunks')).toBeInTheDocument() + }) + + it('should render checkbox', () => { + const { container } = render(<MenuBar {...defaultProps} />) + const checkbox = container.querySelector('[class*="shrink-0"]') + expect(checkbox).toBeInTheDocument() + }) + + it('should call onInputChange when input changes', () => { + render(<MenuBar {...defaultProps} />) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: 'test search' } }) + expect(defaultProps.onInputChange).toHaveBeenCalledWith('test search') + }) + + it('should render display toggle', () => { + render(<MenuBar {...defaultProps} />) + expect(screen.getByTestId('display-toggle')).toBeInTheDocument() + }) + + it('should call toggleCollapsed when display toggle clicked', () => { + render(<MenuBar {...defaultProps} />) + fireEvent.click(screen.getByTestId('display-toggle')) + expect(defaultProps.toggleCollapsed).toHaveBeenCalled() + }) + + it('should call onInputChange with empty string when input is cleared', () => { + render(<MenuBar {...defaultProps} inputValue="some text" />) + const clearButton = screen.getByTestId('input-clear') + fireEvent.click(clearButton) + expect(defaultProps.onInputChange).toHaveBeenCalledWith('') + }) + + it('should render select with status items via renderOption', () => { + render(<MenuBar {...defaultProps} />) + expect(screen.getByText('All')).toBeInTheDocument() + }) + + it('should call renderOption for each item when dropdown is opened', async () => { + render(<MenuBar {...defaultProps} />) + + const selectButton = screen.getByRole('button', { name: /All/i }) + fireEvent.click(selectButton) + + // After opening, renderOption is called for each item, rendering the mocked StatusItem + const statusItems = await screen.findAllByTestId('status-item') + expect(statusItems.length).toBe(3) + expect(statusItems[0]).toHaveTextContent('All') + expect(statusItems[1]).toHaveTextContent('Enabled') + expect(statusItems[2]).toHaveTextContent('Disabled') + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/components/__tests__/segment-list-content.spec.tsx b/web/app/components/datasets/documents/detail/completed/components/__tests__/segment-list-content.spec.tsx new file mode 100644 index 0000000000..eeeeca333d --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/components/__tests__/segment-list-content.spec.tsx @@ -0,0 +1,103 @@ +import type { SegmentDetailModel } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FullDocModeContent, GeneralModeContent } from '../segment-list-content' + +vi.mock('../../child-segment-list', () => ({ + default: ({ parentChunkId }: { parentChunkId: string }) => ( + <div data-testid="child-segment-list">{parentChunkId}</div> + ), +})) + +vi.mock('../../segment-card', () => ({ + default: ({ detail, onClick }: { detail: { id: string }, onClick?: () => void }) => ( + <div data-testid="segment-card" onClick={onClick}>{detail?.id}</div> + ), +})) + +vi.mock('../../segment-list', () => { + const SegmentList = vi.fn(({ items }: { items: { id: string }[] }) => ( + <div data-testid="segment-list"> + {items?.length ?? 0} + {' '} + items + </div> + )) + return { default: SegmentList } +}) + +describe('FullDocModeContent', () => { + const defaultProps = { + segments: [{ id: 'seg-1', position: 1, content: 'test', word_count: 10 }] as SegmentDetailModel[], + childSegments: [], + isLoadingSegmentList: false, + isLoadingChildSegmentList: false, + currSegmentId: undefined, + onClickCard: vi.fn(), + onDeleteChildChunk: vi.fn(), + handleInputChange: vi.fn(), + handleAddNewChildChunk: vi.fn(), + onClickSlice: vi.fn(), + archived: false, + childChunkTotal: 0, + inputValue: '', + onClearFilter: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render segment card with first segment', () => { + render(<FullDocModeContent {...defaultProps} />) + expect(screen.getByTestId('segment-card')).toHaveTextContent('seg-1') + }) + + it('should render child segment list', () => { + render(<FullDocModeContent {...defaultProps} />) + expect(screen.getByTestId('child-segment-list')).toHaveTextContent('seg-1') + }) + + it('should apply overflow-y-hidden when loading', () => { + const { container } = render( + <FullDocModeContent {...defaultProps} isLoadingSegmentList={true} />, + ) + expect(container.firstChild).toHaveClass('overflow-y-hidden') + }) + + it('should apply overflow-y-auto when not loading', () => { + const { container } = render(<FullDocModeContent {...defaultProps} />) + expect(container.firstChild).toHaveClass('overflow-y-auto') + }) + + it('should call onClickCard with first segment when segment card is clicked', () => { + const onClickCard = vi.fn() + render(<FullDocModeContent {...defaultProps} onClickCard={onClickCard} />) + fireEvent.click(screen.getByTestId('segment-card')) + expect(onClickCard).toHaveBeenCalledWith(defaultProps.segments[0]) + }) +}) + +describe('GeneralModeContent', () => { + const defaultProps = { + segmentListRef: { current: null }, + embeddingAvailable: true, + isLoadingSegmentList: false, + segments: [{ id: 'seg-1' }, { id: 'seg-2' }] as SegmentDetailModel[], + selectedSegmentIds: [], + onSelected: vi.fn(), + onChangeSwitch: vi.fn(), + onDelete: vi.fn(), + onClickCard: vi.fn(), + archived: false, + onDeleteChildChunk: vi.fn(), + handleAddNewChildChunk: vi.fn(), + onClickSlice: vi.fn(), + onClearFilter: vi.fn(), + } + + it('should render segment list with items', () => { + render(<GeneralModeContent {...defaultProps} />) + expect(screen.getByTestId('segment-list')).toHaveTextContent('2 items') + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts similarity index 76% rename from web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.spec.ts rename to web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts index 66a2f9e541..83918a3f30 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-child-segment-data.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-child-segment-data.spec.ts @@ -3,7 +3,7 @@ import type { ChildChunkDetail, ChildSegmentsResponse, ChunkingMode, ParentMode, import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook } from '@testing-library/react' import * as React from 'react' -import { useChildSegmentData } from './use-child-segment-data' +import { useChildSegmentData } from '../use-child-segment-data' // Type for mutation callbacks type MutationResponse = { data: ChildChunkDetail } @@ -13,9 +13,7 @@ type MutationCallbacks = { } type _ErrorCallback = { onSuccess?: () => void, onError: () => void } -// ============================================================================ // Hoisted Mocks -// ============================================================================ const { mockParentMode, @@ -41,21 +39,6 @@ const { mockInvalidChildSegmentList: vi.fn(), })) -// Mock dependencies -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - if (key === 'actionMsg.modifiedSuccessfully') - return 'Modified successfully' - if (key === 'actionMsg.modifiedUnsuccessfully') - return 'Modified unsuccessfully' - if (key === 'segment.contentEmpty') - return 'Content cannot be empty' - return key - }, - }), -})) - vi.mock('@tanstack/react-query', async () => { const actual = await vi.importActual('@tanstack/react-query') return { @@ -64,7 +47,7 @@ vi.mock('@tanstack/react-query', async () => { } }) -vi.mock('../../context', () => ({ +vi.mock('../../../context', () => ({ useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => { const value: DocumentContextValue = { datasetId: mockDatasetId.current, @@ -98,10 +81,6 @@ vi.mock('@/service/use-base', () => ({ useInvalid: () => mockInvalidChildSegmentList, })) -// ============================================================================ -// Test Utilities -// ============================================================================ - const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -167,9 +146,7 @@ const defaultOptions = { updateSegmentInCache: vi.fn(), } -// ============================================================================ // Tests -// ============================================================================ describe('useChildSegmentData', () => { beforeEach(() => { @@ -226,7 +203,7 @@ describe('useChildSegmentData', () => { }) expect(mockDeleteChildSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' }) expect(updateSegmentInCache).toHaveBeenCalledWith('seg-1', expect.any(Function)) }) @@ -261,7 +238,7 @@ describe('useChildSegmentData', () => { await result.current.onDeleteChildChunk('seg-1', 'child-1') }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Modified unsuccessfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'common.actionMsg.modifiedUnsuccessfully' }) }) }) @@ -275,7 +252,7 @@ describe('useChildSegmentData', () => { await result.current.handleUpdateChildChunk('seg-1', 'child-1', ' ') }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Content cannot be empty' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'datasetDocuments.segment.contentEmpty' }) expect(mockUpdateChildSegment).not.toHaveBeenCalled() }) @@ -311,7 +288,7 @@ describe('useChildSegmentData', () => { }) expect(mockUpdateChildSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' }) expect(onCloseChildSegmentDetail).toHaveBeenCalled() expect(updateSegmentInCache).toHaveBeenCalled() expect(refreshChunkListDataWithDetailChanged).toHaveBeenCalled() @@ -564,5 +541,151 @@ describe('useChildSegmentData', () => { expect(mockQueryClient.setQueryData).toHaveBeenCalled() }) + + it('should handle updateChildSegmentInCache when old data is undefined', async () => { + mockParentMode.current = 'full-doc' + const onCloseChildSegmentDetail = vi.fn() + + // Capture the setQueryData callback to verify null-safety + mockQueryClient.setQueryData.mockImplementation((_key: unknown, updater: (old: unknown) => unknown) => { + if (typeof updater === 'function') { + // Invoke with undefined to cover the !old branch + const resultWithUndefined = updater(undefined) + expect(resultWithUndefined).toBeUndefined() + // Also test with real data + const resultWithData = updater({ + data: [ + createMockChildChunk({ id: 'child-1', content: 'old content' }), + createMockChildChunk({ id: 'child-2', content: 'other' }), + ], + total: 2, + total_pages: 1, + }) as ChildSegmentsResponse + expect(resultWithData.data[0].content).toBe('new content') + expect(resultWithData.data[1].content).toBe('other') + } + }) + + mockUpdateChildSegment.mockImplementation(async (_params, { onSuccess, onSettled }: MutationCallbacks) => { + onSuccess({ + data: createMockChildChunk({ + id: 'child-1', + content: 'new content', + type: 'customized', + word_count: 50, + updated_at: 1700000001, + }), + }) + onSettled() + }) + + const { result } = renderHook(() => useChildSegmentData({ + ...defaultOptions, + onCloseChildSegmentDetail, + }), { + wrapper: createWrapper(), + }) + + await act(async () => { + await result.current.handleUpdateChildChunk('seg-1', 'child-1', 'new content') + }) + + expect(mockQueryClient.setQueryData).toHaveBeenCalled() + }) + }) + + describe('Scroll to bottom effect', () => { + it('should scroll to bottom when childSegments change and needScrollToBottom is true', () => { + // Start with empty data + mockChildSegmentListData.current = { data: [], total: 0, total_pages: 0, page: 1, limit: 20 } + + const { result, rerender } = renderHook(() => useChildSegmentData(defaultOptions), { + wrapper: createWrapper(), + }) + + // Set up the ref to a mock DOM element + const mockScrollTo = vi.fn() + Object.defineProperty(result.current.childSegmentListRef, 'current', { + value: { scrollTo: mockScrollTo, scrollHeight: 500 }, + writable: true, + }) + result.current.needScrollToBottom.current = true + + // Change mock data to trigger the useEffect + mockChildSegmentListData.current = { + data: [createMockChildChunk({ id: 'new-child' })], + total: 1, + total_pages: 1, + page: 1, + limit: 20, + } + rerender() + + expect(mockScrollTo).toHaveBeenCalledWith({ top: 500, behavior: 'smooth' }) + expect(result.current.needScrollToBottom.current).toBe(false) + }) + + it('should not scroll when needScrollToBottom is false', () => { + mockChildSegmentListData.current = { data: [], total: 0, total_pages: 0, page: 1, limit: 20 } + + const { result, rerender } = renderHook(() => useChildSegmentData(defaultOptions), { + wrapper: createWrapper(), + }) + + const mockScrollTo = vi.fn() + Object.defineProperty(result.current.childSegmentListRef, 'current', { + value: { scrollTo: mockScrollTo, scrollHeight: 500 }, + writable: true, + }) + // needScrollToBottom remains false + + mockChildSegmentListData.current = { + data: [createMockChildChunk()], + total: 1, + total_pages: 1, + page: 1, + limit: 20, + } + rerender() + + expect(mockScrollTo).not.toHaveBeenCalled() + }) + + it('should not scroll when childSegmentListRef is null', () => { + mockChildSegmentListData.current = { data: [], total: 0, total_pages: 0, page: 1, limit: 20 } + + const { result, rerender } = renderHook(() => useChildSegmentData(defaultOptions), { + wrapper: createWrapper(), + }) + + // ref.current stays null, needScrollToBottom is true + result.current.needScrollToBottom.current = true + + mockChildSegmentListData.current = { + data: [createMockChildChunk()], + total: 1, + total_pages: 1, + page: 1, + limit: 20, + } + rerender() + + // needScrollToBottom stays true since scroll didn't happen + expect(result.current.needScrollToBottom.current).toBe(true) + }) + }) + + describe('Query params edge cases', () => { + it('should handle currentPage of 0 by defaulting to page 1', () => { + const { result } = renderHook(() => useChildSegmentData({ + ...defaultOptions, + currentPage: 0, + }), { + wrapper: createWrapper(), + }) + + // Should still work with page defaulted to 1 + expect(result.current.childSegments).toEqual([]) + }) }) }) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts new file mode 100644 index 0000000000..57e7ae5d5e --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-modal-state.spec.ts @@ -0,0 +1,146 @@ +import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useModalState } from '../use-modal-state' + +describe('useModalState', () => { + const onNewSegmentModalChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + const renderUseModalState = () => + renderHook(() => useModalState({ onNewSegmentModalChange })) + + it('should initialize with all modals closed', () => { + const { result } = renderUseModalState() + + expect(result.current.currSegment.showModal).toBe(false) + expect(result.current.currChildChunk.showModal).toBe(false) + expect(result.current.showNewChildSegmentModal).toBe(false) + expect(result.current.isRegenerationModalOpen).toBe(false) + expect(result.current.fullScreen).toBe(false) + expect(result.current.isCollapsed).toBe(true) + }) + + it('should open segment detail on card click', () => { + const { result } = renderUseModalState() + const detail = { id: 'seg-1', content: 'test' } as unknown as SegmentDetailModel + + act(() => { + result.current.onClickCard(detail, true) + }) + + expect(result.current.currSegment.showModal).toBe(true) + expect(result.current.currSegment.segInfo).toBe(detail) + expect(result.current.currSegment.isEditMode).toBe(true) + }) + + it('should close segment detail and reset fullscreen', () => { + const { result } = renderUseModalState() + + act(() => { + result.current.onClickCard({ id: 'seg-1' } as unknown as SegmentDetailModel) + }) + act(() => { + result.current.setFullScreen(true) + }) + act(() => { + result.current.onCloseSegmentDetail() + }) + + expect(result.current.currSegment.showModal).toBe(false) + expect(result.current.fullScreen).toBe(false) + }) + + it('should open child segment detail on slice click', () => { + const { result } = renderUseModalState() + const childDetail = { id: 'child-1', segment_id: 'seg-1' } as unknown as ChildChunkDetail + + act(() => { + result.current.onClickSlice(childDetail) + }) + + expect(result.current.currChildChunk.showModal).toBe(true) + expect(result.current.currChildChunk.childChunkInfo).toBe(childDetail) + expect(result.current.currChunkId).toBe('seg-1') + }) + + it('should close child segment detail', () => { + const { result } = renderUseModalState() + + act(() => { + result.current.onClickSlice({ id: 'c1', segment_id: 's1' } as unknown as ChildChunkDetail) + }) + act(() => { + result.current.onCloseChildSegmentDetail() + }) + + expect(result.current.currChildChunk.showModal).toBe(false) + }) + + it('should handle new child chunk modal', () => { + const { result } = renderUseModalState() + + act(() => { + result.current.handleAddNewChildChunk('parent-chunk-1') + }) + + expect(result.current.showNewChildSegmentModal).toBe(true) + expect(result.current.currChunkId).toBe('parent-chunk-1') + + act(() => { + result.current.onCloseNewChildChunkModal() + }) + + expect(result.current.showNewChildSegmentModal).toBe(false) + }) + + it('should close new segment modal and notify parent', () => { + const { result } = renderUseModalState() + + act(() => { + result.current.onCloseNewSegmentModal() + }) + + expect(onNewSegmentModalChange).toHaveBeenCalledWith(false) + }) + + it('should toggle full screen', () => { + const { result } = renderUseModalState() + + act(() => { + result.current.toggleFullScreen() + }) + expect(result.current.fullScreen).toBe(true) + + act(() => { + result.current.toggleFullScreen() + }) + expect(result.current.fullScreen).toBe(false) + }) + + it('should toggle collapsed', () => { + const { result } = renderUseModalState() + + act(() => { + result.current.toggleCollapsed() + }) + expect(result.current.isCollapsed).toBe(false) + + act(() => { + result.current.toggleCollapsed() + }) + expect(result.current.isCollapsed).toBe(true) + }) + + it('should set regeneration modal state', () => { + const { result } = renderUseModalState() + + act(() => { + result.current.setIsRegenerationModalOpen(true) + }) + expect(result.current.isRegenerationModalOpen).toBe(true) + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-search-filter.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-search-filter.spec.ts new file mode 100644 index 0000000000..31b644b73b --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-search-filter.spec.ts @@ -0,0 +1,124 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useSearchFilter } from '../use-search-filter' + +describe('useSearchFilter', () => { + const onPageChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should initialize with default values', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + expect(result.current.inputValue).toBe('') + expect(result.current.searchValue).toBe('') + expect(result.current.selectedStatus).toBe('all') + expect(result.current.selectDefaultValue).toBe('all') + }) + + it('should provide status list with three items', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + expect(result.current.statusList).toHaveLength(3) + }) + + it('should update input value immediately on handleInputChange', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.handleInputChange('test query') + }) + + expect(result.current.inputValue).toBe('test query') + }) + + it('should update search value after debounce', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.handleInputChange('debounced') + }) + + // Before debounce + expect(result.current.searchValue).toBe('') + + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(result.current.searchValue).toBe('debounced') + expect(onPageChange).toHaveBeenCalledWith(1) + }) + + it('should change status and reset page', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.onChangeStatus({ value: 1, name: 'Enabled' }) + }) + + expect(result.current.selectedStatus).toBe(true) + expect(result.current.selectDefaultValue).toBe(1) + expect(onPageChange).toHaveBeenCalledWith(1) + }) + + it('should set status to false when value is 0', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.onChangeStatus({ value: 0, name: 'Disabled' }) + }) + + expect(result.current.selectedStatus).toBe(false) + expect(result.current.selectDefaultValue).toBe(0) + }) + + it('should set status to "all" when value is "all"', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.onChangeStatus({ value: 1, name: 'Enabled' }) + }) + act(() => { + result.current.onChangeStatus({ value: 'all', name: 'All' }) + }) + + expect(result.current.selectedStatus).toBe('all') + }) + + it('should clear all filters on onClearFilter', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.handleInputChange('test') + vi.advanceTimersByTime(500) + }) + act(() => { + result.current.onChangeStatus({ value: 1, name: 'Enabled' }) + }) + + act(() => { + result.current.onClearFilter() + }) + + expect(result.current.inputValue).toBe('') + expect(result.current.searchValue).toBe('') + expect(result.current.selectedStatus).toBe('all') + }) + + it('should reset page on resetPage', () => { + const { result } = renderHook(() => useSearchFilter({ onPageChange })) + + act(() => { + result.current.resetPage() + }) + + expect(onPageChange).toHaveBeenCalledWith(1) + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts similarity index 92% rename from web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.spec.ts rename to web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts index e90994661d..aef2053298 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts @@ -5,8 +5,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook } from '@testing-library/react' import * as React from 'react' import { ChunkingMode as ChunkingModeEnum } from '@/models/datasets' -import { ProcessStatus } from '../../segment-add' -import { useSegmentListData } from './use-segment-list-data' +import { ProcessStatus } from '../../../segment-add' +import { useSegmentListData } from '../use-segment-list-data' // Type for mutation callbacks type SegmentMutationResponse = { data: SegmentDetailModel } @@ -28,9 +28,7 @@ const createMockFileEntity = (overrides: Partial<FileEntity> = {}): FileEntity = ...overrides, }) -// ============================================================================ // Hoisted Mocks -// ============================================================================ const { mockDocForm, @@ -70,33 +68,6 @@ const { mockPathname: { current: '/datasets/test/documents/test' }, })) -// Mock dependencies -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { count?: number, ns?: string }) => { - if (key === 'actionMsg.modifiedSuccessfully') - return 'Modified successfully' - if (key === 'actionMsg.modifiedUnsuccessfully') - return 'Modified unsuccessfully' - if (key === 'segment.contentEmpty') - return 'Content cannot be empty' - if (key === 'segment.questionEmpty') - return 'Question cannot be empty' - if (key === 'segment.answerEmpty') - return 'Answer cannot be empty' - if (key === 'segment.allFilesUploaded') - return 'All files must be uploaded' - if (key === 'segment.chunks') - return options?.count === 1 ? 'chunk' : 'chunks' - if (key === 'segment.parentChunks') - return options?.count === 1 ? 'parent chunk' : 'parent chunks' - if (key === 'segment.searchResults') - return 'search results' - return `${options?.ns || ''}.${key}` - }, - }), -})) - vi.mock('next/navigation', () => ({ usePathname: () => mockPathname.current, })) @@ -109,7 +80,7 @@ vi.mock('@tanstack/react-query', async () => { } }) -vi.mock('../../context', () => ({ +vi.mock('../../../context', () => ({ useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => { const value: DocumentContextValue = { datasetId: mockDatasetId.current, @@ -157,10 +128,6 @@ vi.mock('@/service/use-base', () => ({ }, })) -// ============================================================================ -// Test Utilities -// ============================================================================ - const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, @@ -213,9 +180,7 @@ const defaultOptions = { clearSelection: vi.fn(), } -// ============================================================================ // Tests -// ============================================================================ describe('useSegmentListData', () => { beforeEach(() => { @@ -269,7 +234,7 @@ describe('useSegmentListData', () => { }) expect(result.current.totalText).toContain('10') - expect(result.current.totalText).toContain('chunks') + expect(result.current.totalText).toContain('datasetDocuments.segment.chunks') }) it('should show search results when searching', () => { @@ -283,7 +248,7 @@ describe('useSegmentListData', () => { }) expect(result.current.totalText).toContain('5') - expect(result.current.totalText).toContain('search results') + expect(result.current.totalText).toContain('datasetDocuments.segment.searchResults') }) it('should show search results when status is filtered', () => { @@ -296,7 +261,7 @@ describe('useSegmentListData', () => { wrapper: createWrapper(), }) - expect(result.current.totalText).toContain('search results') + expect(result.current.totalText).toContain('datasetDocuments.segment.searchResults') }) it('should show parent chunks in parentChild paragraph mode', () => { @@ -308,7 +273,7 @@ describe('useSegmentListData', () => { wrapper: createWrapper(), }) - expect(result.current.totalText).toContain('parent chunk') + expect(result.current.totalText).toContain('datasetDocuments.segment.parentChunks') }) it('should show "--" when total is undefined', () => { @@ -398,7 +363,7 @@ describe('useSegmentListData', () => { }) expect(mockEnableSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' }) }) it('should call disableSegment when enable is false', async () => { @@ -452,7 +417,7 @@ describe('useSegmentListData', () => { await result.current.onChangeSwitch(true, 'seg-1') }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Modified unsuccessfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'common.actionMsg.modifiedUnsuccessfully' }) }) }) @@ -475,7 +440,7 @@ describe('useSegmentListData', () => { }) expect(mockDeleteSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' }) }) it('should clear selection when deleting batch (no segId)', async () => { @@ -513,7 +478,7 @@ describe('useSegmentListData', () => { await result.current.onDelete('seg-1') }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Modified unsuccessfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'common.actionMsg.modifiedUnsuccessfully' }) }) }) @@ -527,7 +492,7 @@ describe('useSegmentListData', () => { await result.current.handleUpdateSegment('seg-1', ' ', '', [], []) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Content cannot be empty' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'datasetDocuments.segment.contentEmpty' }) expect(mockUpdateSegment).not.toHaveBeenCalled() }) @@ -542,7 +507,7 @@ describe('useSegmentListData', () => { await result.current.handleUpdateSegment('seg-1', '', 'answer', [], []) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Question cannot be empty' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'datasetDocuments.segment.questionEmpty' }) }) it('should validate empty answer in QA mode', async () => { @@ -556,7 +521,7 @@ describe('useSegmentListData', () => { await result.current.handleUpdateSegment('seg-1', 'question', ' ', [], []) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'Answer cannot be empty' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'datasetDocuments.segment.answerEmpty' }) }) it('should validate attachments are uploaded', async () => { @@ -570,7 +535,7 @@ describe('useSegmentListData', () => { ]) }) - expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'All files must be uploaded' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'datasetDocuments.segment.allFilesUploaded' }) }) it('should call updateSegment with correct params', async () => { @@ -592,7 +557,7 @@ describe('useSegmentListData', () => { }) expect(mockUpdateSegment).toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'Modified successfully' }) + expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' }) expect(onCloseSegmentDetail).toHaveBeenCalled() expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-segment') expect(mockEventEmitter.emit).toHaveBeenCalledWith('update-segment-success') diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-selection.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-selection.spec.ts new file mode 100644 index 0000000000..382baf69a8 --- /dev/null +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-selection.spec.ts @@ -0,0 +1,159 @@ +import type { SegmentDetailModel } from '@/models/datasets' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSegmentSelection } from '../use-segment-selection' + +describe('useSegmentSelection', () => { + const segments = [ + { id: 'seg-1', content: 'A' }, + { id: 'seg-2', content: 'B' }, + { id: 'seg-3', content: 'C' }, + ] as unknown as SegmentDetailModel[] + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should initialize with empty selection', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + expect(result.current.selectedSegmentIds).toEqual([]) + expect(result.current.isAllSelected).toBe(false) + expect(result.current.isSomeSelected).toBe(false) + }) + + it('should select a segment', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + + expect(result.current.selectedSegmentIds).toEqual(['seg-1']) + expect(result.current.isSomeSelected).toBe(true) + expect(result.current.isAllSelected).toBe(false) + }) + + it('should deselect a selected segment', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + act(() => { + result.current.onSelected('seg-1') + }) + + expect(result.current.selectedSegmentIds).toEqual([]) + }) + + it('should select all segments', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelectedAll() + }) + + expect(result.current.selectedSegmentIds).toEqual(['seg-1', 'seg-2', 'seg-3']) + expect(result.current.isAllSelected).toBe(true) + }) + + it('should deselect all when all are selected', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelectedAll() + }) + act(() => { + result.current.onSelectedAll() + }) + + expect(result.current.selectedSegmentIds).toEqual([]) + expect(result.current.isAllSelected).toBe(false) + }) + + it('should cancel batch operation', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + result.current.onSelected('seg-2') + }) + act(() => { + result.current.onCancelBatchOperation() + }) + + expect(result.current.selectedSegmentIds).toEqual([]) + }) + + it('should clear selection', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + act(() => { + result.current.clearSelection() + }) + + expect(result.current.selectedSegmentIds).toEqual([]) + }) + + it('should handle empty segments array', () => { + const { result } = renderHook(() => useSegmentSelection([])) + + expect(result.current.isAllSelected).toBe(false) + expect(result.current.isSomeSelected).toBe(false) + }) + + it('should allow multiple selections', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + act(() => { + result.current.onSelected('seg-2') + }) + + expect(result.current.selectedSegmentIds).toEqual(['seg-1', 'seg-2']) + expect(result.current.isSomeSelected).toBe(true) + expect(result.current.isAllSelected).toBe(false) + }) + + it('should preserve selection of segments not in current list', () => { + const { result, rerender } = renderHook( + ({ segs }) => useSegmentSelection(segs), + { initialProps: { segs: segments } }, + ) + + act(() => { + result.current.onSelected('seg-1') + }) + + // Rerender with different segment list (simulating page change) + const newSegments = [ + { id: 'seg-4', content: 'D' }, + { id: 'seg-5', content: 'E' }, + ] as unknown as SegmentDetailModel[] + + rerender({ segs: newSegments }) + + // Previously selected segment should still be in selectedSegmentIds + expect(result.current.selectedSegmentIds).toContain('seg-1') + }) + + it('should select remaining unselected segments when onSelectedAll is called with partial selection', () => { + const { result } = renderHook(() => useSegmentSelection(segments)) + + act(() => { + result.current.onSelected('seg-1') + }) + act(() => { + result.current.onSelectedAll() + }) + + expect(result.current.selectedSegmentIds).toEqual(expect.arrayContaining(['seg-1', 'seg-2', 'seg-3'])) + expect(result.current.isAllSelected).toBe(true) + }) +}) diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/chunk-content.spec.tsx similarity index 92% rename from web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.spec.tsx rename to web/app/components/datasets/documents/detail/completed/segment-card/__tests__/chunk-content.spec.tsx index 570d93d390..3b6492939c 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/chunk-content.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/chunk-content.spec.tsx @@ -4,7 +4,7 @@ import { noop } from 'es-toolkit/function' import { createContext, useContextSelector } from 'use-context-selector' import { describe, expect, it, vi } from 'vitest' -import ChunkContent from './chunk-content' +import ChunkContent from '../chunk-content' // Create mock context matching the actual SegmentListContextValue type SegmentListContextValue = { @@ -24,7 +24,7 @@ const MockSegmentListContext = createContext<SegmentListContextValue>({ }) // Mock the context module -vi.mock('..', () => ({ +vi.mock('../..', () => ({ useSegmentListContext: (selector: (value: SegmentListContextValue) => unknown) => { return useContextSelector(MockSegmentListContext, selector) }, @@ -53,21 +53,17 @@ describe('ChunkContent', () => { sign_content: 'Test sign content', } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render( <ChunkContent detail={defaultDetail} isFullDocMode={false} />, { wrapper: createWrapper() }, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render content in non-QA mode', () => { - // Arrange & Act const { container } = render( <ChunkContent detail={defaultDetail} isFullDocMode={false} />, { wrapper: createWrapper() }, @@ -82,41 +78,34 @@ describe('ChunkContent', () => { // QA mode tests describe('QA Mode', () => { it('should render Q and A labels when answer is present', () => { - // Arrange const qaDetail = { content: 'Question content', sign_content: 'Sign content', answer: 'Answer content', } - // Act render( <ChunkContent detail={qaDetail} isFullDocMode={false} />, { wrapper: createWrapper() }, ) - // Assert expect(screen.getByText('Q')).toBeInTheDocument() expect(screen.getByText('A')).toBeInTheDocument() }) it('should not render Q and A labels when answer is undefined', () => { - // Arrange & Act render( <ChunkContent detail={defaultDetail} isFullDocMode={false} />, { wrapper: createWrapper() }, ) - // Assert expect(screen.queryByText('Q')).not.toBeInTheDocument() expect(screen.queryByText('A')).not.toBeInTheDocument() }) }) - // Props tests describe('Props', () => { it('should apply custom className', () => { - // Arrange & Act const { container } = render( <ChunkContent detail={defaultDetail} @@ -126,12 +115,10 @@ describe('ChunkContent', () => { { wrapper: createWrapper() }, ) - // Assert expect(container.querySelector('.custom-class')).toBeInTheDocument() }) it('should handle isFullDocMode=true', () => { - // Arrange & Act const { container } = render( <ChunkContent detail={defaultDetail} isFullDocMode={true} />, { wrapper: createWrapper() }, @@ -142,7 +129,6 @@ describe('ChunkContent', () => { }) it('should handle isFullDocMode=false with isCollapsed=true', () => { - // Arrange & Act const { container } = render( <ChunkContent detail={defaultDetail} isFullDocMode={false} />, { wrapper: createWrapper(true) }, @@ -153,7 +139,6 @@ describe('ChunkContent', () => { }) it('should handle isFullDocMode=false with isCollapsed=false', () => { - // Arrange & Act const { container } = render( <ChunkContent detail={defaultDetail} isFullDocMode={false} />, { wrapper: createWrapper(false) }, @@ -167,13 +152,11 @@ describe('ChunkContent', () => { // Content priority tests describe('Content Priority', () => { it('should prefer sign_content over content when both exist', () => { - // Arrange const detail = { content: 'Regular content', sign_content: 'Sign content', } - // Act const { container } = render( <ChunkContent detail={detail} isFullDocMode={false} />, { wrapper: createWrapper() }, @@ -184,44 +167,36 @@ describe('ChunkContent', () => { }) it('should use content when sign_content is empty', () => { - // Arrange const detail = { content: 'Regular content', sign_content: '', } - // Act const { container } = render( <ChunkContent detail={detail} isFullDocMode={false} />, { wrapper: createWrapper() }, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty content', () => { - // Arrange const emptyDetail = { content: '', sign_content: '', } - // Act const { container } = render( <ChunkContent detail={emptyDetail} isFullDocMode={false} />, { wrapper: createWrapper() }, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should handle empty answer in QA mode', () => { - // Arrange const qaDetail = { content: 'Question', sign_content: '', @@ -239,13 +214,11 @@ describe('ChunkContent', () => { }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender, container } = render( <ChunkContent detail={defaultDetail} isFullDocMode={false} />, { wrapper: createWrapper() }, ) - // Act rerender( <MockSegmentListContext.Provider value={{ @@ -263,7 +236,6 @@ describe('ChunkContent', () => { </MockSegmentListContext.Provider>, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx rename to web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx index 1ecc2ec597..f0edbb3ebc 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-card/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/segment-card/__tests__/index.spec.tsx @@ -4,30 +4,14 @@ import type { Attachment, ChildChunkDetail, ParentMode, SegmentDetailModel } fro import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import { ChunkingMode } from '@/models/datasets' -import SegmentCard from './index' +import SegmentCard from '../index' -// Mock react-i18next - external dependency -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, options?: { count?: number, ns?: string }) => { - if (key === 'segment.characters') - return options?.count === 1 ? 'character' : 'characters' - if (key === 'segment.childChunks') - return options?.count === 1 ? 'child chunk' : 'child chunks' - const prefix = options?.ns ? `${options.ns}.` : '' - return `${prefix}${key}` - }, - }), -})) - -// ============================================================================ // Context Mocks - need to control test scenarios -// ============================================================================ const mockDocForm = { current: ChunkingMode.text } const mockParentMode = { current: 'paragraph' as ParentMode } -vi.mock('../../context', () => ({ +vi.mock('../../../context', () => ({ useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => { const value: DocumentContextValue = { datasetId: 'test-dataset-id', @@ -40,7 +24,7 @@ vi.mock('../../context', () => ({ })) const mockIsCollapsed = { current: true } -vi.mock('../index', () => ({ +vi.mock('../../index', () => ({ useSegmentListContext: (selector: (value: SegmentListContextValue) => unknown) => { const value: SegmentListContextValue = { isCollapsed: mockIsCollapsed.current, @@ -53,12 +37,10 @@ vi.mock('../index', () => ({ }, })) -// ============================================================================ // Component Mocks - components with complex dependencies -// ============================================================================ // StatusItem uses React Query hooks which require QueryClientProvider -vi.mock('../../../status-item', () => ({ +vi.mock('../../../../status-item', () => ({ default: ({ status, reverse, textCls }: { status: string, reverse?: boolean, textCls?: string }) => ( <div data-testid="status-item" data-status={status} data-reverse={reverse} className={textCls}> Status: @@ -86,10 +68,6 @@ vi.mock('@/app/components/base/markdown', () => ({ ), })) -// ============================================================================ -// Test Data Factories -// ============================================================================ - const createMockAttachment = (overrides: Partial<Attachment> = {}): Attachment => ({ id: 'attachment-1', name: 'test-image.png', @@ -143,9 +121,7 @@ const createMockSegmentDetail = (overrides: Partial<SegmentDetailModel & { docum const defaultFocused = { segmentIndex: false, segmentContent: false } -// ============================================================================ // Tests -// ============================================================================ describe('SegmentCard', () => { beforeEach(() => { @@ -155,9 +131,6 @@ describe('SegmentCard', () => { mockIsCollapsed.current = true }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render loading skeleton when loading is true', () => { render(<SegmentCard loading={true} focused={defaultFocused} />) @@ -188,7 +161,7 @@ describe('SegmentCard', () => { render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) - expect(screen.getByText('250 characters')).toBeInTheDocument() + expect(screen.getByText('250 datasetDocuments.segment.characters:{"count":250}')).toBeInTheDocument() }) it('should render hit count text', () => { @@ -211,9 +184,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // Props Tests - // -------------------------------------------------------------------------- describe('Props', () => { it('should use default empty object when detail is undefined', () => { render(<SegmentCard loading={false} focused={defaultFocused} />) @@ -286,9 +257,6 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- - // State Management Tests - // -------------------------------------------------------------------------- describe('State Management', () => { it('should toggle delete confirmation modal when delete button clicked', async () => { const detail = createMockSegmentDetail() @@ -337,9 +305,6 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- - // Callback Tests - // -------------------------------------------------------------------------- describe('Callbacks', () => { it('should call onClick when card is clicked in general mode', () => { const onClick = vi.fn() @@ -501,9 +466,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // Memoization Logic Tests - // -------------------------------------------------------------------------- describe('Memoization Logic', () => { it('should compute isGeneralMode correctly for text mode - show keywords', () => { mockDocForm.current = ChunkingMode.text @@ -550,8 +513,7 @@ describe('SegmentCard', () => { render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) - // ChildSegmentList should render - expect(screen.getByText(/child chunk/i)).toBeInTheDocument() + expect(screen.getByText(/datasetDocuments\.segment\.childChunks/)).toBeInTheDocument() }) it('should compute chunkEdited correctly when updated_at > created_at', () => { @@ -630,13 +592,11 @@ describe('SegmentCard', () => { render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) - expect(screen.getByText('1 character')).toBeInTheDocument() + expect(screen.getByText('1 datasetDocuments.segment.characters:{"count":1}')).toBeInTheDocument() }) }) - // -------------------------------------------------------------------------- // Mode-specific Rendering Tests - // -------------------------------------------------------------------------- describe('Mode-specific Rendering', () => { it('should render without padding classes in full-doc mode', () => { mockDocForm.current = ChunkingMode.parentChild @@ -673,9 +633,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // Child Segment List Tests - // -------------------------------------------------------------------------- describe('Child Segment List', () => { it('should render ChildSegmentList when in paragraph mode with child chunks', () => { mockDocForm.current = ChunkingMode.parentChild @@ -685,7 +643,7 @@ describe('SegmentCard', () => { render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) - expect(screen.getByText(/2 child chunks/i)).toBeInTheDocument() + expect(screen.getByText(/2 datasetDocuments\.segment\.childChunks/)).toBeInTheDocument() }) it('should not render ChildSegmentList when child_chunks is empty', () => { @@ -733,9 +691,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // Keywords Display Tests - // -------------------------------------------------------------------------- describe('Keywords Display', () => { it('should render keywords with # prefix in general mode', () => { mockDocForm.current = ChunkingMode.text @@ -769,9 +725,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // Images Display Tests - // -------------------------------------------------------------------------- describe('Images Display', () => { it('should render ImageList when attachments exist', () => { const attachments = [createMockAttachment()] @@ -792,9 +746,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // Edge Cases and Error Handling Tests - // -------------------------------------------------------------------------- describe('Edge Cases and Error Handling', () => { it('should handle undefined detail gracefully', () => { render(<SegmentCard loading={false} detail={undefined} focused={defaultFocused} />) @@ -850,7 +802,7 @@ describe('SegmentCard', () => { render(<SegmentCard loading={false} detail={detail} focused={defaultFocused} />) - expect(screen.getByText('0 characters')).toBeInTheDocument() + expect(screen.getByText('0 datasetDocuments.segment.characters:{"count":0}')).toBeInTheDocument() }) it('should handle zero hit count', () => { @@ -872,9 +824,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // Component Integration Tests - // -------------------------------------------------------------------------- describe('Component Integration', () => { it('should render real Tag component with hashtag styling', () => { mockDocForm.current = ChunkingMode.text @@ -963,9 +913,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // All Props Variations Tests - // -------------------------------------------------------------------------- describe('All Props Variations', () => { it('should render correctly with all props provided', () => { mockDocForm.current = ChunkingMode.parentChild @@ -1032,9 +980,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // ChunkContent QA Mode Tests - cover lines 25-49 - // -------------------------------------------------------------------------- describe('ChunkContent QA Mode', () => { it('should render Q and A sections when answer is provided', () => { const detail = createMockSegmentDetail({ @@ -1135,9 +1081,7 @@ describe('SegmentCard', () => { }) }) - // -------------------------------------------------------------------------- // ChunkContent Non-QA Mode Tests - ensure full coverage - // -------------------------------------------------------------------------- describe('ChunkContent Non-QA Mode', () => { it('should apply line-clamp-3 in fullDocMode', () => { mockDocForm.current = ChunkingMode.parentChild diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/full-doc-list-skeleton.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.spec.tsx rename to web/app/components/datasets/documents/detail/completed/skeleton/__tests__/full-doc-list-skeleton.spec.tsx index 08ba55cc35..c45637c8f4 100644 --- a/web/app/components/datasets/documents/detail/completed/skeleton/full-doc-list-skeleton.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/full-doc-list-skeleton.spec.tsx @@ -1,20 +1,16 @@ import { render } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import FullDocListSkeleton from './full-doc-list-skeleton' +import FullDocListSkeleton from '../full-doc-list-skeleton' describe('FullDocListSkeleton', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the correct number of slice elements', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) // Assert - component renders 15 slices @@ -23,7 +19,6 @@ describe('FullDocListSkeleton', () => { }) it('should render mask overlay element', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) // Assert - check for the mask overlay element @@ -32,10 +27,8 @@ describe('FullDocListSkeleton', () => { }) it('should render with correct container classes', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) - // Assert const containerElement = container.firstChild as HTMLElement expect(containerElement).toHaveClass('relative') expect(containerElement).toHaveClass('z-10') @@ -48,10 +41,8 @@ describe('FullDocListSkeleton', () => { }) }) - // Structure tests describe('Structure', () => { it('should render slice elements with proper structure', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) // Assert - each slice should have the content placeholder elements @@ -63,7 +54,6 @@ describe('FullDocListSkeleton', () => { }) it('should render slice with width placeholder elements', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) // Assert - check for skeleton content width class @@ -72,7 +62,6 @@ describe('FullDocListSkeleton', () => { }) it('should render slice elements with background classes', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) // Assert - check for skeleton background classes @@ -81,10 +70,8 @@ describe('FullDocListSkeleton', () => { }) }) - // Memoization tests describe('Memoization', () => { it('should render consistently across multiple renders', () => { - // Arrange & Act const { container: container1 } = render(<FullDocListSkeleton />) const { container: container2 } = render(<FullDocListSkeleton />) @@ -95,23 +82,18 @@ describe('FullDocListSkeleton', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rendered multiple times', () => { - // Arrange const { rerender, container } = render(<FullDocListSkeleton />) - // Act rerender(<FullDocListSkeleton />) rerender(<FullDocListSkeleton />) - // Assert const sliceElements = container.querySelectorAll('.flex.flex-col.gap-y-1') expect(sliceElements).toHaveLength(15) }) it('should not have accessibility issues with skeleton content', () => { - // Arrange & Act const { container } = render(<FullDocListSkeleton />) // Assert - skeleton should be purely visual, no interactive elements diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/general-list-skeleton.spec.tsx similarity index 88% rename from web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.spec.tsx rename to web/app/components/datasets/documents/detail/completed/skeleton/__tests__/general-list-skeleton.spec.tsx index 0430724671..54e36019c4 100644 --- a/web/app/components/datasets/documents/detail/completed/skeleton/general-list-skeleton.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/general-list-skeleton.spec.tsx @@ -1,20 +1,16 @@ import { render } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import GeneralListSkeleton, { CardSkelton } from './general-list-skeleton' +import GeneralListSkeleton, { CardSkelton } from '../general-list-skeleton' describe('CardSkelton', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<CardSkelton />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render skeleton rows', () => { - // Arrange & Act const { container } = render(<CardSkelton />) // Assert - component should have skeleton rectangle elements @@ -23,19 +19,15 @@ describe('CardSkelton', () => { }) it('should render with proper container padding', () => { - // Arrange & Act const { container } = render(<CardSkelton />) - // Assert expect(container.querySelector('.p-1')).toBeInTheDocument() expect(container.querySelector('.pb-2')).toBeInTheDocument() }) }) - // Structure tests describe('Structure', () => { it('should render skeleton points as separators', () => { - // Arrange & Act const { container } = render(<CardSkelton />) // Assert - check for opacity class on skeleton points @@ -44,7 +36,6 @@ describe('CardSkelton', () => { }) it('should render width-constrained skeleton elements', () => { - // Arrange & Act const { container } = render(<CardSkelton />) // Assert - check for various width classes @@ -56,18 +47,14 @@ describe('CardSkelton', () => { }) describe('GeneralListSkeleton', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the correct number of list items', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) // Assert - component renders 10 items (Checkbox is a div with shrink-0 and h-4 w-4) @@ -76,19 +63,15 @@ describe('GeneralListSkeleton', () => { }) it('should render mask overlay element', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) - // Assert const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg') expect(maskElement).toBeInTheDocument() }) it('should render with correct container classes', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) - // Assert const containerElement = container.firstChild as HTMLElement expect(containerElement).toHaveClass('relative') expect(containerElement).toHaveClass('z-10') @@ -102,7 +85,6 @@ describe('GeneralListSkeleton', () => { // Checkbox tests describe('Checkboxes', () => { it('should render disabled checkboxes', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) // Assert - Checkbox component uses cursor-not-allowed class when disabled @@ -111,10 +93,8 @@ describe('GeneralListSkeleton', () => { }) it('should render checkboxes with shrink-0 class for consistent sizing', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) - // Assert const checkboxContainers = container.querySelectorAll('.shrink-0') expect(checkboxContainers.length).toBeGreaterThan(0) }) @@ -123,7 +103,6 @@ describe('GeneralListSkeleton', () => { // Divider tests describe('Dividers', () => { it('should render dividers between items except for the last one', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) // Assert - should have 9 dividers (not after last item) @@ -132,19 +111,15 @@ describe('GeneralListSkeleton', () => { }) }) - // Structure tests describe('Structure', () => { it('should render list items with proper gap styling', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) - // Assert const listItems = container.querySelectorAll('.gap-x-2') expect(listItems.length).toBeGreaterThan(0) }) it('should render CardSkelton inside each list item', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) // Assert - each list item should contain card skeleton content @@ -153,39 +128,30 @@ describe('GeneralListSkeleton', () => { }) }) - // Memoization tests describe('Memoization', () => { it('should render consistently across multiple renders', () => { - // Arrange & Act const { container: container1 } = render(<GeneralListSkeleton />) const { container: container2 } = render(<GeneralListSkeleton />) - // Assert const checkboxes1 = container1.querySelectorAll('input[type="checkbox"]') const checkboxes2 = container2.querySelectorAll('input[type="checkbox"]') expect(checkboxes1.length).toBe(checkboxes2.length) }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rerendered', () => { - // Arrange const { rerender, container } = render(<GeneralListSkeleton />) - // Act rerender(<GeneralListSkeleton />) - // Assert const listItems = container.querySelectorAll('.items-start.gap-x-2') expect(listItems).toHaveLength(10) }) it('should not have interactive elements besides disabled checkboxes', () => { - // Arrange & Act const { container } = render(<GeneralListSkeleton />) - // Assert const buttons = container.querySelectorAll('button') const links = container.querySelectorAll('a') expect(buttons).toHaveLength(0) diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/paragraph-list-skeleton.spec.tsx similarity index 88% rename from web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.spec.tsx rename to web/app/components/datasets/documents/detail/completed/skeleton/__tests__/paragraph-list-skeleton.spec.tsx index a26b357e1e..556a9de50f 100644 --- a/web/app/components/datasets/documents/detail/completed/skeleton/paragraph-list-skeleton.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/paragraph-list-skeleton.spec.tsx @@ -1,20 +1,16 @@ import { render } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import ParagraphListSkeleton from './paragraph-list-skeleton' +import ParagraphListSkeleton from '../paragraph-list-skeleton' describe('ParagraphListSkeleton', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the correct number of list items', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) // Assert - component renders 10 items @@ -23,19 +19,15 @@ describe('ParagraphListSkeleton', () => { }) it('should render mask overlay element', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) - // Assert const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg') expect(maskElement).toBeInTheDocument() }) it('should render with correct container classes', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) - // Assert const containerElement = container.firstChild as HTMLElement expect(containerElement).toHaveClass('relative') expect(containerElement).toHaveClass('z-10') @@ -49,7 +41,6 @@ describe('ParagraphListSkeleton', () => { // Checkbox tests describe('Checkboxes', () => { it('should render disabled checkboxes', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) // Assert - Checkbox component uses cursor-not-allowed class when disabled @@ -58,10 +49,8 @@ describe('ParagraphListSkeleton', () => { }) it('should render checkboxes with shrink-0 class for consistent sizing', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) - // Assert const checkboxContainers = container.querySelectorAll('.shrink-0') expect(checkboxContainers.length).toBeGreaterThan(0) }) @@ -70,7 +59,6 @@ describe('ParagraphListSkeleton', () => { // Divider tests describe('Dividers', () => { it('should render dividers between items except for the last one', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) // Assert - should have 9 dividers (not after last item) @@ -79,10 +67,8 @@ describe('ParagraphListSkeleton', () => { }) }) - // Structure tests describe('Structure', () => { it('should render arrow icon for expand button styling', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) // Assert - paragraph list skeleton has expand button styled area @@ -91,16 +77,13 @@ describe('ParagraphListSkeleton', () => { }) it('should render skeleton rectangles with quaternary text color', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) - // Assert const skeletonElements = container.querySelectorAll('.bg-text-quaternary') expect(skeletonElements.length).toBeGreaterThan(0) }) it('should render CardSkelton inside each list item', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) // Assert - each list item should contain card skeleton content @@ -109,39 +92,30 @@ describe('ParagraphListSkeleton', () => { }) }) - // Memoization tests describe('Memoization', () => { it('should render consistently across multiple renders', () => { - // Arrange & Act const { container: container1 } = render(<ParagraphListSkeleton />) const { container: container2 } = render(<ParagraphListSkeleton />) - // Assert const items1 = container1.querySelectorAll('.items-start.gap-x-2') const items2 = container2.querySelectorAll('.items-start.gap-x-2') expect(items1.length).toBe(items2.length) }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rerendered', () => { - // Arrange const { rerender, container } = render(<ParagraphListSkeleton />) - // Act rerender(<ParagraphListSkeleton />) - // Assert const listItems = container.querySelectorAll('.items-start.gap-x-2') expect(listItems).toHaveLength(10) }) it('should not have interactive elements besides disabled checkboxes', () => { - // Arrange & Act const { container } = render(<ParagraphListSkeleton />) - // Assert const buttons = container.querySelectorAll('button') const links = container.querySelectorAll('a') expect(buttons).toHaveLength(0) diff --git a/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.spec.tsx b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/parent-chunk-card-skeleton.spec.tsx similarity index 87% rename from web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.spec.tsx rename to web/app/components/datasets/documents/detail/completed/skeleton/__tests__/parent-chunk-card-skeleton.spec.tsx index 71d15a9178..9e6e74e7a6 100644 --- a/web/app/components/datasets/documents/detail/completed/skeleton/parent-chunk-card-skeleton.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/skeleton/__tests__/parent-chunk-card-skeleton.spec.tsx @@ -1,23 +1,18 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import ParentChunkCardSkelton from './parent-chunk-card-skeleton' +import ParentChunkCardSkelton from '../parent-chunk-card-skeleton' describe('ParentChunkCardSkelton', () => { - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act render(<ParentChunkCardSkelton />) - // Assert expect(screen.getByTestId('parent-chunk-card-skeleton')).toBeInTheDocument() }) it('should render with correct container classes', () => { - // Arrange & Act render(<ParentChunkCardSkelton />) - // Assert const container = screen.getByTestId('parent-chunk-card-skeleton') expect(container).toHaveClass('flex') expect(container).toHaveClass('flex-col') @@ -25,10 +20,8 @@ describe('ParentChunkCardSkelton', () => { }) it('should render skeleton rectangles', () => { - // Arrange & Act const { container } = render(<ParentChunkCardSkelton />) - // Assert const skeletonRectangles = container.querySelectorAll('.bg-text-quaternary') expect(skeletonRectangles.length).toBeGreaterThan(0) }) @@ -37,7 +30,6 @@ describe('ParentChunkCardSkelton', () => { // i18n tests describe('i18n', () => { it('should render view more button with translated text', () => { - // Arrange & Act render(<ParentChunkCardSkelton />) // Assert - the button should contain translated text @@ -46,28 +38,22 @@ describe('ParentChunkCardSkelton', () => { }) it('should render disabled view more button', () => { - // Arrange & Act render(<ParentChunkCardSkelton />) - // Assert const viewMoreButton = screen.getByRole('button') expect(viewMoreButton).toBeDisabled() }) }) - // Structure tests describe('Structure', () => { it('should render skeleton points as separators', () => { - // Arrange & Act const { container } = render(<ParentChunkCardSkelton />) - // Assert const opacityElements = container.querySelectorAll('.opacity-20') expect(opacityElements.length).toBeGreaterThan(0) }) it('should render width-constrained skeleton elements', () => { - // Arrange & Act const { container } = render(<ParentChunkCardSkelton />) // Assert - check for various width classes @@ -78,50 +64,39 @@ describe('ParentChunkCardSkelton', () => { }) it('should render button with proper styling classes', () => { - // Arrange & Act render(<ParentChunkCardSkelton />) - // Assert const button = screen.getByRole('button') expect(button).toHaveClass('system-xs-semibold-uppercase') expect(button).toHaveClass('text-components-button-secondary-accent-text-disabled') }) }) - // Memoization tests describe('Memoization', () => { it('should render consistently across multiple renders', () => { - // Arrange & Act const { container: container1 } = render(<ParentChunkCardSkelton />) const { container: container2 } = render(<ParentChunkCardSkelton />) - // Assert const skeletons1 = container1.querySelectorAll('.bg-text-quaternary') const skeletons2 = container2.querySelectorAll('.bg-text-quaternary') expect(skeletons1.length).toBe(skeletons2.length) }) }) - // Edge cases describe('Edge Cases', () => { it('should maintain structure when rerendered', () => { - // Arrange const { rerender, container } = render(<ParentChunkCardSkelton />) - // Act rerender(<ParentChunkCardSkelton />) - // Assert expect(screen.getByTestId('parent-chunk-card-skeleton')).toBeInTheDocument() const skeletons = container.querySelectorAll('.bg-text-quaternary') expect(skeletons.length).toBeGreaterThan(0) }) it('should have only one interactive element (disabled button)', () => { - // Arrange & Act const { container } = render(<ParentChunkCardSkelton />) - // Assert const buttons = container.querySelectorAll('button') const links = container.querySelectorAll('a') expect(buttons).toHaveLength(1) diff --git a/web/app/components/datasets/documents/detail/embedding/index.spec.tsx b/web/app/components/datasets/documents/detail/embedding/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/detail/embedding/index.spec.tsx rename to web/app/components/datasets/documents/detail/embedding/__tests__/index.spec.tsx index 699de4f12a..b97f824c27 100644 --- a/web/app/components/datasets/documents/detail/embedding/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import type { DocumentContextValue } from '../context' +import type { DocumentContextValue } from '../../context' import type { IndexingStatusResponse, ProcessRuleResponse } from '@/models/datasets' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' @@ -9,9 +9,9 @@ import { ProcessMode } from '@/models/datasets' import * as datasetsService from '@/service/datasets' import * as useDataset from '@/service/knowledge/use-dataset' import { RETRIEVE_METHOD } from '@/types/app' -import { IndexingType } from '../../../create/step-two' -import { DocumentContext } from '../context' -import EmbeddingDetail from './index' +import { IndexingType } from '../../../../create/step-two' +import { DocumentContext } from '../../context' +import EmbeddingDetail from '../index' vi.mock('@/service/datasets') vi.mock('@/service/knowledge/use-dataset') diff --git a/web/app/components/datasets/documents/detail/embedding/components/progress-bar.spec.tsx b/web/app/components/datasets/documents/detail/embedding/components/__tests__/progress-bar.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/detail/embedding/components/progress-bar.spec.tsx rename to web/app/components/datasets/documents/detail/embedding/components/__tests__/progress-bar.spec.tsx index b54c8000fe..c4c6501eef 100644 --- a/web/app/components/datasets/documents/detail/embedding/components/progress-bar.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/components/__tests__/progress-bar.spec.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import ProgressBar from './progress-bar' +import ProgressBar from '../progress-bar' describe('ProgressBar', () => { const defaultProps = { diff --git a/web/app/components/datasets/documents/detail/embedding/components/rule-detail.spec.tsx b/web/app/components/datasets/documents/detail/embedding/components/__tests__/rule-detail.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/detail/embedding/components/rule-detail.spec.tsx rename to web/app/components/datasets/documents/detail/embedding/components/__tests__/rule-detail.spec.tsx index 138a4eacd8..981f26934c 100644 --- a/web/app/components/datasets/documents/detail/embedding/components/rule-detail.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/components/__tests__/rule-detail.spec.tsx @@ -3,8 +3,8 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { ProcessMode } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import { IndexingType } from '../../../../create/step-two' -import RuleDetail from './rule-detail' +import { IndexingType } from '../../../../../create/step-two' +import RuleDetail from '../rule-detail' describe('RuleDetail', () => { const defaultProps = { diff --git a/web/app/components/datasets/documents/detail/embedding/components/segment-progress.spec.tsx b/web/app/components/datasets/documents/detail/embedding/components/__tests__/segment-progress.spec.tsx similarity index 98% rename from web/app/components/datasets/documents/detail/embedding/components/segment-progress.spec.tsx rename to web/app/components/datasets/documents/detail/embedding/components/__tests__/segment-progress.spec.tsx index 1afc2f42f1..8f8ee26140 100644 --- a/web/app/components/datasets/documents/detail/embedding/components/segment-progress.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/components/__tests__/segment-progress.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import SegmentProgress from './segment-progress' +import SegmentProgress from '../segment-progress' describe('SegmentProgress', () => { const defaultProps = { diff --git a/web/app/components/datasets/documents/detail/embedding/components/status-header.spec.tsx b/web/app/components/datasets/documents/detail/embedding/components/__tests__/status-header.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/detail/embedding/components/status-header.spec.tsx rename to web/app/components/datasets/documents/detail/embedding/components/__tests__/status-header.spec.tsx index 519d2f3aa8..33d34769e9 100644 --- a/web/app/components/datasets/documents/detail/embedding/components/status-header.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/components/__tests__/status-header.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import StatusHeader from './status-header' +import StatusHeader from '../status-header' describe('StatusHeader', () => { const defaultProps = { diff --git a/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.spec.tsx b/web/app/components/datasets/documents/detail/embedding/hooks/__tests__/use-embedding-status.spec.tsx similarity index 99% rename from web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.spec.tsx rename to web/app/components/datasets/documents/detail/embedding/hooks/__tests__/use-embedding-status.spec.tsx index 7cadc12dfc..893484afeb 100644 --- a/web/app/components/datasets/documents/detail/embedding/hooks/use-embedding-status.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/hooks/__tests__/use-embedding-status.spec.tsx @@ -12,7 +12,7 @@ import { useInvalidateEmbeddingStatus, usePauseIndexing, useResumeIndexing, -} from './use-embedding-status' +} from '../use-embedding-status' vi.mock('@/service/datasets') diff --git a/web/app/components/datasets/documents/detail/embedding/skeleton/index.spec.tsx b/web/app/components/datasets/documents/detail/embedding/skeleton/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/datasets/documents/detail/embedding/skeleton/index.spec.tsx rename to web/app/components/datasets/documents/detail/embedding/skeleton/__tests__/index.spec.tsx index e0dc60b668..b350ce8a20 100644 --- a/web/app/components/datasets/documents/detail/embedding/skeleton/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/embedding/skeleton/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import EmbeddingSkeleton from './index' +import EmbeddingSkeleton from '../index' // Mock Skeleton components vi.mock('@/app/components/base/skeleton', () => ({ diff --git a/web/app/components/datasets/documents/detail/metadata/index.spec.tsx b/web/app/components/datasets/documents/detail/metadata/__tests__/index.spec.tsx similarity index 93% rename from web/app/components/datasets/documents/detail/metadata/index.spec.tsx rename to web/app/components/datasets/documents/detail/metadata/__tests__/index.spec.tsx index 6efc9661d5..0e385106b6 100644 --- a/web/app/components/datasets/documents/detail/metadata/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/metadata/__tests__/index.spec.tsx @@ -1,16 +1,15 @@ import type { FullDocumentDetail } from '@/models/datasets' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Metadata, { FieldInfo } from './index' +import Metadata, { FieldInfo } from '../index' // Mock document context -vi.mock('../context', () => ({ +vi.mock('../../context', () => ({ useDocumentContext: (selector: (state: { datasetId: string, documentId: string }) => unknown) => { return selector({ datasetId: 'test-dataset-id', documentId: 'test-document-id' }) }, })) -// Mock ToastContext const mockNotify = vi.fn() vi.mock('use-context-selector', async (importOriginal) => { const actual = await importOriginal() as Record<string, unknown> @@ -161,31 +160,24 @@ describe('Metadata', () => { describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<Metadata {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render metadata title', () => { - // Arrange & Act render(<Metadata {...defaultProps} />) - // Assert expect(screen.getByText(/metadata\.title/i)).toBeInTheDocument() }) it('should render edit button', () => { - // Arrange & Act render(<Metadata {...defaultProps} />) - // Assert expect(screen.getByText(/operation\.edit/i)).toBeInTheDocument() }) it('should show loading state', () => { - // Arrange & Act render(<Metadata {...defaultProps} loading={true} />) // Assert - Loading component should be rendered, title should not @@ -193,10 +185,8 @@ describe('Metadata', () => { }) it('should display document type icon and text', () => { - // Arrange & Act render(<Metadata {...defaultProps} />) - // Assert expect(screen.getByText('Book')).toBeInTheDocument() }) }) @@ -204,36 +194,28 @@ describe('Metadata', () => { // Edit mode (tests useMetadataState hook integration) describe('Edit Mode', () => { it('should enter edit mode when edit button is clicked', () => { - // Arrange render(<Metadata {...defaultProps} />) - // Act fireEvent.click(screen.getByText(/operation\.edit/i)) - // Assert expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() expect(screen.getByText(/operation\.save/i)).toBeInTheDocument() }) it('should show change link in edit mode', () => { - // Arrange render(<Metadata {...defaultProps} />) - // Act fireEvent.click(screen.getByText(/operation\.edit/i)) - // Assert expect(screen.getByText(/operation\.change/i)).toBeInTheDocument() }) it('should cancel edit and restore values when cancel is clicked', () => { - // Arrange render(<Metadata {...defaultProps} />) // Enter edit mode fireEvent.click(screen.getByText(/operation\.edit/i)) - // Act fireEvent.click(screen.getByText(/operation\.cancel/i)) // Assert - should be back to view mode @@ -241,34 +223,28 @@ describe('Metadata', () => { }) it('should save metadata when save button is clicked', async () => { - // Arrange mockModifyDocMetadata.mockResolvedValueOnce({}) render(<Metadata {...defaultProps} />) // Enter edit mode fireEvent.click(screen.getByText(/operation\.edit/i)) - // Act fireEvent.click(screen.getByText(/operation\.save/i)) - // Assert await waitFor(() => { expect(mockModifyDocMetadata).toHaveBeenCalled() }) }) it('should show success notification after successful save', async () => { - // Arrange mockModifyDocMetadata.mockResolvedValueOnce({}) render(<Metadata {...defaultProps} />) // Enter edit mode fireEvent.click(screen.getByText(/operation\.edit/i)) - // Act fireEvent.click(screen.getByText(/operation\.save/i)) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -279,17 +255,14 @@ describe('Metadata', () => { }) it('should show error notification after failed save', async () => { - // Arrange mockModifyDocMetadata.mockRejectedValueOnce(new Error('Save failed')) render(<Metadata {...defaultProps} />) // Enter edit mode fireEvent.click(screen.getByText(/operation\.edit/i)) - // Act fireEvent.click(screen.getByText(/operation\.save/i)) - // Assert await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ @@ -303,49 +276,38 @@ describe('Metadata', () => { // Document type selection (tests DocTypeSelector sub-component integration) describe('Document Type Selection', () => { it('should show doc type selection when no doc_type exists', () => { - // Arrange const docDetail = createMockDocDetail({ doc_type: '' }) - // Act render(<Metadata {...defaultProps} docDetail={docDetail} />) - // Assert expect(screen.getByText(/metadata\.docTypeSelectTitle/i)).toBeInTheDocument() }) it('should show description when no doc_type exists', () => { - // Arrange const docDetail = createMockDocDetail({ doc_type: '' }) - // Act render(<Metadata {...defaultProps} docDetail={docDetail} />) - // Assert expect(screen.getByText(/metadata\.desc/i)).toBeInTheDocument() }) it('should show change link in edit mode when doc_type exists', () => { - // Arrange render(<Metadata {...defaultProps} />) // Enter edit mode fireEvent.click(screen.getByText(/operation\.edit/i)) - // Assert expect(screen.getByText(/operation\.change/i)).toBeInTheDocument() }) it('should show doc type change title after clicking change', () => { - // Arrange render(<Metadata {...defaultProps} />) // Enter edit mode fireEvent.click(screen.getByText(/operation\.edit/i)) - // Act fireEvent.click(screen.getByText(/operation\.change/i)) - // Assert expect(screen.getByText(/metadata\.docTypeChangeTitle/i)).toBeInTheDocument() }) }) @@ -353,7 +315,6 @@ describe('Metadata', () => { // Fixed fields (tests MetadataFieldList sub-component integration) describe('Fixed Fields', () => { it('should render origin info fields', () => { - // Arrange & Act render(<Metadata {...defaultProps} />) // Assert @@ -361,22 +322,17 @@ describe('Metadata', () => { }) it('should render technical parameters fields', () => { - // Arrange & Act render(<Metadata {...defaultProps} />) - // Assert expect(screen.getByText('Segment Count')).toBeInTheDocument() expect(screen.getByText('Hit Count')).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle doc_type as others', () => { - // Arrange const docDetail = createMockDocDetail({ doc_type: 'others' }) - // Act const { container } = render(<Metadata {...defaultProps} docDetail={docDetail} />) // Assert @@ -384,7 +340,6 @@ describe('Metadata', () => { }) it('should handle undefined docDetail gracefully', () => { - // Arrange & Act const { container } = render(<Metadata {...defaultProps} docDetail={undefined} loading={false} />) // Assert @@ -392,7 +347,6 @@ describe('Metadata', () => { }) it('should update document type display when docDetail changes', () => { - // Arrange const { rerender } = render(<Metadata {...defaultProps} />) // Act - verify initial state shows Book @@ -402,7 +356,6 @@ describe('Metadata', () => { const updatedDocDetail = createMockDocDetail({ doc_type: 'paper' }) rerender(<Metadata {...defaultProps} docDetail={updatedDocDetail} />) - // Assert expect(screen.getByText('Paper')).toBeInTheDocument() }) }) @@ -410,13 +363,10 @@ describe('Metadata', () => { // First meta action button describe('First Meta Action Button', () => { it('should show first meta action button when no doc type exists', () => { - // Arrange const docDetail = createMockDocDetail({ doc_type: '' }) - // Act render(<Metadata {...defaultProps} docDetail={docDetail} />) - // Assert expect(screen.getByText(/metadata\.firstMetaAction/i)).toBeInTheDocument() }) }) @@ -436,26 +386,20 @@ describe('FieldInfo', () => { // Rendering describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<FieldInfo {...defaultFieldInfoProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render label', () => { - // Arrange & Act render(<FieldInfo {...defaultFieldInfoProps} />) - // Assert expect(screen.getByText('Test Label')).toBeInTheDocument() }) it('should render displayed value in view mode', () => { - // Arrange & Act render(<FieldInfo {...defaultFieldInfoProps} showEdit={false} />) - // Assert expect(screen.getByText('Test Display Value')).toBeInTheDocument() }) }) @@ -463,15 +407,12 @@ describe('FieldInfo', () => { // Edit mode describe('Edit Mode', () => { it('should render input when showEdit is true and inputType is input', () => { - // Arrange & Act render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="input" onUpdate={vi.fn()} />) - // Assert expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should render select when showEdit is true and inputType is select', () => { - // Arrange & Act render( <FieldInfo {...defaultFieldInfoProps} @@ -487,34 +428,26 @@ describe('FieldInfo', () => { }) it('should render textarea when showEdit is true and inputType is textarea', () => { - // Arrange & Act render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="textarea" onUpdate={vi.fn()} />) - // Assert expect(screen.getByRole('textbox')).toBeInTheDocument() }) it('should call onUpdate when input value changes', () => { - // Arrange const mockOnUpdate = vi.fn() render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="input" onUpdate={mockOnUpdate} />) - // Act fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Value' } }) - // Assert expect(mockOnUpdate).toHaveBeenCalledWith('New Value') }) it('should call onUpdate when textarea value changes', () => { - // Arrange const mockOnUpdate = vi.fn() render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="textarea" onUpdate={mockOnUpdate} />) - // Act fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Textarea Value' } }) - // Assert expect(mockOnUpdate).toHaveBeenCalledWith('New Textarea Value') }) }) @@ -522,18 +455,14 @@ describe('FieldInfo', () => { // Props describe('Props', () => { it('should render value icon when provided', () => { - // Arrange & Act render(<FieldInfo {...defaultFieldInfoProps} valueIcon={<span data-testid="value-icon">Icon</span>} />) - // Assert expect(screen.getByTestId('value-icon')).toBeInTheDocument() }) it('should use defaultValue when provided', () => { - // Arrange & Act render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="input" defaultValue="Default" onUpdate={vi.fn()} />) - // Assert const input = screen.getByRole('textbox') expect(input).toHaveAttribute('placeholder') }) diff --git a/web/app/components/datasets/documents/detail/segment-add/index.spec.tsx b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/detail/segment-add/index.spec.tsx rename to web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx index 2ae1c61da4..7f95e42bb7 100644 --- a/web/app/components/datasets/documents/detail/segment-add/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { Plan } from '@/app/components/billing/type' -import SegmentAdd, { ProcessStatus } from './index' +import SegmentAdd, { ProcessStatus } from '../index' // Mock provider context let mockPlan = { type: Plan.professional } @@ -57,29 +57,22 @@ describe('SegmentAdd', () => { embedding: false, } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<SegmentAdd {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render add button when no importStatus', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} />) - // Assert expect(screen.getByText(/list\.action\.addButton/i)).toBeInTheDocument() }) it('should render popover for batch add', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} />) - // Assert expect(screen.getByTestId('popover')).toBeInTheDocument() }) }) @@ -87,64 +80,49 @@ describe('SegmentAdd', () => { // Import Status displays describe('Import Status Display', () => { it('should show processing indicator when status is WAITING', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />) - // Assert expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument() }) it('should show processing indicator when status is PROCESSING', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />) - // Assert expect(screen.getByText(/list\.batchModal\.processing/i)).toBeInTheDocument() }) it('should show completed status with ok button', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.COMPLETED} />) - // Assert expect(screen.getByText(/list\.batchModal\.completed/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument() }) it('should show error status with ok button', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.ERROR} />) - // Assert expect(screen.getByText(/list\.batchModal\.error/i)).toBeInTheDocument() expect(screen.getByText(/list\.batchModal\.ok/i)).toBeInTheDocument() }) it('should not show add button when importStatus is set', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />) - // Assert expect(screen.queryByText(/list\.action\.addButton/i)).not.toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call showNewSegmentModal when add button is clicked', () => { - // Arrange const mockShowNewSegmentModal = vi.fn() render(<SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal} />) - // Act fireEvent.click(screen.getByText(/list\.action\.addButton/i)) - // Assert expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1) }) it('should call clearProcessStatus when ok is clicked on completed status', () => { - // Arrange const mockClearProcessStatus = vi.fn() render( <SegmentAdd @@ -154,15 +132,12 @@ describe('SegmentAdd', () => { />, ) - // Act fireEvent.click(screen.getByText(/list\.batchModal\.ok/i)) - // Assert expect(mockClearProcessStatus).toHaveBeenCalledTimes(1) }) it('should call clearProcessStatus when ok is clicked on error status', () => { - // Arrange const mockClearProcessStatus = vi.fn() render( <SegmentAdd @@ -172,30 +147,23 @@ describe('SegmentAdd', () => { />, ) - // Act fireEvent.click(screen.getByText(/list\.batchModal\.ok/i)) - // Assert expect(mockClearProcessStatus).toHaveBeenCalledTimes(1) }) it('should render batch add option in popover', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} />) - // Assert expect(screen.getByText(/list\.action\.batchAdd/i)).toBeInTheDocument() }) it('should call showBatchModal when batch add is clicked', () => { - // Arrange const mockShowBatchModal = vi.fn() render(<SegmentAdd {...defaultProps} showBatchModal={mockShowBatchModal} />) - // Act fireEvent.click(screen.getByText(/list\.action\.batchAdd/i)) - // Assert expect(mockShowBatchModal).toHaveBeenCalledTimes(1) }) }) @@ -203,27 +171,21 @@ describe('SegmentAdd', () => { // Disabled state (embedding) describe('Embedding State', () => { it('should disable add button when embedding is true', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} embedding={true} />) - // Assert const addButton = screen.getByText(/list\.action\.addButton/i).closest('button') expect(addButton).toBeDisabled() }) it('should disable popover button when embedding is true', () => { - // Arrange & Act render(<SegmentAdd {...defaultProps} embedding={true} />) - // Assert expect(screen.getByTestId('popover-btn')).toBeDisabled() }) it('should apply disabled styling when embedding is true', () => { - // Arrange & Act const { container } = render(<SegmentAdd {...defaultProps} embedding={true} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('border-components-button-secondary-border-disabled') }) @@ -232,46 +194,36 @@ describe('SegmentAdd', () => { // Plan upgrade modal describe('Plan Upgrade Modal', () => { it('should show plan upgrade modal when sandbox user tries to add', () => { - // Arrange mockPlan = { type: Plan.sandbox } render(<SegmentAdd {...defaultProps} />) - // Act fireEvent.click(screen.getByText(/list\.action\.addButton/i)) - // Assert expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() }) it('should not call showNewSegmentModal for sandbox users', () => { - // Arrange mockPlan = { type: Plan.sandbox } const mockShowNewSegmentModal = vi.fn() render(<SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal} />) - // Act fireEvent.click(screen.getByText(/list\.action\.addButton/i)) - // Assert expect(mockShowNewSegmentModal).not.toHaveBeenCalled() }) it('should allow add when billing is disabled regardless of plan', () => { - // Arrange mockPlan = { type: Plan.sandbox } mockEnableBilling = false const mockShowNewSegmentModal = vi.fn() render(<SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal} />) - // Act fireEvent.click(screen.getByText(/list\.action\.addButton/i)) - // Assert expect(mockShowNewSegmentModal).toHaveBeenCalledTimes(1) }) it('should close plan upgrade modal when close button is clicked', () => { - // Arrange mockPlan = { type: Plan.sandbox } render(<SegmentAdd {...defaultProps} />) @@ -279,10 +231,8 @@ describe('SegmentAdd', () => { fireEvent.click(screen.getByText(/list\.action\.addButton/i)) expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() - // Act fireEvent.click(screen.getByTestId('close-modal')) - // Assert expect(screen.queryByTestId('plan-upgrade-modal')).not.toBeInTheDocument() }) }) @@ -290,25 +240,20 @@ describe('SegmentAdd', () => { // Progress bar width tests describe('Progress Bar', () => { it('should show 3/12 width progress bar for WAITING status', () => { - // Arrange & Act const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.WAITING} />) - // Assert const progressBar = container.querySelector('.w-3\\/12') expect(progressBar).toBeInTheDocument() }) it('should show 2/3 width progress bar for PROCESSING status', () => { - // Arrange & Act const { container } = render(<SegmentAdd {...defaultProps} importStatus={ProcessStatus.PROCESSING} />) - // Assert const progressBar = container.querySelector('.w-2\\/3') expect(progressBar).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle unknown importStatus string', () => { // Arrange & Act - pass unknown status @@ -320,30 +265,24 @@ describe('SegmentAdd', () => { }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<SegmentAdd {...defaultProps} />) - // Act rerender(<SegmentAdd {...defaultProps} embedding={true} />) - // Assert const addButton = screen.getByText(/list\.action\.addButton/i).closest('button') expect(addButton).toBeDisabled() }) it('should handle callback change', () => { - // Arrange const mockShowNewSegmentModal1 = vi.fn() const mockShowNewSegmentModal2 = vi.fn() const { rerender } = render( <SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal1} />, ) - // Act rerender(<SegmentAdd {...defaultProps} showNewSegmentModal={mockShowNewSegmentModal2} />) fireEvent.click(screen.getByText(/list\.action\.addButton/i)) - // Assert expect(mockShowNewSegmentModal1).not.toHaveBeenCalled() expect(mockShowNewSegmentModal2).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/documents/detail/settings/document-settings.spec.tsx b/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx similarity index 90% rename from web/app/components/datasets/documents/detail/settings/document-settings.spec.tsx rename to web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx index 545a51bd49..e6109132a4 100644 --- a/web/app/components/datasets/documents/detail/settings/document-settings.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx @@ -1,9 +1,8 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import DocumentSettings from './document-settings' +import DocumentSettings from '../document-settings' -// Mock next/navigation const mockPush = vi.fn() const mockBack = vi.fn() vi.mock('next/navigation', () => ({ @@ -25,7 +24,6 @@ vi.mock('use-context-selector', async (importOriginal) => { } }) -// Mock hooks const mockInvalidDocumentList = vi.fn() const mockInvalidDocumentDetail = vi.fn() let mockDocumentDetail: Record<string, unknown> | null = { @@ -53,7 +51,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () }), })) -// Mock child components vi.mock('@/app/components/base/app-unavailable', () => ({ default: ({ code, unknownReason }: { code?: number, unknownReason?: string }) => ( <div data-testid="app-unavailable"> @@ -129,43 +126,32 @@ describe('DocumentSettings', () => { documentId: 'document-1', } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<DocumentSettings {...defaultProps} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render StepTwo component when data is loaded', () => { - // Arrange & Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('step-two')).toBeInTheDocument() }) it('should render loading when documentDetail is not available', () => { - // Arrange mockDocumentDetail = null - // Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('loading')).toBeInTheDocument() }) it('should render AppUnavailable when error occurs', () => { - // Arrange mockError = new Error('Error loading document') - // Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('app-unavailable')).toBeInTheDocument() expect(screen.getByTestId('error-code')).toHaveTextContent('500') }) @@ -174,85 +160,64 @@ describe('DocumentSettings', () => { // Props passing describe('Props Passing', () => { it('should pass datasetId to StepTwo', () => { - // Arrange & Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('dataset-id')).toHaveTextContent('dataset-1') }) it('should pass isSetting true to StepTwo', () => { - // Arrange & Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('is-setting')).toHaveTextContent('true') }) it('should pass isAPIKeySet when embedding model is available', () => { - // Arrange & Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('api-key-set')).toHaveTextContent('true') }) it('should pass data source type to StepTwo', () => { - // Arrange & Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('data-source-type')).toHaveTextContent('upload_file') }) }) - // User Interactions describe('User Interactions', () => { it('should call router.back when cancel is clicked', () => { - // Arrange render(<DocumentSettings {...defaultProps} />) - // Act fireEvent.click(screen.getByTestId('cancel-btn')) - // Assert expect(mockBack).toHaveBeenCalled() }) it('should navigate to document page when save is clicked', () => { - // Arrange render(<DocumentSettings {...defaultProps} />) - // Act fireEvent.click(screen.getByTestId('save-btn')) - // Assert expect(mockInvalidDocumentList).toHaveBeenCalled() expect(mockInvalidDocumentDetail).toHaveBeenCalled() expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/document-1') }) it('should show AccountSetting modal when setting button is clicked', () => { - // Arrange render(<DocumentSettings {...defaultProps} />) - // Act fireEvent.click(screen.getByTestId('setting-btn')) - // Assert expect(screen.getByTestId('account-setting')).toBeInTheDocument() }) it('should hide AccountSetting modal when close is clicked', async () => { - // Arrange render(<DocumentSettings {...defaultProps} />) fireEvent.click(screen.getByTestId('setting-btn')) expect(screen.getByTestId('account-setting')).toBeInTheDocument() - // Act fireEvent.click(screen.getByTestId('close-setting')) - // Assert expect(screen.queryByTestId('account-setting')).not.toBeInTheDocument() }) }) @@ -260,7 +225,6 @@ describe('DocumentSettings', () => { // Data source types describe('Data Source Types', () => { it('should handle legacy upload_file data source', () => { - // Arrange mockDocumentDetail = { name: 'test-document', data_source_type: 'upload_file', @@ -269,15 +233,12 @@ describe('DocumentSettings', () => { }, } - // Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('files-count')).toHaveTextContent('1') }) it('should handle website crawl data source', () => { - // Arrange mockDocumentDetail = { name: 'test-website', data_source_type: 'website_crawl', @@ -289,15 +250,12 @@ describe('DocumentSettings', () => { }, } - // Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('data-source-type')).toHaveTextContent('website_crawl') }) it('should handle local file data source', () => { - // Arrange mockDocumentDetail = { name: 'local-file', data_source_type: 'upload_file', @@ -309,15 +267,12 @@ describe('DocumentSettings', () => { }, } - // Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('files-count')).toHaveTextContent('1') }) it('should handle online document (Notion) data source', () => { - // Arrange mockDocumentDetail = { name: 'notion-page', data_source_type: 'notion_import', @@ -333,41 +288,32 @@ describe('DocumentSettings', () => { }, } - // Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('data-source-type')).toHaveTextContent('notion_import') }) }) - // Edge cases describe('Edge Cases', () => { it('should handle undefined data_source_info', () => { - // Arrange mockDocumentDetail = { name: 'test-document', data_source_type: 'upload_file', data_source_info: undefined, } - // Act render(<DocumentSettings {...defaultProps} />) - // Assert expect(screen.getByTestId('files-count')).toHaveTextContent('0') }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render( <DocumentSettings datasetId="dataset-1" documentId="doc-1" />, ) - // Act rerender(<DocumentSettings datasetId="dataset-2" documentId="doc-2" />) - // Assert expect(screen.getByTestId('step-two')).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/settings/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/datasets/documents/detail/settings/index.spec.tsx rename to web/app/components/datasets/documents/detail/settings/__tests__/index.spec.tsx index 3a7c10a0be..e7cd851724 100644 --- a/web/app/components/datasets/documents/detail/settings/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Settings from './index' +import Settings from '../index' // Mock the dataset detail context let mockRuntimeMode: string | undefined = 'general' @@ -11,8 +11,7 @@ vi.mock('@/context/dataset-detail', () => ({ }, })) -// Mock child components -vi.mock('./document-settings', () => ({ +vi.mock('../document-settings', () => ({ default: ({ datasetId, documentId }: { datasetId: string, documentId: string }) => ( <div data-testid="document-settings"> DocumentSettings - @@ -26,7 +25,7 @@ vi.mock('./document-settings', () => ({ ), })) -vi.mock('./pipeline-settings', () => ({ +vi.mock('../pipeline-settings', () => ({ default: ({ datasetId, documentId }: { datasetId: string, documentId: string }) => ( <div data-testid="pipeline-settings"> PipelineSettings - @@ -46,15 +45,12 @@ describe('Settings', () => { mockRuntimeMode = 'general' }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render( <Settings datasetId="dataset-1" documentId="doc-1" />, ) - // Assert expect(container.firstChild).toBeInTheDocument() }) }) @@ -62,25 +58,19 @@ describe('Settings', () => { // Conditional rendering tests describe('Conditional Rendering', () => { it('should render DocumentSettings when runtimeMode is general', () => { - // Arrange mockRuntimeMode = 'general' - // Act render(<Settings datasetId="dataset-1" documentId="doc-1" />) - // Assert expect(screen.getByTestId('document-settings')).toBeInTheDocument() expect(screen.queryByTestId('pipeline-settings')).not.toBeInTheDocument() }) it('should render PipelineSettings when runtimeMode is not general', () => { - // Arrange mockRuntimeMode = 'pipeline' - // Act render(<Settings datasetId="dataset-1" documentId="doc-1" />) - // Assert expect(screen.getByTestId('pipeline-settings')).toBeInTheDocument() expect(screen.queryByTestId('document-settings')).not.toBeInTheDocument() }) @@ -89,37 +79,28 @@ describe('Settings', () => { // Props passing tests describe('Props', () => { it('should pass datasetId and documentId to DocumentSettings', () => { - // Arrange mockRuntimeMode = 'general' - // Act render(<Settings datasetId="test-dataset" documentId="test-document" />) - // Assert expect(screen.getByText(/test-dataset/)).toBeInTheDocument() expect(screen.getByText(/test-document/)).toBeInTheDocument() }) it('should pass datasetId and documentId to PipelineSettings', () => { - // Arrange mockRuntimeMode = 'pipeline' - // Act render(<Settings datasetId="test-dataset" documentId="test-document" />) - // Assert expect(screen.getByText(/test-dataset/)).toBeInTheDocument() expect(screen.getByText(/test-document/)).toBeInTheDocument() }) }) - // Edge cases describe('Edge Cases', () => { it('should handle undefined runtimeMode as non-general', () => { - // Arrange mockRuntimeMode = undefined - // Act render(<Settings datasetId="dataset-1" documentId="doc-1" />) // Assert - undefined !== 'general', so PipelineSettings should render @@ -127,16 +108,13 @@ describe('Settings', () => { }) it('should maintain structure when rerendered', () => { - // Arrange mockRuntimeMode = 'general' const { rerender } = render( <Settings datasetId="dataset-1" documentId="doc-1" />, ) - // Act rerender(<Settings datasetId="dataset-2" documentId="doc-2" />) - // Assert expect(screen.getByText(/dataset-2/)).toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx similarity index 94% rename from web/app/components/datasets/documents/detail/settings/pipeline-settings/index.spec.tsx rename to web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx index efec45be0b..9f2ccc0acd 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx @@ -2,7 +2,7 @@ import type { PipelineExecutionLogResponse } from '@/models/pipeline' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { DatasourceType } from '@/models/pipeline' -import PipelineSettings from './index' +import PipelineSettings from '../index' // Mock Next.js router const mockPush = vi.fn() @@ -44,7 +44,7 @@ vi.mock('@/service/knowledge/use-document', () => ({ })) // Mock Form component in ProcessDocuments - internal dependencies are too complex -vi.mock('../../../create-from-pipeline/process-documents/form', () => ({ +vi.mock('../../../../create-from-pipeline/process-documents/form', () => ({ default: function MockForm({ ref, initialData, @@ -88,7 +88,7 @@ vi.mock('../../../create-from-pipeline/process-documents/form', () => ({ })) // Mock ChunkPreview - has complex internal state and many dependencies -vi.mock('../../../create-from-pipeline/preview/chunk-preview', () => ({ +vi.mock('../../../../create-from-pipeline/preview/chunk-preview', () => ({ default: function MockChunkPreview({ dataSourceType, localFiles, @@ -123,7 +123,6 @@ vi.mock('../../../create-from-pipeline/preview/chunk-preview', () => ({ }, })) -// Test utilities const createQueryClient = () => new QueryClient({ defaultOptions: { @@ -189,10 +188,8 @@ describe('PipelineSettings', () => { // Test basic rendering with real components describe('Rendering', () => { it('should render without crashing when data is loaded', () => { - // Arrange const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) // Assert - Real LeftHeader should render with correct content @@ -205,7 +202,6 @@ describe('PipelineSettings', () => { }) it('should render Loading component when fetching data', () => { - // Arrange mockUsePipelineExecutionLog.mockReturnValue({ data: undefined, isFetching: true, @@ -213,7 +209,6 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) // Assert - Loading component should be rendered, not main content @@ -222,7 +217,6 @@ describe('PipelineSettings', () => { }) it('should render AppUnavailable when there is an error', () => { - // Arrange mockUsePipelineExecutionLog.mockReturnValue({ data: undefined, isFetching: false, @@ -230,7 +224,6 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) // Assert - AppUnavailable should be rendered @@ -238,13 +231,10 @@ describe('PipelineSettings', () => { }) it('should render container with correct CSS classes', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = renderWithProviders(<PipelineSettings {...props} />) - // Assert const mainContainer = container.firstChild as HTMLElement expect(mainContainer).toHaveClass('relative', 'flex', 'min-w-[1024px]') }) @@ -254,10 +244,8 @@ describe('PipelineSettings', () => { // Test real LeftHeader component behavior describe('LeftHeader Integration', () => { it('should render LeftHeader with title prop', () => { - // Arrange const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) // Assert - LeftHeader displays the title @@ -265,10 +253,8 @@ describe('PipelineSettings', () => { }) it('should render back button in LeftHeader', () => { - // Arrange const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) // Assert - Back button should exist with proper aria-label @@ -277,15 +263,12 @@ describe('PipelineSettings', () => { }) it('should call router.back when back button is clicked', () => { - // Arrange const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) const backButton = screen.getByRole('button', { name: 'common.operation.back' }) fireEvent.click(backButton) - // Assert expect(mockBack).toHaveBeenCalledTimes(1) }) }) @@ -293,13 +276,10 @@ describe('PipelineSettings', () => { // ==================== Props Testing ==================== describe('Props', () => { it('should pass datasetId and documentId to usePipelineExecutionLog', () => { - // Arrange const props = { datasetId: 'custom-dataset', documentId: 'custom-document' } - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(mockUsePipelineExecutionLog).toHaveBeenCalledWith({ dataset_id: 'custom-dataset', document_id: 'custom-document', @@ -310,7 +290,6 @@ describe('PipelineSettings', () => { // ==================== Memoization - Data Transformation ==================== describe('Memoization - Data Transformation', () => { it('should transform localFile datasource correctly', () => { - // Arrange const mockData = createMockExecutionLogResponse({ datasource_type: DatasourceType.localFile, datasource_info: { @@ -326,16 +305,13 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.getByTestId('local-files-count')).toHaveTextContent('1') expect(screen.getByTestId('datasource-type')).toHaveTextContent(DatasourceType.localFile) }) it('should transform websiteCrawl datasource correctly', () => { - // Arrange const mockData = createMockExecutionLogResponse({ datasource_type: DatasourceType.websiteCrawl, datasource_info: { @@ -352,16 +328,13 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.getByTestId('website-pages-count')).toHaveTextContent('1') expect(screen.getByTestId('local-files-count')).toHaveTextContent('0') }) it('should transform onlineDocument datasource correctly', () => { - // Arrange const mockData = createMockExecutionLogResponse({ datasource_type: DatasourceType.onlineDocument, datasource_info: { @@ -376,15 +349,12 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.getByTestId('online-documents-count')).toHaveTextContent('1') }) it('should transform onlineDrive datasource correctly', () => { - // Arrange const mockData = createMockExecutionLogResponse({ datasource_type: DatasourceType.onlineDrive, datasource_info: { id: 'drive-1', type: 'doc', name: 'Google Doc', size: 1024 }, @@ -396,10 +366,8 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.getByTestId('online-drive-files-count')).toHaveTextContent('1') }) }) @@ -407,32 +375,26 @@ describe('PipelineSettings', () => { // ==================== User Interactions - Process ==================== describe('User Interactions - Process', () => { it('should trigger form submit when process button is clicked', async () => { - // Arrange mockMutateAsync.mockResolvedValue({}) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) // Find the "Save and Process" button (from real ProcessDocuments > Actions) const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }) fireEvent.click(processButton) - // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalled() }) }) it('should call handleProcess with is_preview=false', async () => { - // Arrange mockMutateAsync.mockResolvedValue({}) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) - // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ @@ -446,36 +408,30 @@ describe('PipelineSettings', () => { }) it('should navigate to documents list after successful process', async () => { - // Arrange mockMutateAsync.mockImplementation((_request, options) => { options?.onSuccess?.() return Promise.resolve({}) }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) - // Assert await waitFor(() => { expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-123/documents') }) }) it('should invalidate document cache after successful process', async () => { - // Arrange mockMutateAsync.mockImplementation((_request, options) => { options?.onSuccess?.() return Promise.resolve({}) }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) - // Assert await waitFor(() => { expect(mockInvalidDocumentList).toHaveBeenCalled() expect(mockInvalidDocumentDetail).toHaveBeenCalled() @@ -486,30 +442,24 @@ describe('PipelineSettings', () => { // ==================== User Interactions - Preview ==================== describe('User Interactions - Preview', () => { it('should trigger preview when preview button is clicked', async () => { - // Arrange mockMutateAsync.mockResolvedValue({ data: { outputs: {} } }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByTestId('preview-btn')) - // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalled() }) }) it('should call handlePreviewChunks with is_preview=true', async () => { - // Arrange mockMutateAsync.mockResolvedValue({ data: { outputs: {} } }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByTestId('preview-btn')) - // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ @@ -522,7 +472,6 @@ describe('PipelineSettings', () => { }) it('should update estimateData on successful preview', async () => { - // Arrange const mockOutputs = { chunks: [], total_tokens: 50 } mockMutateAsync.mockImplementation((_req, opts) => { opts?.onSuccess?.({ data: { outputs: mockOutputs } }) @@ -530,11 +479,9 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByTestId('preview-btn')) - // Assert await waitFor(() => { expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('true') }) @@ -544,7 +491,6 @@ describe('PipelineSettings', () => { // ==================== API Integration ==================== describe('API Integration', () => { it('should pass correct parameters for preview', async () => { - // Arrange const mockData = createMockExecutionLogResponse({ datasource_type: DatasourceType.localFile, datasource_node_id: 'node-xyz', @@ -559,7 +505,6 @@ describe('PipelineSettings', () => { mockMutateAsync.mockResolvedValue({ data: { outputs: {} } }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByTestId('preview-btn')) @@ -589,7 +534,6 @@ describe('PipelineSettings', () => { [DatasourceType.onlineDocument, 'online-documents-count', '1'], [DatasourceType.onlineDrive, 'online-drive-files-count', '1'], ])('should handle %s datasource type correctly', (datasourceType, testId, expectedCount) => { - // Arrange const datasourceInfoMap: Record<DatasourceType, Record<string, unknown>> = { [DatasourceType.localFile]: { related_id: 'f1', name: 'file.pdf', extension: 'pdf' }, [DatasourceType.websiteCrawl]: { content: 'c', description: 'd', source_url: 'u', title: 't' }, @@ -608,15 +552,12 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.getByTestId(testId)).toHaveTextContent(expectedCount) }) it('should show loading state during initial fetch', () => { - // Arrange mockUsePipelineExecutionLog.mockReturnValue({ data: undefined, isFetching: true, @@ -624,15 +565,12 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.queryByTestId('process-form')).not.toBeInTheDocument() }) it('should show error state when API fails', () => { - // Arrange mockUsePipelineExecutionLog.mockReturnValue({ data: undefined, isFetching: false, @@ -640,10 +578,8 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.queryByTestId('process-form')).not.toBeInTheDocument() }) }) @@ -651,18 +587,14 @@ describe('PipelineSettings', () => { // ==================== State Management ==================== describe('State Management', () => { it('should initialize with undefined estimateData', () => { - // Arrange const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Assert expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('false') }) it('should update estimateData after successful preview', async () => { - // Arrange const mockEstimateData = { chunks: [], total_tokens: 50 } mockMutateAsync.mockImplementation((_req, opts) => { opts?.onSuccess?.({ data: { outputs: mockEstimateData } }) @@ -670,26 +602,21 @@ describe('PipelineSettings', () => { }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByTestId('preview-btn')) - // Assert await waitFor(() => { expect(screen.getByTestId('has-estimate-data')).toHaveTextContent('true') }) }) it('should set isPreview ref to false when process is clicked', async () => { - // Arrange mockMutateAsync.mockResolvedValue({}) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) - // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ is_preview: false }), @@ -699,15 +626,12 @@ describe('PipelineSettings', () => { }) it('should set isPreview ref to true when preview is clicked', async () => { - // Arrange mockMutateAsync.mockResolvedValue({ data: { outputs: {} } }) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) fireEvent.click(screen.getByTestId('preview-btn')) - // Assert await waitFor(() => { expect(mockMutateAsync).toHaveBeenCalledWith( expect.objectContaining({ is_preview: true }), @@ -765,9 +689,7 @@ describe('PipelineSettings', () => { mockMutateAsync.mockReturnValue(new Promise<void>(() => undefined)) const props = createDefaultProps() - // Act renderWithProviders(<PipelineSettings {...props} />) - // Click process (not preview) to set isPreview.current = false fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) // Assert - isPending && isPreview.current should be false (true && false = false) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx similarity index 84% rename from web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.spec.tsx rename to web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx index 208b3b3955..9a1ffab673 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx @@ -1,9 +1,8 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import LeftHeader from './left-header' +import LeftHeader from '../left-header' -// Mock next/navigation const mockBack = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ @@ -16,26 +15,20 @@ describe('LeftHeader', () => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<LeftHeader title="Test Title" />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render the title', () => { - // Arrange & Act render(<LeftHeader title="My Document Title" />) - // Assert expect(screen.getByText('My Document Title')).toBeInTheDocument() }) it('should render the process documents text', () => { - // Arrange & Act render(<LeftHeader title="Test" />) // Assert - i18n key format @@ -43,54 +36,41 @@ describe('LeftHeader', () => { }) it('should render back button', () => { - // Arrange & Act render(<LeftHeader title="Test" />) - // Assert const backButton = screen.getByRole('button') expect(backButton).toBeInTheDocument() }) }) - // User Interactions describe('User Interactions', () => { it('should call router.back when back button is clicked', () => { - // Arrange render(<LeftHeader title="Test" />) - // Act const backButton = screen.getByRole('button') fireEvent.click(backButton) - // Assert expect(mockBack).toHaveBeenCalledTimes(1) }) it('should call router.back multiple times on multiple clicks', () => { - // Arrange render(<LeftHeader title="Test" />) - // Act const backButton = screen.getByRole('button') fireEvent.click(backButton) fireEvent.click(backButton) - // Assert expect(mockBack).toHaveBeenCalledTimes(2) }) }) - // Props tests describe('Props', () => { it('should render different titles', () => { - // Arrange const { rerender } = render(<LeftHeader title="First Title" />) expect(screen.getByText('First Title')).toBeInTheDocument() - // Act rerender(<LeftHeader title="Second Title" />) - // Assert expect(screen.getByText('Second Title')).toBeInTheDocument() }) }) @@ -98,55 +78,42 @@ describe('LeftHeader', () => { // Styling tests describe('Styling', () => { it('should have back button with proper styling', () => { - // Arrange & Act render(<LeftHeader title="Test" />) - // Assert const backButton = screen.getByRole('button') expect(backButton).toHaveClass('absolute') expect(backButton).toHaveClass('rounded-full') }) it('should render title with gradient background styling', () => { - // Arrange & Act const { container } = render(<LeftHeader title="Test" />) - // Assert const titleElement = container.querySelector('.bg-pipeline-add-documents-title-bg') expect(titleElement).toBeInTheDocument() }) }) - // Accessibility tests describe('Accessibility', () => { it('should have aria-label on back button', () => { - // Arrange & Act render(<LeftHeader title="Test" />) - // Assert const backButton = screen.getByRole('button') expect(backButton).toHaveAttribute('aria-label') }) }) - // Edge cases describe('Edge Cases', () => { it('should handle empty title', () => { - // Arrange & Act const { container } = render(<LeftHeader title="" />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should maintain structure when rerendered', () => { - // Arrange const { rerender } = render(<LeftHeader title="Test" />) - // Act rerender(<LeftHeader title="Updated Test" />) - // Assert expect(screen.getByText('Updated Test')).toBeInTheDocument() expect(screen.getByRole('button')).toBeInTheDocument() }) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/actions.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/actions.spec.tsx similarity index 86% rename from web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/actions.spec.tsx rename to web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/actions.spec.tsx index 67c935a7b8..73782a55ca 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/actions.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/actions.spec.tsx @@ -1,32 +1,26 @@ import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Actions from './actions' +import Actions from '../actions' describe('Actions', () => { beforeEach(() => { vi.clearAllMocks() }) - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { - // Arrange & Act const { container } = render(<Actions onProcess={vi.fn()} />) - // Assert expect(container.firstChild).toBeInTheDocument() }) it('should render save and process button', () => { - // Arrange & Act render(<Actions onProcess={vi.fn()} />) - // Assert expect(screen.getByRole('button')).toBeInTheDocument() }) it('should render button with translated text', () => { - // Arrange & Act render(<Actions onProcess={vi.fn()} />) // Assert - i18n key format @@ -34,10 +28,8 @@ describe('Actions', () => { }) it('should render with correct container styling', () => { - // Arrange & Act const { container } = render(<Actions onProcess={vi.fn()} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex') expect(wrapper).toHaveClass('items-center') @@ -45,56 +37,42 @@ describe('Actions', () => { }) }) - // User Interactions describe('User Interactions', () => { it('should call onProcess when button is clicked', () => { - // Arrange const mockOnProcess = vi.fn() render(<Actions onProcess={mockOnProcess} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnProcess).toHaveBeenCalledTimes(1) }) it('should not call onProcess when button is disabled', () => { - // Arrange const mockOnProcess = vi.fn() render(<Actions onProcess={mockOnProcess} runDisabled={true} />) - // Act fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnProcess).not.toHaveBeenCalled() }) }) - // Props tests describe('Props', () => { it('should disable button when runDisabled is true', () => { - // Arrange & Act render(<Actions onProcess={vi.fn()} runDisabled={true} />) - // Assert expect(screen.getByRole('button')).toBeDisabled() }) it('should enable button when runDisabled is false', () => { - // Arrange & Act render(<Actions onProcess={vi.fn()} runDisabled={false} />) - // Assert expect(screen.getByRole('button')).not.toBeDisabled() }) it('should enable button when runDisabled is undefined (default)', () => { - // Arrange & Act render(<Actions onProcess={vi.fn()} />) - // Assert expect(screen.getByRole('button')).not.toBeDisabled() }) }) @@ -102,7 +80,6 @@ describe('Actions', () => { // Button variant tests describe('Button Styling', () => { it('should render button with primary variant', () => { - // Arrange & Act render(<Actions onProcess={vi.fn()} />) // Assert - primary variant buttons have specific classes @@ -111,46 +88,36 @@ describe('Actions', () => { }) }) - // Edge cases describe('Edge Cases', () => { it('should handle multiple rapid clicks', () => { - // Arrange const mockOnProcess = vi.fn() render(<Actions onProcess={mockOnProcess} />) - // Act const button = screen.getByRole('button') fireEvent.click(button) fireEvent.click(button) fireEvent.click(button) - // Assert expect(mockOnProcess).toHaveBeenCalledTimes(3) }) it('should maintain structure when rerendered', () => { - // Arrange const mockOnProcess = vi.fn() const { rerender } = render(<Actions onProcess={mockOnProcess} />) - // Act rerender(<Actions onProcess={mockOnProcess} runDisabled={true} />) - // Assert expect(screen.getByRole('button')).toBeDisabled() }) it('should handle callback change', () => { - // Arrange const mockOnProcess1 = vi.fn() const mockOnProcess2 = vi.fn() const { rerender } = render(<Actions onProcess={mockOnProcess1} />) - // Act rerender(<Actions onProcess={mockOnProcess2} />) fireEvent.click(screen.getByRole('button')) - // Assert expect(mockOnProcess1).not.toHaveBeenCalled() expect(mockOnProcess2).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..227dc63a8c --- /dev/null +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/hooks.spec.ts @@ -0,0 +1,70 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useInputVariables } from '../hooks' + +let mockPipelineId: string | undefined + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: { dataset: { pipeline_id?: string } | null }) => unknown) => + selector({ dataset: mockPipelineId ? { pipeline_id: mockPipelineId } : null }), +})) + +let mockParamsReturn: { + data: Record<string, unknown> | undefined + isFetching: boolean +} + +const mockUsePublishedPipelineProcessingParams = vi.fn( + (_params: { pipeline_id: string, node_id: string }) => mockParamsReturn, +) + +vi.mock('@/service/use-pipeline', () => ({ + usePublishedPipelineProcessingParams: (params: { pipeline_id: string, node_id: string }) => + mockUsePublishedPipelineProcessingParams(params), +})) + +describe('useInputVariables', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPipelineId = 'pipeline-123' + mockParamsReturn = { + data: undefined, + isFetching: false, + } + }) + + // Returns paramsConfig from API + describe('Data Retrieval', () => { + it('should return paramsConfig from API', () => { + const mockConfig = { variables: [{ name: 'var1', type: 'string' }] } + mockParamsReturn = { data: mockConfig, isFetching: false } + + const { result } = renderHook(() => useInputVariables('node-456')) + + expect(result.current.paramsConfig).toEqual(mockConfig) + }) + + it('should return isFetchingParams loading state', () => { + mockParamsReturn = { data: undefined, isFetching: true } + + const { result } = renderHook(() => useInputVariables('node-456')) + + expect(result.current.isFetchingParams).toBe(true) + }) + }) + + // Passes correct parameters to API hook + describe('Parameter Passing', () => { + it('should pass correct pipeline_id and node_id to API hook', () => { + mockPipelineId = 'pipeline-789' + mockParamsReturn = { data: undefined, isFetching: false } + + renderHook(() => useInputVariables('node-abc')) + + expect(mockUsePublishedPipelineProcessingParams).toHaveBeenCalledWith({ + pipeline_id: 'pipeline-789', + node_id: 'node-abc', + }) + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/index.spec.tsx similarity index 94% rename from web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.spec.tsx rename to web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/index.spec.tsx index d0d8da43cf..a38672c3dc 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/process-documents/__tests__/index.spec.tsx @@ -2,7 +2,7 @@ import type { RAGPipelineVariable } from '@/models/pipeline' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import { PipelineInputVarType } from '@/models/pipeline' -import ProcessDocuments from './index' +import ProcessDocuments from '../index' // Mock dataset detail context - required for useInputVariables hook const mockPipelineId = 'pipeline-123' @@ -22,7 +22,7 @@ vi.mock('@/service/use-pipeline', () => ({ // Mock Form component - internal dependencies (useAppForm, BaseField) are too complex // Keep the mock minimal and focused on testing the integration -vi.mock('../../../../create-from-pipeline/process-documents/form', () => ({ +vi.mock('../../../../../create-from-pipeline/process-documents/form', () => ({ default: function MockForm({ ref, initialData, @@ -72,7 +72,6 @@ vi.mock('../../../../create-from-pipeline/process-documents/form', () => ({ }, })) -// Test utilities const createQueryClient = () => new QueryClient({ defaultOptions: { @@ -131,10 +130,8 @@ describe('ProcessDocuments', () => { // Test basic rendering and component structure describe('Rendering', () => { it('should render without crashing', () => { - // Arrange const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) // Assert - verify both Form and Actions are rendered @@ -143,19 +140,15 @@ describe('ProcessDocuments', () => { }) it('should render with correct container structure', () => { - // Arrange const props = createDefaultProps() - // Act const { container } = renderWithProviders(<ProcessDocuments {...props} />) - // Assert const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('flex', 'flex-col', 'gap-y-4', 'pt-4') }) it('should render form fields based on variables configuration', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number }), createMockVariable({ variable: 'separator', label: 'Separator', type: PipelineInputVarType.textInput }), @@ -163,7 +156,6 @@ describe('ProcessDocuments', () => { mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) // Assert - real hooks transform variables to configurations @@ -179,7 +171,6 @@ describe('ProcessDocuments', () => { describe('Props', () => { describe('lastRunInputData', () => { it('should use lastRunInputData as initial form values', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }), ] @@ -187,7 +178,6 @@ describe('ProcessDocuments', () => { const lastRunInputData = { chunk_size: 500 } const props = createDefaultProps({ lastRunInputData }) - // Act renderWithProviders(<ProcessDocuments {...props} />) // Assert - lastRunInputData should override default_value @@ -196,17 +186,14 @@ describe('ProcessDocuments', () => { }) it('should use default_value when lastRunInputData is empty', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps({ lastRunInputData: {} }) - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert const input = screen.getByTestId('input-chunk_size') as HTMLInputElement expect(input.value).toBe('100') }) @@ -214,52 +201,40 @@ describe('ProcessDocuments', () => { describe('isRunning', () => { it('should enable Actions button when isRunning is false', () => { - // Arrange const props = createDefaultProps({ isRunning: false }) - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }) expect(processButton).not.toBeDisabled() }) it('should disable Actions button when isRunning is true', () => { - // Arrange const props = createDefaultProps({ isRunning: true }) - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert const processButton = screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' }) expect(processButton).toBeDisabled() }) it('should disable preview button when isRunning is true', () => { - // Arrange const props = createDefaultProps({ isRunning: true }) - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert expect(screen.getByTestId('preview-btn')).toBeDisabled() }) }) describe('ref', () => { it('should expose submit method via ref', () => { - // Arrange const ref = { current: null } as React.RefObject<{ submit: () => void } | null> const onSubmit = vi.fn() const props = createDefaultProps({ ref, onSubmit }) - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert expect(ref.current).not.toBeNull() expect(typeof ref.current?.submit).toBe('function') @@ -277,50 +252,40 @@ describe('ProcessDocuments', () => { describe('User Interactions', () => { describe('onProcess', () => { it('should call onProcess when Save and Process button is clicked', () => { - // Arrange const onProcess = vi.fn() const props = createDefaultProps({ onProcess }) - // Act renderWithProviders(<ProcessDocuments {...props} />) fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) - // Assert expect(onProcess).toHaveBeenCalledTimes(1) }) it('should not call onProcess when button is disabled due to isRunning', () => { - // Arrange const onProcess = vi.fn() const props = createDefaultProps({ onProcess, isRunning: true }) - // Act renderWithProviders(<ProcessDocuments {...props} />) fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.operations.saveAndProcess' })) - // Assert expect(onProcess).not.toHaveBeenCalled() }) }) describe('onPreview', () => { it('should call onPreview when preview button is clicked', () => { - // Arrange const onPreview = vi.fn() const props = createDefaultProps({ onPreview }) - // Act renderWithProviders(<ProcessDocuments {...props} />) fireEvent.click(screen.getByTestId('preview-btn')) - // Assert expect(onPreview).toHaveBeenCalledTimes(1) }) }) describe('onSubmit', () => { it('should call onSubmit with form data when form is submitted', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'chunk_size', label: 'Chunk Size', type: PipelineInputVarType.number, default_value: '100' }), ] @@ -328,7 +293,6 @@ describe('ProcessDocuments', () => { const onSubmit = vi.fn() const props = createDefaultProps({ onSubmit }) - // Act renderWithProviders(<ProcessDocuments {...props} />) fireEvent.submit(screen.getByTestId('process-form')) @@ -343,65 +307,53 @@ describe('ProcessDocuments', () => { // Test real hooks transform data correctly describe('Data Transformation', () => { it('should transform text-input variable to string initial value', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'name', label: 'Name', type: PipelineInputVarType.textInput, default_value: 'default' }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert const input = screen.getByTestId('input-name') as HTMLInputElement expect(input.defaultValue).toBe('default') }) it('should transform number variable to number initial value', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'count', label: 'Count', type: PipelineInputVarType.number, default_value: '42' }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert const input = screen.getByTestId('input-count') as HTMLInputElement expect(input.defaultValue).toBe('42') }) it('should use empty string for text-input without default value', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'name', label: 'Name', type: PipelineInputVarType.textInput }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert const input = screen.getByTestId('input-name') as HTMLInputElement expect(input.defaultValue).toBe('') }) it('should prioritize lastRunInputData over default_value', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'size', label: 'Size', type: PipelineInputVarType.number, default_value: '100' }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps({ lastRunInputData: { size: 999 } }) - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert const input = screen.getByTestId('input-size') as HTMLInputElement expect(input.defaultValue).toBe('999') }) @@ -412,11 +364,9 @@ describe('ProcessDocuments', () => { describe('Edge Cases', () => { describe('Empty/Null data handling', () => { it('should handle undefined paramsConfig.variables', () => { - // Arrange mockParamsConfig.mockReturnValue({ variables: undefined }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) // Assert - should render without fields @@ -425,26 +375,20 @@ describe('ProcessDocuments', () => { }) it('should handle null paramsConfig', () => { - // Arrange mockParamsConfig.mockReturnValue(null) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert expect(screen.getByTestId('process-form')).toBeInTheDocument() }) it('should handle empty variables array', () => { - // Arrange mockParamsConfig.mockReturnValue({ variables: [] }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert expect(screen.getByTestId('process-form')).toBeInTheDocument() expect(screen.queryByTestId(/^field-/)).not.toBeInTheDocument() }) @@ -452,7 +396,6 @@ describe('ProcessDocuments', () => { describe('Multiple variables', () => { it('should handle multiple variables of different types', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'text_field', label: 'Text', type: PipelineInputVarType.textInput, default_value: 'hello' }), createMockVariable({ variable: 'number_field', label: 'Number', type: PipelineInputVarType.number, default_value: '123' }), @@ -461,7 +404,6 @@ describe('ProcessDocuments', () => { mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) // Assert - all fields should be rendered @@ -471,7 +413,6 @@ describe('ProcessDocuments', () => { }) it('should submit all variables data correctly', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'field1', label: 'Field 1', type: PipelineInputVarType.textInput, default_value: 'value1' }), createMockVariable({ variable: 'field2', label: 'Field 2', type: PipelineInputVarType.number, default_value: '42' }), @@ -480,7 +421,6 @@ describe('ProcessDocuments', () => { const onSubmit = vi.fn() const props = createDefaultProps({ onSubmit }) - // Act renderWithProviders(<ProcessDocuments {...props} />) fireEvent.submit(screen.getByTestId('process-form')) @@ -494,7 +434,6 @@ describe('ProcessDocuments', () => { describe('Variable with options (select type)', () => { it('should handle select variable with options', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'mode', @@ -507,10 +446,8 @@ describe('ProcessDocuments', () => { mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) - // Assert expect(screen.getByTestId('field-mode')).toBeInTheDocument() const input = screen.getByTestId('input-mode') as HTMLInputElement expect(input.defaultValue).toBe('auto') @@ -522,7 +459,6 @@ describe('ProcessDocuments', () => { // Test Form and Actions components work together with real hooks describe('Integration', () => { it('should coordinate form submission flow correctly', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'setting', label: 'Setting', type: PipelineInputVarType.textInput, default_value: 'initial' }), ] @@ -531,7 +467,6 @@ describe('ProcessDocuments', () => { const onSubmit = vi.fn() const props = createDefaultProps({ onProcess, onSubmit }) - // Act renderWithProviders(<ProcessDocuments {...props} />) // Assert - form is rendered with correct initial data @@ -546,14 +481,12 @@ describe('ProcessDocuments', () => { }) it('should render complete UI with all interactive elements', () => { - // Arrange const variables: RAGPipelineVariable[] = [ createMockVariable({ variable: 'test', label: 'Test Field', type: PipelineInputVarType.textInput }), ] mockParamsConfig.mockReturnValue({ variables }) const props = createDefaultProps() - // Act renderWithProviders(<ProcessDocuments {...props} />) // Assert - all UI elements are present diff --git a/web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.ts b/web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.ts new file mode 100644 index 0000000000..e31d4ac547 --- /dev/null +++ b/web/app/components/datasets/documents/hooks/__tests__/use-document-list-query-state.spec.ts @@ -0,0 +1,439 @@ +import type { DocumentListQuery } from '../use-document-list-query-state' +import { act, renderHook } from '@testing-library/react' + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import useDocumentListQueryState from '../use-document-list-query-state' + +const mockPush = vi.fn() +const mockSearchParams = new URLSearchParams() + +vi.mock('@/models/datasets', () => ({ + DisplayStatusList: [ + 'queuing', + 'indexing', + 'paused', + 'error', + 'available', + 'enabled', + 'disabled', + 'archived', + ], +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), + usePathname: () => '/datasets/test-id/documents', + useSearchParams: () => mockSearchParams, +})) + +describe('useDocumentListQueryState', () => { + beforeEach(() => { + vi.clearAllMocks() + // Reset mock search params to empty + for (const key of [...mockSearchParams.keys()]) + mockSearchParams.delete(key) + }) + + // Tests for parseParams (exposed via the query property) + describe('parseParams (via query)', () => { + it('should return default query when no search params present', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query).toEqual({ + page: 1, + limit: 10, + keyword: '', + status: 'all', + sort: '-created_at', + }) + }) + + it('should parse page from search params', () => { + mockSearchParams.set('page', '3') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.page).toBe(3) + }) + + it('should default page to 1 when page is zero', () => { + mockSearchParams.set('page', '0') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.page).toBe(1) + }) + + it('should default page to 1 when page is negative', () => { + mockSearchParams.set('page', '-5') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.page).toBe(1) + }) + + it('should default page to 1 when page is NaN', () => { + mockSearchParams.set('page', 'abc') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.page).toBe(1) + }) + + it('should parse limit from search params', () => { + mockSearchParams.set('limit', '50') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.limit).toBe(50) + }) + + it('should default limit to 10 when limit is zero', () => { + mockSearchParams.set('limit', '0') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.limit).toBe(10) + }) + + it('should default limit to 10 when limit exceeds 100', () => { + mockSearchParams.set('limit', '101') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.limit).toBe(10) + }) + + it('should default limit to 10 when limit is negative', () => { + mockSearchParams.set('limit', '-1') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.limit).toBe(10) + }) + + it('should accept limit at boundary 100', () => { + mockSearchParams.set('limit', '100') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.limit).toBe(100) + }) + + it('should accept limit at boundary 1', () => { + mockSearchParams.set('limit', '1') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.limit).toBe(1) + }) + + it('should parse and decode keyword from search params', () => { + mockSearchParams.set('keyword', encodeURIComponent('hello world')) + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.keyword).toBe('hello world') + }) + + it('should return empty keyword when not present', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.keyword).toBe('') + }) + + it('should sanitize status from search params', () => { + mockSearchParams.set('status', 'available') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.status).toBe('available') + }) + + it('should fallback status to all for unknown status', () => { + mockSearchParams.set('status', 'badvalue') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.status).toBe('all') + }) + + it('should resolve active status alias to available', () => { + mockSearchParams.set('status', 'active') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.status).toBe('available') + }) + + it('should parse valid sort value from search params', () => { + mockSearchParams.set('sort', 'hit_count') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.sort).toBe('hit_count') + }) + + it('should default sort to -created_at for invalid sort value', () => { + mockSearchParams.set('sort', 'invalid_sort') + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.sort).toBe('-created_at') + }) + + it('should default sort to -created_at when not present', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.sort).toBe('-created_at') + }) + + it.each([ + '-created_at', + 'created_at', + '-hit_count', + 'hit_count', + ] as const)('should accept valid sort value %s', (sortValue) => { + mockSearchParams.set('sort', sortValue) + + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current.query.sort).toBe(sortValue) + }) + }) + + // Tests for updateQuery + describe('updateQuery', () => { + it('should call router.push with updated params when page is changed', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ page: 3 }) + }) + + expect(mockPush).toHaveBeenCalledTimes(1) + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toContain('page=3') + }) + + it('should call router.push with scroll false', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ page: 2 }) + }) + + expect(mockPush).toHaveBeenCalledWith( + expect.any(String), + { scroll: false }, + ) + }) + + it('should set status in URL when status is not all', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ status: 'error' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toContain('status=error') + }) + + it('should not set status in URL when status is all', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ status: 'all' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).not.toContain('status=') + }) + + it('should set sort in URL when sort is not the default -created_at', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ sort: 'hit_count' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toContain('sort=hit_count') + }) + + it('should not set sort in URL when sort is default -created_at', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ sort: '-created_at' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).not.toContain('sort=') + }) + + it('should encode keyword in URL when keyword is provided', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ keyword: 'test query' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + // Source code applies encodeURIComponent before setting in URLSearchParams + expect(pushedUrl).toContain('keyword=') + const params = new URLSearchParams(pushedUrl.split('?')[1]) + // params.get decodes one layer, but the value was pre-encoded with encodeURIComponent + expect(decodeURIComponent(params.get('keyword')!)).toBe('test query') + }) + + it('should remove keyword from URL when keyword is empty', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ keyword: '' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).not.toContain('keyword=') + }) + + it('should sanitize invalid status to all and not include in URL', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ status: 'invalidstatus' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).not.toContain('status=') + }) + + it('should sanitize invalid sort to -created_at and not include in URL', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ sort: 'invalidsort' as DocumentListQuery['sort'] }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).not.toContain('sort=') + }) + + it('should omit page and limit when they are default and no keyword', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ page: 1, limit: 10 }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).not.toContain('page=') + expect(pushedUrl).not.toContain('limit=') + }) + + it('should include page and limit when page is greater than 1', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ page: 2 }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toContain('page=2') + expect(pushedUrl).toContain('limit=10') + }) + + it('should include page and limit when limit is non-default', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ limit: 25 }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toContain('page=1') + expect(pushedUrl).toContain('limit=25') + }) + + it('should include page and limit when keyword is provided', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ keyword: 'search' }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toContain('page=1') + expect(pushedUrl).toContain('limit=10') + }) + + it('should use pathname prefix in pushed URL', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({ page: 2 }) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toMatch(/^\/datasets\/test-id\/documents/) + }) + + it('should push path without query string when all values are defaults', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.updateQuery({}) + }) + + const pushedUrl = mockPush.mock.calls[0][0] as string + expect(pushedUrl).toBe('/datasets/test-id/documents') + }) + }) + + // Tests for resetQuery + describe('resetQuery', () => { + it('should push URL with default query params when called', () => { + mockSearchParams.set('page', '5') + mockSearchParams.set('status', 'error') + + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.resetQuery() + }) + + expect(mockPush).toHaveBeenCalledTimes(1) + const pushedUrl = mockPush.mock.calls[0][0] as string + // Default query has all defaults, so no params should be in the URL + expect(pushedUrl).toBe('/datasets/test-id/documents') + }) + + it('should call router.push with scroll false when resetting', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + act(() => { + result.current.resetQuery() + }) + + expect(mockPush).toHaveBeenCalledWith( + expect.any(String), + { scroll: false }, + ) + }) + }) + + // Tests for return value stability + describe('return value', () => { + it('should return query, updateQuery, and resetQuery', () => { + const { result } = renderHook(() => useDocumentListQueryState()) + + expect(result.current).toHaveProperty('query') + expect(result.current).toHaveProperty('updateQuery') + expect(result.current).toHaveProperty('resetQuery') + expect(typeof result.current.updateQuery).toBe('function') + expect(typeof result.current.resetQuery).toBe('function') + }) + }) +}) diff --git a/web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts b/web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts new file mode 100644 index 0000000000..34911e9e9c --- /dev/null +++ b/web/app/components/datasets/documents/hooks/__tests__/use-documents-page-state.spec.ts @@ -0,0 +1,711 @@ +import type { DocumentListQuery } from '../use-document-list-query-state' +import type { DocumentListResponse } from '@/models/datasets' + +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useDocumentsPageState } from '../use-documents-page-state' + +const mockUpdateQuery = vi.fn() +const mockResetQuery = vi.fn() +let mockQuery: DocumentListQuery = { page: 1, limit: 10, keyword: '', status: 'all', sort: '-created_at' } + +vi.mock('@/models/datasets', () => ({ + DisplayStatusList: [ + 'queuing', + 'indexing', + 'paused', + 'error', + 'available', + 'enabled', + 'disabled', + 'archived', + ], +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/datasets/test-id/documents', + useSearchParams: () => new URLSearchParams(), +})) + +// Mock ahooks debounce utilities: required because tests capture the debounce +// callback reference to invoke it synchronously, bypassing real timer delays. +let capturedDebounceFnCallback: (() => void) | null = null + +vi.mock('ahooks', () => ({ + useDebounce: (value: unknown, _options?: { wait?: number }) => value, + useDebounceFn: (fn: () => void, _options?: { wait?: number }) => { + capturedDebounceFnCallback = fn + return { run: fn, cancel: vi.fn(), flush: vi.fn() } + }, +})) + +// Mock the dependent hook +vi.mock('../use-document-list-query-state', () => ({ + default: () => ({ + query: mockQuery, + updateQuery: mockUpdateQuery, + resetQuery: mockResetQuery, + }), +})) + +// Factory for creating DocumentListResponse test data +function createDocumentListResponse(overrides: Partial<DocumentListResponse> = {}): DocumentListResponse { + return { + data: [], + has_more: false, + total: 0, + page: 1, + limit: 10, + ...overrides, + } +} + +// Factory for creating a minimal document item +function createDocumentItem(overrides: Record<string, unknown> = {}) { + return { + id: `doc-${Math.random().toString(36).slice(2, 8)}`, + name: 'test-doc.txt', + indexing_status: 'completed' as string, + display_status: 'available' as string, + enabled: true, + archived: false, + word_count: 100, + created_at: Date.now(), + updated_at: Date.now(), + created_from: 'web' as const, + created_by: 'user-1', + dataset_process_rule_id: 'rule-1', + doc_form: 'text_model' as const, + doc_language: 'en', + position: 1, + data_source_type: 'upload_file', + ...overrides, + } +} + +describe('useDocumentsPageState', () => { + beforeEach(() => { + vi.clearAllMocks() + capturedDebounceFnCallback = null + mockQuery = { page: 1, limit: 10, keyword: '', status: 'all', sort: '-created_at' } + }) + + // Initial state verification + describe('initial state', () => { + it('should return correct initial search state', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.inputValue).toBe('') + expect(result.current.searchValue).toBe('') + expect(result.current.debouncedSearchValue).toBe('') + }) + + it('should return correct initial filter and sort state', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.statusFilterValue).toBe('all') + expect(result.current.sortValue).toBe('-created_at') + expect(result.current.normalizedStatusFilterValue).toBe('all') + }) + + it('should return correct initial pagination state', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + // page is query.page - 1 = 0 + expect(result.current.currPage).toBe(0) + expect(result.current.limit).toBe(10) + }) + + it('should return correct initial selection state', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.selectedIds).toEqual([]) + }) + + it('should return correct initial polling state', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.timerCanRun).toBe(true) + }) + + it('should initialize from query when query has keyword', () => { + mockQuery = { ...mockQuery, keyword: 'initial search' } + + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.inputValue).toBe('initial search') + expect(result.current.searchValue).toBe('initial search') + }) + + it('should initialize pagination from query with non-default page', () => { + mockQuery = { ...mockQuery, page: 3, limit: 25 } + + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.currPage).toBe(2) // page - 1 + expect(result.current.limit).toBe(25) + }) + + it('should initialize status filter from query', () => { + mockQuery = { ...mockQuery, status: 'error' } + + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.statusFilterValue).toBe('error') + }) + + it('should initialize sort from query', () => { + mockQuery = { ...mockQuery, sort: 'hit_count' } + + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.sortValue).toBe('hit_count') + }) + }) + + // Handler behaviors + describe('handleInputChange', () => { + it('should update input value when called', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleInputChange('new value') + }) + + expect(result.current.inputValue).toBe('new value') + }) + + it('should trigger debounced search callback when called', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + // First call sets inputValue and triggers the debounced fn + act(() => { + result.current.handleInputChange('search term') + }) + + // The debounced fn captures inputValue from its render closure. + // After re-render with new inputValue, calling the captured callback again + // should reflect the updated state. + act(() => { + if (capturedDebounceFnCallback) + capturedDebounceFnCallback() + }) + + expect(result.current.searchValue).toBe('search term') + }) + }) + + describe('handleStatusFilterChange', () => { + it('should update status filter value when called with valid status', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('error') + }) + + expect(result.current.statusFilterValue).toBe('error') + }) + + it('should reset page to 0 when status filter changes', () => { + mockQuery = { ...mockQuery, page: 3 } + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('error') + }) + + expect(result.current.currPage).toBe(0) + }) + + it('should call updateQuery with sanitized status and page 1', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('error') + }) + + expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'error', page: 1 }) + }) + + it('should sanitize invalid status to all', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('invalid') + }) + + expect(result.current.statusFilterValue).toBe('all') + expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'all', page: 1 }) + }) + }) + + describe('handleStatusFilterClear', () => { + it('should set status to all and reset page when status is not all', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + // First set a non-all status + act(() => { + result.current.handleStatusFilterChange('error') + }) + vi.clearAllMocks() + + // Then clear + act(() => { + result.current.handleStatusFilterClear() + }) + + expect(result.current.statusFilterValue).toBe('all') + expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'all', page: 1 }) + }) + + it('should not call updateQuery when status is already all', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterClear() + }) + + expect(mockUpdateQuery).not.toHaveBeenCalled() + }) + }) + + describe('handleSortChange', () => { + it('should update sort value and call updateQuery when value changes', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleSortChange('hit_count') + }) + + expect(result.current.sortValue).toBe('hit_count') + expect(mockUpdateQuery).toHaveBeenCalledWith({ sort: 'hit_count', page: 1 }) + }) + + it('should reset page to 0 when sort changes', () => { + mockQuery = { ...mockQuery, page: 5 } + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleSortChange('hit_count') + }) + + expect(result.current.currPage).toBe(0) + }) + + it('should not call updateQuery when sort value is same as current', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleSortChange('-created_at') + }) + + expect(mockUpdateQuery).not.toHaveBeenCalled() + }) + }) + + describe('handlePageChange', () => { + it('should update current page and call updateQuery', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handlePageChange(2) + }) + + expect(result.current.currPage).toBe(2) + expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 3 }) // newPage + 1 + }) + + it('should handle page 0 (first page)', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handlePageChange(0) + }) + + expect(result.current.currPage).toBe(0) + expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 1 }) + }) + }) + + describe('handleLimitChange', () => { + it('should update limit, reset page to 0, and call updateQuery', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleLimitChange(25) + }) + + expect(result.current.limit).toBe(25) + expect(result.current.currPage).toBe(0) + expect(mockUpdateQuery).toHaveBeenCalledWith({ limit: 25, page: 1 }) + }) + }) + + // Selection state + describe('selection state', () => { + it('should update selectedIds via setSelectedIds', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.setSelectedIds(['doc-1', 'doc-2']) + }) + + expect(result.current.selectedIds).toEqual(['doc-1', 'doc-2']) + }) + }) + + // Polling state management + describe('updatePollingState', () => { + it('should not update timer when documentsRes is undefined', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.updatePollingState(undefined) + }) + + // timerCanRun remains true (initial value) + expect(result.current.timerCanRun).toBe(true) + }) + + it('should not update timer when documentsRes.data is undefined', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.updatePollingState({ data: undefined } as unknown as DocumentListResponse) + }) + + expect(result.current.timerCanRun).toBe(true) + }) + + it('should set timerCanRun to false when all documents are completed and status filter is all', () => { + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'completed' }), + createDocumentItem({ indexing_status: 'completed' }), + ] as DocumentListResponse['data'], + total: 2, + }) + + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.updatePollingState(response) + }) + + expect(result.current.timerCanRun).toBe(false) + }) + + it('should set timerCanRun to true when some documents are not completed', () => { + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'completed' }), + createDocumentItem({ indexing_status: 'indexing' }), + ] as DocumentListResponse['data'], + total: 2, + }) + + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.updatePollingState(response) + }) + + expect(result.current.timerCanRun).toBe(true) + }) + + it('should count paused documents as completed for polling purposes', () => { + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'paused' }), + createDocumentItem({ indexing_status: 'completed' }), + ] as DocumentListResponse['data'], + total: 2, + }) + + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.updatePollingState(response) + }) + + // All docs are "embedded" (completed, paused, error), so hasIncomplete = false + // statusFilter is 'all', so shouldForcePolling = false + expect(result.current.timerCanRun).toBe(false) + }) + + it('should count error documents as completed for polling purposes', () => { + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'error' }), + createDocumentItem({ indexing_status: 'completed' }), + ] as DocumentListResponse['data'], + total: 2, + }) + + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.updatePollingState(response) + }) + + expect(result.current.timerCanRun).toBe(false) + }) + + it('should force polling when status filter is a transient status (queuing)', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + // Set status filter to queuing + act(() => { + result.current.handleStatusFilterChange('queuing') + }) + + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'completed' }), + ] as DocumentListResponse['data'], + total: 1, + }) + + act(() => { + result.current.updatePollingState(response) + }) + + // shouldForcePolling = true (queuing is transient), hasIncomplete = false + // timerCanRun = true || false = true + expect(result.current.timerCanRun).toBe(true) + }) + + it('should force polling when status filter is indexing', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('indexing') + }) + + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'completed' }), + ] as DocumentListResponse['data'], + total: 1, + }) + + act(() => { + result.current.updatePollingState(response) + }) + + expect(result.current.timerCanRun).toBe(true) + }) + + it('should force polling when status filter is paused', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('paused') + }) + + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'paused' }), + ] as DocumentListResponse['data'], + total: 1, + }) + + act(() => { + result.current.updatePollingState(response) + }) + + expect(result.current.timerCanRun).toBe(true) + }) + + it('should not force polling when status filter is a non-transient status (error)', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('error') + }) + + const response = createDocumentListResponse({ + data: [ + createDocumentItem({ indexing_status: 'error' }), + ] as DocumentListResponse['data'], + total: 1, + }) + + act(() => { + result.current.updatePollingState(response) + }) + + // shouldForcePolling = false (error is not transient), hasIncomplete = false (error is embedded) + expect(result.current.timerCanRun).toBe(false) + }) + + it('should set timerCanRun to true when data is empty and filter is transient', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('indexing') + }) + + const response = createDocumentListResponse({ data: [] as DocumentListResponse['data'], total: 0 }) + + act(() => { + result.current.updatePollingState(response) + }) + + // shouldForcePolling = true (indexing is transient), hasIncomplete = false (0 !== 0 is false) + expect(result.current.timerCanRun).toBe(true) + }) + }) + + // Page adjustment + describe('adjustPageForTotal', () => { + it('should not adjust page when documentsRes is undefined', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.adjustPageForTotal(undefined) + }) + + expect(result.current.currPage).toBe(0) + }) + + it('should not adjust page when currPage is within total pages', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + const response = createDocumentListResponse({ total: 20 }) + + act(() => { + result.current.adjustPageForTotal(response) + }) + + // currPage is 0, totalPages is 2, so no adjustment needed + expect(result.current.currPage).toBe(0) + }) + + it('should adjust page to last page when currPage exceeds total pages', () => { + mockQuery = { ...mockQuery, page: 6 } + const { result } = renderHook(() => useDocumentsPageState()) + + // currPage should be 5 (page - 1) + expect(result.current.currPage).toBe(5) + + const response = createDocumentListResponse({ total: 30 }) // 30/10 = 3 pages + + act(() => { + result.current.adjustPageForTotal(response) + }) + + // currPage (5) + 1 > totalPages (3), so adjust to totalPages - 1 = 2 + expect(result.current.currPage).toBe(2) + expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 3 }) // handlePageChange passes newPage + 1 + }) + + it('should adjust page to 0 when total is 0 and currPage > 0', () => { + mockQuery = { ...mockQuery, page: 3 } + const { result } = renderHook(() => useDocumentsPageState()) + + const response = createDocumentListResponse({ total: 0 }) + + act(() => { + result.current.adjustPageForTotal(response) + }) + + // totalPages = 0, so adjust to max(0 - 1, 0) = 0 + expect(result.current.currPage).toBe(0) + expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 1 }) + }) + + it('should not adjust page when currPage is 0 even if total is 0', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + const response = createDocumentListResponse({ total: 0 }) + + act(() => { + result.current.adjustPageForTotal(response) + }) + + // currPage is 0, condition is currPage > 0 so no adjustment + expect(mockUpdateQuery).not.toHaveBeenCalled() + }) + }) + + // Normalized status filter value + describe('normalizedStatusFilterValue', () => { + it('should return all for default status', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + expect(result.current.normalizedStatusFilterValue).toBe('all') + }) + + it('should normalize enabled to available', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('enabled') + }) + + expect(result.current.normalizedStatusFilterValue).toBe('available') + }) + + it('should return non-aliased status as-is', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + act(() => { + result.current.handleStatusFilterChange('error') + }) + + expect(result.current.normalizedStatusFilterValue).toBe('error') + }) + }) + + // Return value shape + describe('return value', () => { + it('should return all expected properties', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + // Search state + expect(result.current).toHaveProperty('inputValue') + expect(result.current).toHaveProperty('searchValue') + expect(result.current).toHaveProperty('debouncedSearchValue') + expect(result.current).toHaveProperty('handleInputChange') + + // Filter & sort state + expect(result.current).toHaveProperty('statusFilterValue') + expect(result.current).toHaveProperty('sortValue') + expect(result.current).toHaveProperty('normalizedStatusFilterValue') + expect(result.current).toHaveProperty('handleStatusFilterChange') + expect(result.current).toHaveProperty('handleStatusFilterClear') + expect(result.current).toHaveProperty('handleSortChange') + + // Pagination state + expect(result.current).toHaveProperty('currPage') + expect(result.current).toHaveProperty('limit') + expect(result.current).toHaveProperty('handlePageChange') + expect(result.current).toHaveProperty('handleLimitChange') + + // Selection state + expect(result.current).toHaveProperty('selectedIds') + expect(result.current).toHaveProperty('setSelectedIds') + + // Polling state + expect(result.current).toHaveProperty('timerCanRun') + expect(result.current).toHaveProperty('updatePollingState') + expect(result.current).toHaveProperty('adjustPageForTotal') + }) + + it('should have function types for all handlers', () => { + const { result } = renderHook(() => useDocumentsPageState()) + + expect(typeof result.current.handleInputChange).toBe('function') + expect(typeof result.current.handleStatusFilterChange).toBe('function') + expect(typeof result.current.handleStatusFilterClear).toBe('function') + expect(typeof result.current.handleSortChange).toBe('function') + expect(typeof result.current.handlePageChange).toBe('function') + expect(typeof result.current.handleLimitChange).toBe('function') + expect(typeof result.current.setSelectedIds).toBe('function') + expect(typeof result.current.updatePollingState).toBe('function') + expect(typeof result.current.adjustPageForTotal).toBe('function') + }) + }) +}) diff --git a/web/app/components/datasets/documents/status-item/__tests__/hooks.spec.ts b/web/app/components/datasets/documents/status-item/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..9b89cab7a0 --- /dev/null +++ b/web/app/components/datasets/documents/status-item/__tests__/hooks.spec.ts @@ -0,0 +1,119 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useIndexStatus } from '../hooks' + +// Explicit react-i18next mock so the test stays portable +// even if the global vitest.setup changes. + +describe('useIndexStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Verify the hook returns all expected status keys + it('should return all expected status keys', () => { + const { result } = renderHook(() => useIndexStatus()) + + const expectedKeys = ['queuing', 'indexing', 'paused', 'error', 'available', 'enabled', 'disabled', 'archived'] + const keys = Object.keys(result.current) + expect(keys).toEqual(expect.arrayContaining(expectedKeys)) + }) + + // Verify each status entry has the correct color + describe('colors', () => { + it('should return orange color for queuing', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.queuing.color).toBe('orange') + }) + + it('should return blue color for indexing', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.indexing.color).toBe('blue') + }) + + it('should return orange color for paused', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.paused.color).toBe('orange') + }) + + it('should return red color for error', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.error.color).toBe('red') + }) + + it('should return green color for available', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.available.color).toBe('green') + }) + + it('should return green color for enabled', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.enabled.color).toBe('green') + }) + + it('should return gray color for disabled', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.disabled.color).toBe('gray') + }) + + it('should return gray color for archived', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.archived.color).toBe('gray') + }) + }) + + // Verify each status entry has translated text (global mock returns ns.key format) + describe('translated text', () => { + it('should return translated text for queuing', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.queuing.text).toBe('datasetDocuments.list.status.queuing') + }) + + it('should return translated text for indexing', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.indexing.text).toBe('datasetDocuments.list.status.indexing') + }) + + it('should return translated text for paused', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.paused.text).toBe('datasetDocuments.list.status.paused') + }) + + it('should return translated text for error', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.error.text).toBe('datasetDocuments.list.status.error') + }) + + it('should return translated text for available', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.available.text).toBe('datasetDocuments.list.status.available') + }) + + it('should return translated text for enabled', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.enabled.text).toBe('datasetDocuments.list.status.enabled') + }) + + it('should return translated text for disabled', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.disabled.text).toBe('datasetDocuments.list.status.disabled') + }) + + it('should return translated text for archived', () => { + const { result } = renderHook(() => useIndexStatus()) + expect(result.current.archived.text).toBe('datasetDocuments.list.status.archived') + }) + }) + + // Verify each entry has both color and text properties + it('should return objects with color and text properties for every status', () => { + const { result } = renderHook(() => useIndexStatus()) + + for (const key of Object.keys(result.current) as Array<keyof typeof result.current>) { + expect(result.current[key]).toHaveProperty('color') + expect(result.current[key]).toHaveProperty('text') + expect(typeof result.current[key].color).toBe('string') + expect(typeof result.current[key].text).toBe('string') + } + }) +}) diff --git a/web/app/components/datasets/documents/status-item/index.spec.tsx b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/datasets/documents/status-item/index.spec.tsx rename to web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx index ff02bef11b..ce31bdc62f 100644 --- a/web/app/components/datasets/documents/status-item/index.spec.tsx +++ b/web/app/components/datasets/documents/status-item/__tests__/index.spec.tsx @@ -1,16 +1,8 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import StatusItem from './index' +import StatusItem from '../index' -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) - -// Mock ToastContext const mockNotify = vi.fn() vi.mock('use-context-selector', () => ({ createContext: (defaultValue: unknown) => React.createContext(defaultValue), @@ -21,7 +13,7 @@ vi.mock('use-context-selector', () => ({ })) // Mock useIndexStatus hook -vi.mock('./hooks', () => ({ +vi.mock('../hooks', () => ({ useIndexStatus: () => ({ queuing: { text: 'Queuing', color: 'orange' }, indexing: { text: 'Indexing', color: 'blue' }, @@ -34,7 +26,6 @@ vi.mock('./hooks', () => ({ }), })) -// Mock service hooks const mockEnable = vi.fn() const mockDisable = vi.fn() const mockDelete = vi.fn() @@ -361,7 +352,7 @@ describe('StatusItem', () => { }) expect(mockNotify).toHaveBeenCalledWith({ type: 'success', - message: 'actionMsg.modifiedSuccessfully', + message: 'common.actionMsg.modifiedSuccessfully', }) vi.useRealTimers() }) @@ -421,7 +412,7 @@ describe('StatusItem', () => { }) expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - message: 'actionMsg.modifiedUnsuccessfully', + message: 'common.actionMsg.modifiedUnsuccessfully', }) vi.useRealTimers() }) diff --git a/web/app/components/datasets/external-api/external-api-modal/Form.spec.tsx b/web/app/components/datasets/external-api/external-api-modal/__tests__/Form.spec.tsx similarity index 98% rename from web/app/components/datasets/external-api/external-api-modal/Form.spec.tsx rename to web/app/components/datasets/external-api/external-api-modal/__tests__/Form.spec.tsx index 346bcd00b7..fd23a18365 100644 --- a/web/app/components/datasets/external-api/external-api-modal/Form.spec.tsx +++ b/web/app/components/datasets/external-api/external-api-modal/__tests__/Form.spec.tsx @@ -1,7 +1,7 @@ -import type { CreateExternalAPIReq, FormSchema } from '../declarations' +import type { CreateExternalAPIReq, FormSchema } from '../../declarations' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Form from './Form' +import Form from '../Form' // Mock context for i18n doc link vi.mock('@/context/i18n', () => ({ diff --git a/web/app/components/datasets/external-api/external-api-modal/index.spec.tsx b/web/app/components/datasets/external-api/external-api-modal/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/datasets/external-api/external-api-modal/index.spec.tsx rename to web/app/components/datasets/external-api/external-api-modal/__tests__/index.spec.tsx index 94c4deab04..a631de3ea0 100644 --- a/web/app/components/datasets/external-api/external-api-modal/index.spec.tsx +++ b/web/app/components/datasets/external-api/external-api-modal/__tests__/index.spec.tsx @@ -1,17 +1,16 @@ -import type { CreateExternalAPIReq } from '../declarations' +import type { CreateExternalAPIReq } from '../../declarations' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' // Import mocked service import { createExternalAPI } from '@/service/datasets' -import AddExternalAPIModal from './index' +import AddExternalAPIModal from '../index' // Mock API service vi.mock('@/service/datasets', () => ({ createExternalAPI: vi.fn(), })) -// Mock toast context const mockNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ useToastContext: () => ({ diff --git a/web/app/components/datasets/external-api/external-api-panel/index.spec.tsx b/web/app/components/datasets/external-api/external-api-panel/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/datasets/external-api/external-api-panel/index.spec.tsx rename to web/app/components/datasets/external-api/external-api-panel/__tests__/index.spec.tsx index 291b7516c3..eb7c0558ac 100644 --- a/web/app/components/datasets/external-api/external-api-panel/index.spec.tsx +++ b/web/app/components/datasets/external-api/external-api-panel/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import type { ExternalAPIItem } from '@/models/datasets' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import ExternalAPIPanel from './index' +import ExternalAPIPanel from '../index' // Mock external contexts (only mock context providers, not base components) const mockSetShowExternalKnowledgeAPIModal = vi.fn() @@ -28,7 +28,7 @@ vi.mock('@/context/i18n', () => ({ })) // Mock the ExternalKnowledgeAPICard to avoid mocking its internal dependencies -vi.mock('../external-knowledge-api-card', () => ({ +vi.mock('../../external-knowledge-api-card', () => ({ default: ({ api }: { api: ExternalAPIItem }) => ( <div data-testid={`api-card-${api.id}`}>{api.name}</div> ), diff --git a/web/app/components/datasets/external-api/external-knowledge-api-card/index.spec.tsx b/web/app/components/datasets/external-api/external-knowledge-api-card/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/datasets/external-api/external-knowledge-api-card/index.spec.tsx rename to web/app/components/datasets/external-api/external-knowledge-api-card/__tests__/index.spec.tsx index f8aacde3e1..bc1c923876 100644 --- a/web/app/components/datasets/external-api/external-knowledge-api-card/index.spec.tsx +++ b/web/app/components/datasets/external-api/external-knowledge-api-card/__tests__/index.spec.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' // Import mocked services import { checkUsageExternalAPI, deleteExternalAPI, fetchExternalAPI } from '@/service/datasets' -import ExternalKnowledgeAPICard from './index' +import ExternalKnowledgeAPICard from '../index' // Mock API services vi.mock('@/service/datasets', () => ({ diff --git a/web/app/components/datasets/external-knowledge-base/connector/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/datasets/external-knowledge-base/connector/index.spec.tsx rename to web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx index ffb86336f9..ccd637887b 100644 --- a/web/app/components/datasets/external-knowledge-base/connector/index.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx @@ -3,9 +3,8 @@ import type { ExternalAPIItem } from '@/models/datasets' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { createExternalKnowledgeBase } from '@/service/datasets' -import ExternalKnowledgeBaseConnector from './index' +import ExternalKnowledgeBaseConnector from '../index' -// Mock next/navigation const mockRouterBack = vi.fn() const mockReplace = vi.fn() vi.mock('next/navigation', () => ({ @@ -22,7 +21,6 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`, })) -// Mock toast context const mockNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ useToastContext: () => ({ @@ -262,7 +260,6 @@ describe('ExternalKnowledgeBaseConnector', () => { expect(connectButton).not.toBeDisabled() }) - // Click connect const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') await user.click(connectButton!) diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx new file mode 100644 index 0000000000..3b8b35a5b7 --- /dev/null +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx @@ -0,0 +1,104 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Explicit react-i18next mock so the test stays portable +// even if the global vitest.setup changes. + +// Hoisted mocks +const mocks = vi.hoisted(() => ({ + push: vi.fn(), + refresh: vi.fn(), + setShowExternalKnowledgeAPIModal: vi.fn(), + mutateExternalKnowledgeApis: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push, refresh: mocks.refresh }), +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowExternalKnowledgeAPIModal: mocks.setShowExternalKnowledgeAPIModal, + }), +})) + +vi.mock('@/context/external-knowledge-api-context', () => ({ + useExternalKnowledgeApi: () => ({ + mutateExternalKnowledgeApis: mocks.mutateExternalKnowledgeApis, + }), +})) + +vi.mock('@/app/components/base/icons/src/vender/solid/development', () => ({ + ApiConnectionMod: (props: Record<string, unknown>) => <span data-testid="api-icon" {...props} />, +})) + +const { default: ExternalApiSelect } = await import('../ExternalApiSelect') + +describe('ExternalApiSelect', () => { + const items = [ + { value: 'api-1', name: 'API One', url: 'https://api1.com' }, + { value: 'api-2', name: 'API Two', url: 'https://api2.com' }, + ] + const onSelect = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('should show placeholder when no value selected', () => { + render(<ExternalApiSelect items={items} onSelect={onSelect} />) + expect(screen.getByText('dataset.selectExternalKnowledgeAPI.placeholder')).toBeInTheDocument() + }) + + it('should show selected item name when value matches', () => { + render(<ExternalApiSelect items={items} value="api-1" onSelect={onSelect} />) + expect(screen.getByText('API One')).toBeInTheDocument() + }) + + it('should not show dropdown initially', () => { + render(<ExternalApiSelect items={items} onSelect={onSelect} />) + expect(screen.queryByText('API Two')).not.toBeInTheDocument() + }) + }) + + describe('dropdown interactions', () => { + it('should open dropdown on click', () => { + render(<ExternalApiSelect items={items} onSelect={onSelect} />) + fireEvent.click(screen.getByText('dataset.selectExternalKnowledgeAPI.placeholder')) + expect(screen.getByText('API One')).toBeInTheDocument() + expect(screen.getByText('API Two')).toBeInTheDocument() + }) + + it('should close dropdown and call onSelect when item clicked', () => { + render(<ExternalApiSelect items={items} onSelect={onSelect} />) + // Open + fireEvent.click(screen.getByText('dataset.selectExternalKnowledgeAPI.placeholder')) + // Select + fireEvent.click(screen.getByText('API Two')) + expect(onSelect).toHaveBeenCalledWith(items[1]) + // Dropdown should close - selected name should show + expect(screen.getByText('API Two')).toBeInTheDocument() + }) + + it('should show add new API option in dropdown', () => { + render(<ExternalApiSelect items={items} onSelect={onSelect} />) + fireEvent.click(screen.getByText('dataset.selectExternalKnowledgeAPI.placeholder')) + expect(screen.getByText('dataset.createNewExternalAPI')).toBeInTheDocument() + }) + + it('should call setShowExternalKnowledgeAPIModal when add new clicked', () => { + render(<ExternalApiSelect items={items} onSelect={onSelect} />) + fireEvent.click(screen.getByText('dataset.selectExternalKnowledgeAPI.placeholder')) + fireEvent.click(screen.getByText('dataset.createNewExternalAPI')) + expect(mocks.setShowExternalKnowledgeAPIModal).toHaveBeenCalledOnce() + }) + + it('should show item URLs in dropdown', () => { + render(<ExternalApiSelect items={items} onSelect={onSelect} />) + fireEvent.click(screen.getByText('dataset.selectExternalKnowledgeAPI.placeholder')) + expect(screen.getByText('https://api1.com')).toBeInTheDocument() + expect(screen.getByText('https://api2.com')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx new file mode 100644 index 0000000000..702890bee9 --- /dev/null +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx @@ -0,0 +1,112 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Hoisted mocks +const mocks = vi.hoisted(() => ({ + push: vi.fn(), + refresh: vi.fn(), + setShowExternalKnowledgeAPIModal: vi.fn(), + mutateExternalKnowledgeApis: vi.fn(), + externalKnowledgeApiList: [] as Array<{ id: string, name: string, settings: { endpoint: string } }>, +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push, refresh: mocks.refresh }), +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowExternalKnowledgeAPIModal: mocks.setShowExternalKnowledgeAPIModal, + }), +})) + +vi.mock('@/context/external-knowledge-api-context', () => ({ + useExternalKnowledgeApi: () => ({ + externalKnowledgeApiList: mocks.externalKnowledgeApiList, + mutateExternalKnowledgeApis: mocks.mutateExternalKnowledgeApis, + }), +})) + +// Mock ExternalApiSelect as simple stub +type MockSelectItem = { value: string, name: string } +vi.mock('../ExternalApiSelect', () => ({ + default: ({ items, value, onSelect }: { items: MockSelectItem[], value?: string, onSelect: (item: MockSelectItem) => void }) => ( + <div data-testid="external-api-select"> + <span data-testid="select-value">{value}</span> + <span data-testid="select-items-count">{items.length}</span> + {items.map((item: MockSelectItem) => ( + <button key={item.value} data-testid={`select-${item.value}`} onClick={() => onSelect(item)}> + {item.name} + </button> + ))} + </div> + ), +})) + +const { default: ExternalApiSelection } = await import('../ExternalApiSelection') + +describe('ExternalApiSelection', () => { + const defaultProps = { + external_knowledge_api_id: '', + external_knowledge_id: '', + onChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.externalKnowledgeApiList = [ + { id: 'api-1', name: 'API One', settings: { endpoint: 'https://api1.com' } }, + { id: 'api-2', name: 'API Two', settings: { endpoint: 'https://api2.com' } }, + ] + }) + + describe('rendering', () => { + it('should render API selection label', () => { + render(<ExternalApiSelection {...defaultProps} />) + expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument() + }) + + it('should render knowledge ID label and input', () => { + render(<ExternalApiSelection {...defaultProps} />) + expect(screen.getByText('dataset.externalKnowledgeId')).toBeInTheDocument() + }) + + it('should render ExternalApiSelect when APIs exist', () => { + render(<ExternalApiSelection {...defaultProps} />) + expect(screen.getByTestId('external-api-select')).toBeInTheDocument() + expect(screen.getByTestId('select-items-count').textContent).toBe('2') + }) + + it('should show add button when no APIs exist', () => { + mocks.externalKnowledgeApiList = [] + render(<ExternalApiSelection {...defaultProps} />) + expect(screen.getByText('dataset.noExternalKnowledge')).toBeInTheDocument() + }) + }) + + describe('interactions', () => { + it('should call onChange when API selected', () => { + render(<ExternalApiSelection {...defaultProps} />) + fireEvent.click(screen.getByTestId('select-api-2')) + expect(defaultProps.onChange).toHaveBeenCalledWith( + expect.objectContaining({ external_knowledge_api_id: 'api-2' }), + ) + }) + + it('should call onChange when knowledge ID input changes', () => { + render(<ExternalApiSelection {...defaultProps} />) + const input = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + fireEvent.change(input, { target: { value: 'kb-123' } }) + expect(defaultProps.onChange).toHaveBeenCalledWith( + expect.objectContaining({ external_knowledge_id: 'kb-123' }), + ) + }) + + it('should call setShowExternalKnowledgeAPIModal when add button clicked', () => { + mocks.externalKnowledgeApiList = [] + render(<ExternalApiSelection {...defaultProps} />) + fireEvent.click(screen.getByText('dataset.noExternalKnowledge')) + expect(mocks.setShowExternalKnowledgeAPIModal).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/InfoPanel.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/InfoPanel.spec.tsx new file mode 100644 index 0000000000..9965565111 --- /dev/null +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/InfoPanel.spec.tsx @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import InfoPanel from '../InfoPanel' + +// Mock useDocLink from @/context/i18n +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, +})) + +describe('InfoPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verifies the panel renders all expected content + describe('Rendering', () => { + it('should render without crashing', () => { + render(<InfoPanel />) + expect(screen.getByText(/connectDatasetIntro\.title/)).toBeInTheDocument() + }) + + it('should render the title text', () => { + render(<InfoPanel />) + expect(screen.getByText(/connectDatasetIntro\.title/)).toBeInTheDocument() + }) + + it('should render the front content text', () => { + render(<InfoPanel />) + expect(screen.getByText(/connectDatasetIntro\.content\.front/)).toBeInTheDocument() + }) + + it('should render the content link', () => { + render(<InfoPanel />) + expect(screen.getByText(/connectDatasetIntro\.content\.link/)).toBeInTheDocument() + }) + + it('should render the end content text', () => { + render(<InfoPanel />) + expect(screen.getByText(/connectDatasetIntro\.content\.end/)).toBeInTheDocument() + }) + + it('should render the learn more link', () => { + render(<InfoPanel />) + expect(screen.getByText(/connectDatasetIntro\.learnMore/)).toBeInTheDocument() + }) + + it('should render the book icon', () => { + const { container } = render(<InfoPanel />) + const svgIcons = container.querySelectorAll('svg') + expect(svgIcons.length).toBeGreaterThanOrEqual(1) + }) + }) + + // Props: tests links and their attributes + describe('Links', () => { + it('should have correct href for external knowledge API doc link', () => { + render(<InfoPanel />) + const docLink = screen.getByText(/connectDatasetIntro\.content\.link/) + expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/use-dify/knowledge/external-knowledge-api') + }) + + it('should have correct href for learn more link', () => { + render(<InfoPanel />) + const learnMoreLink = screen.getByText(/connectDatasetIntro\.learnMore/) + expect(learnMoreLink).toHaveAttribute('href', 'https://docs.dify.ai/use-dify/knowledge/connect-external-knowledge-base') + }) + + it('should open links in new tab', () => { + render(<InfoPanel />) + const docLink = screen.getByText(/connectDatasetIntro\.content\.link/) + expect(docLink).toHaveAttribute('target', '_blank') + expect(docLink).toHaveAttribute('rel', 'noopener noreferrer') + + const learnMoreLink = screen.getByText(/connectDatasetIntro\.learnMore/) + expect(learnMoreLink).toHaveAttribute('target', '_blank') + expect(learnMoreLink).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) + + // Styles: checks structural class names + describe('Styles', () => { + it('should have correct container width', () => { + const { container } = render(<InfoPanel />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('w-[360px]') + }) + + it('should have correct panel background', () => { + const { container } = render(<InfoPanel />) + const panel = container.querySelector('.bg-background-section') + expect(panel).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/KnowledgeBaseInfo.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/KnowledgeBaseInfo.spec.tsx new file mode 100644 index 0000000000..3e2698ccb6 --- /dev/null +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/KnowledgeBaseInfo.spec.tsx @@ -0,0 +1,153 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import KnowledgeBaseInfo from '../KnowledgeBaseInfo' + +describe('KnowledgeBaseInfo', () => { + const defaultProps = { + name: '', + description: '', + onChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verifies all form fields render + describe('Rendering', () => { + it('should render without crashing', () => { + render(<KnowledgeBaseInfo {...defaultProps} />) + expect(screen.getByText(/externalKnowledgeName/)).toBeInTheDocument() + }) + + it('should render the name label', () => { + render(<KnowledgeBaseInfo {...defaultProps} />) + expect(screen.getByText(/externalKnowledgeName(?!Placeholder)/)).toBeInTheDocument() + }) + + it('should render the description label', () => { + render(<KnowledgeBaseInfo {...defaultProps} />) + expect(screen.getByText(/externalKnowledgeDescription(?!Placeholder)/)).toBeInTheDocument() + }) + + it('should render name input with placeholder', () => { + render(<KnowledgeBaseInfo {...defaultProps} />) + const input = screen.getByPlaceholderText(/externalKnowledgeNamePlaceholder/) + expect(input).toBeInTheDocument() + }) + + it('should render description textarea with placeholder', () => { + render(<KnowledgeBaseInfo {...defaultProps} />) + const textarea = screen.getByPlaceholderText(/externalKnowledgeDescriptionPlaceholder/) + expect(textarea).toBeInTheDocument() + }) + }) + + // Props: tests value display and onChange callbacks + describe('Props', () => { + it('should display name in the input', () => { + render(<KnowledgeBaseInfo {...defaultProps} name="My Knowledge Base" />) + const input = screen.getByDisplayValue('My Knowledge Base') + expect(input).toBeInTheDocument() + }) + + it('should display description in the textarea', () => { + render(<KnowledgeBaseInfo {...defaultProps} description="A description" />) + const textarea = screen.getByDisplayValue('A description') + expect(textarea).toBeInTheDocument() + }) + + it('should call onChange with name when name input changes', () => { + const onChange = vi.fn() + render(<KnowledgeBaseInfo {...defaultProps} onChange={onChange} />) + const input = screen.getByPlaceholderText(/externalKnowledgeNamePlaceholder/) + + fireEvent.change(input, { target: { value: 'New Name' } }) + + expect(onChange).toHaveBeenCalledWith({ name: 'New Name' }) + }) + + it('should call onChange with description when textarea changes', () => { + const onChange = vi.fn() + render(<KnowledgeBaseInfo {...defaultProps} onChange={onChange} />) + const textarea = screen.getByPlaceholderText(/externalKnowledgeDescriptionPlaceholder/) + + fireEvent.change(textarea, { target: { value: 'New Description' } }) + + expect(onChange).toHaveBeenCalledWith({ description: 'New Description' }) + }) + }) + + // User Interactions: tests form interactions + describe('User Interactions', () => { + it('should allow typing in name input', () => { + const onChange = vi.fn() + render(<KnowledgeBaseInfo {...defaultProps} onChange={onChange} />) + const input = screen.getByPlaceholderText(/externalKnowledgeNamePlaceholder/) + + fireEvent.change(input, { target: { value: 'Typed Name' } }) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith({ name: 'Typed Name' }) + }) + + it('should allow typing in description textarea', () => { + const onChange = vi.fn() + render(<KnowledgeBaseInfo {...defaultProps} onChange={onChange} />) + const textarea = screen.getByPlaceholderText(/externalKnowledgeDescriptionPlaceholder/) + + fireEvent.change(textarea, { target: { value: 'Typed Desc' } }) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith({ description: 'Typed Desc' }) + }) + }) + + // Edge Cases: tests boundary values + describe('Edge Cases', () => { + it('should handle empty name', () => { + render(<KnowledgeBaseInfo {...defaultProps} name="" />) + const input = screen.getByPlaceholderText(/externalKnowledgeNamePlaceholder/) + expect(input).toHaveValue('') + }) + + it('should handle undefined description', () => { + render(<KnowledgeBaseInfo {...defaultProps} description={undefined} />) + const textarea = screen.getByPlaceholderText(/externalKnowledgeDescriptionPlaceholder/) + expect(textarea).toBeInTheDocument() + }) + + it('should handle very long name', () => { + const longName = 'K'.repeat(500) + render(<KnowledgeBaseInfo {...defaultProps} name={longName} />) + const input = screen.getByDisplayValue(longName) + expect(input).toBeInTheDocument() + }) + + it('should handle very long description', () => { + const longDesc = 'D'.repeat(2000) + render(<KnowledgeBaseInfo {...defaultProps} description={longDesc} />) + const textarea = screen.getByDisplayValue(longDesc) + expect(textarea).toBeInTheDocument() + }) + + it('should handle special characters in name', () => { + const specialName = 'Test & "quotes" <angle>' + render(<KnowledgeBaseInfo {...defaultProps} name={specialName} />) + const input = screen.getByDisplayValue(specialName) + expect(input).toBeInTheDocument() + }) + + it('should apply filled text color class when description has content', () => { + const { container } = render(<KnowledgeBaseInfo {...defaultProps} description="has content" />) + const textarea = container.querySelector('textarea') + expect(textarea).toHaveClass('text-components-input-text-filled') + }) + + it('should apply placeholder text color class when description is empty', () => { + const { container } = render(<KnowledgeBaseInfo {...defaultProps} description="" />) + const textarea = container.querySelector('textarea') + expect(textarea).toHaveClass('text-components-input-text-placeholder') + }) + }) +}) diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx new file mode 100644 index 0000000000..e4da8a1a5a --- /dev/null +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/RetrievalSettings.spec.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Mock param items to simplify testing +vi.mock('@/app/components/base/param-item/top-k-item', () => ({ + default: ({ value, onChange, enable }: { value: number, onChange: (key: string, val: number) => void, enable: boolean }) => ( + <div data-testid="top-k-item"> + <span data-testid="top-k-value">{value}</span> + <button data-testid="top-k-change" onClick={() => onChange('top_k', 8)}>change</button> + <span data-testid="top-k-enabled">{String(enable)}</span> + </div> + ), +})) + +vi.mock('@/app/components/base/param-item/score-threshold-item', () => ({ + default: ({ value, onChange, enable, onSwitchChange }: { value: number, onChange: (key: string, val: number) => void, enable: boolean, onSwitchChange: (key: string, val: boolean) => void }) => ( + <div data-testid="score-threshold-item"> + <span data-testid="score-value">{value}</span> + <button data-testid="score-change" onClick={() => onChange('score_threshold', 0.9)}>change</button> + <span data-testid="score-enabled">{String(enable)}</span> + <button data-testid="score-switch" onClick={() => onSwitchChange('score_threshold_enabled', true)}>switch</button> + </div> + ), +})) + +const { default: RetrievalSettings } = await import('../RetrievalSettings') + +describe('RetrievalSettings', () => { + const defaultProps = { + topK: 3, + scoreThreshold: 0.5, + scoreThresholdEnabled: false, + onChange: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('should render TopKItem and ScoreThresholdItem', () => { + render(<RetrievalSettings {...defaultProps} />) + expect(screen.getByTestId('top-k-item')).toBeInTheDocument() + expect(screen.getByTestId('score-threshold-item')).toBeInTheDocument() + }) + + it('should pass topK value to TopKItem', () => { + render(<RetrievalSettings {...defaultProps} />) + expect(screen.getByTestId('top-k-value').textContent).toBe('3') + }) + + it('should pass scoreThreshold to ScoreThresholdItem', () => { + render(<RetrievalSettings {...defaultProps} />) + expect(screen.getByTestId('score-value').textContent).toBe('0.5') + }) + + it('should show label when not in hit testing and not in retrieval setting', () => { + render(<RetrievalSettings {...defaultProps} />) + expect(screen.getByText('dataset.retrievalSettings')).toBeInTheDocument() + }) + + it('should hide label when isInHitTesting is true', () => { + render(<RetrievalSettings {...defaultProps} isInHitTesting />) + expect(screen.queryByText('dataset.retrievalSettings')).not.toBeInTheDocument() + }) + + it('should hide label when isInRetrievalSetting is true', () => { + render(<RetrievalSettings {...defaultProps} isInRetrievalSetting />) + expect(screen.queryByText('dataset.retrievalSettings')).not.toBeInTheDocument() + }) + }) + + describe('user interactions', () => { + it('should call onChange with top_k when TopKItem changes', () => { + render(<RetrievalSettings {...defaultProps} />) + fireEvent.click(screen.getByTestId('top-k-change')) + expect(defaultProps.onChange).toHaveBeenCalledWith({ top_k: 8 }) + }) + + it('should call onChange with score_threshold when ScoreThresholdItem changes', () => { + render(<RetrievalSettings {...defaultProps} />) + fireEvent.click(screen.getByTestId('score-change')) + expect(defaultProps.onChange).toHaveBeenCalledWith({ score_threshold: 0.9 }) + }) + + it('should call onChange with score_threshold_enabled when switch changes', () => { + render(<RetrievalSettings {...defaultProps} />) + fireEvent.click(screen.getByTestId('score-switch')) + expect(defaultProps.onChange).toHaveBeenCalledWith({ score_threshold_enabled: true }) + }) + }) +}) diff --git a/web/app/components/datasets/external-knowledge-base/create/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/datasets/external-knowledge-base/create/index.spec.tsx rename to web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx index d56833fd36..b8aa8b33d7 100644 --- a/web/app/components/datasets/external-knowledge-base/create/index.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx @@ -2,10 +2,9 @@ import type { ExternalAPIItem } from '@/models/datasets' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' -import ExternalKnowledgeBaseCreate from './index' -import RetrievalSettings from './RetrievalSettings' +import ExternalKnowledgeBaseCreate from '../index' +import RetrievalSettings from '../RetrievalSettings' -// Mock next/navigation const mockReplace = vi.fn() const mockRefresh = vi.fn() vi.mock('next/navigation', () => ({ @@ -438,7 +437,6 @@ describe('ExternalKnowledgeBaseCreate', () => { const onConnect = vi.fn() renderComponent({ onConnect }) - // Click on the API selector to open dropdown const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) @@ -484,7 +482,6 @@ describe('ExternalKnowledgeBaseCreate', () => { mockExternalKnowledgeApiList = [] renderComponent() - // Click the add button const addButton = screen.getByText('dataset.noExternalKnowledge').closest('button') await user.click(addButton!) @@ -503,7 +500,6 @@ describe('ExternalKnowledgeBaseCreate', () => { mockExternalKnowledgeApiList = [] renderComponent() - // Click the add button const addButton = screen.getByText('dataset.noExternalKnowledge').closest('button') await user.click(addButton!) @@ -521,7 +517,6 @@ describe('ExternalKnowledgeBaseCreate', () => { mockExternalKnowledgeApiList = [] renderComponent() - // Click the add button const addButton = screen.getByText('dataset.noExternalKnowledge').closest('button') await user.click(addButton!) @@ -536,7 +531,6 @@ describe('ExternalKnowledgeBaseCreate', () => { const user = userEvent.setup() renderComponent() - // Click on the API selector to open dropdown const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) @@ -549,7 +543,6 @@ describe('ExternalKnowledgeBaseCreate', () => { const user = userEvent.setup() renderComponent() - // Click on the API selector to open dropdown const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) @@ -561,11 +554,9 @@ describe('ExternalKnowledgeBaseCreate', () => { const user = userEvent.setup() renderComponent() - // Click on the API selector to open dropdown const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) - // Click on create new API option const createNewApiOption = screen.getByText('dataset.createNewExternalAPI') await user.click(createNewApiOption) @@ -582,11 +573,9 @@ describe('ExternalKnowledgeBaseCreate', () => { const user = userEvent.setup() renderComponent() - // Click on the API selector to open dropdown const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) - // Click on create new API option const createNewApiOption = screen.getByText('dataset.createNewExternalAPI') await user.click(createNewApiOption) @@ -602,11 +591,9 @@ describe('ExternalKnowledgeBaseCreate', () => { const user = userEvent.setup() renderComponent() - // Click on the API selector to open dropdown const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) - // Click on create new API option const createNewApiOption = screen.getByText('dataset.createNewExternalAPI') await user.click(createNewApiOption) @@ -621,7 +608,6 @@ describe('ExternalKnowledgeBaseCreate', () => { const user = userEvent.setup() renderComponent() - // Click on the API selector to open dropdown const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) @@ -640,12 +626,10 @@ describe('ExternalKnowledgeBaseCreate', () => { const user = userEvent.setup() renderComponent() - // Click to open const apiSelector = screen.getByText('Test API 1') await user.click(apiSelector) expect(screen.getByText('https://api1.example.com')).toBeInTheDocument() - // Click again to close await user.click(apiSelector) expect(screen.queryByText('https://api1.example.com')).not.toBeInTheDocument() }) diff --git a/web/app/components/datasets/extra-info/index.spec.tsx b/web/app/components/datasets/extra-info/__tests__/index.spec.tsx similarity index 95% rename from web/app/components/datasets/extra-info/index.spec.tsx rename to web/app/components/datasets/extra-info/__tests__/index.spec.tsx index ce34ea26e3..f4e651d3c5 100644 --- a/web/app/components/datasets/extra-info/index.spec.tsx +++ b/web/app/components/datasets/extra-info/__tests__/index.spec.tsx @@ -4,20 +4,15 @@ import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import { AppModeEnum } from '@/types/app' -// ============================================================================ // Component Imports (after mocks) -// ============================================================================ -import ApiAccess from './api-access' -import ApiAccessCard from './api-access/card' -import ExtraInfo from './index' -import Statistics from './statistics' +import ApiAccess from '../api-access' +import ApiAccessCard from '../api-access/card' +import ExtraInfo from '../index' +import Statistics from '../statistics' -// ============================================================================ // Mock Setup -// ============================================================================ -// Mock next/navigation vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), @@ -69,7 +64,6 @@ vi.mock('@/context/app-context', () => ({ ), })) -// Mock service hooks const mockEnableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' })) const mockDisableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' })) @@ -111,9 +105,7 @@ vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ ), })) -// ============================================================================ // Test Data Factory -// ============================================================================ const createMockRelatedApp = (overrides: Partial<RelatedApp> = {}): RelatedApp => ({ id: 'app-1', @@ -132,9 +124,7 @@ const createMockRelatedAppsResponse = (count: number = 2): RelatedAppResponse => total: count, }) -// ============================================================================ // Statistics Component Tests -// ============================================================================ describe('Statistics', () => { beforeEach(() => { @@ -372,9 +362,7 @@ describe('Statistics', () => { }) }) -// ============================================================================ // ApiAccess Component Tests -// ============================================================================ describe('ApiAccess', () => { beforeEach(() => { @@ -528,9 +516,7 @@ describe('ApiAccess', () => { }) }) -// ============================================================================ // ApiAccessCard Component Tests -// ============================================================================ describe('ApiAccessCard', () => { beforeEach(() => { @@ -745,9 +731,7 @@ describe('ApiAccessCard', () => { }) }) -// ============================================================================ // ExtraInfo (Main Component) Tests -// ============================================================================ describe('ExtraInfo', () => { beforeEach(() => { @@ -1101,10 +1085,6 @@ describe('ExtraInfo', () => { }) }) -// ============================================================================ -// Integration Tests -// ============================================================================ - describe('ExtraInfo Integration', () => { beforeEach(() => { vi.clearAllMocks() @@ -1142,7 +1122,6 @@ describe('ExtraInfo Integration', () => { expect(screen.getByText('10')).toBeInTheDocument() expect(screen.getByText('3')).toBeInTheDocument() - // Click on ApiAccess to open the card const apiAccessTrigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]') if (apiAccessTrigger) await user.click(apiAccessTrigger) diff --git a/web/app/components/datasets/extra-info/statistics.spec.tsx b/web/app/components/datasets/extra-info/__tests__/statistics.spec.tsx similarity index 88% rename from web/app/components/datasets/extra-info/statistics.spec.tsx rename to web/app/components/datasets/extra-info/__tests__/statistics.spec.tsx index d7f79a1ab2..5cc6a9b1d5 100644 --- a/web/app/components/datasets/extra-info/statistics.spec.tsx +++ b/web/app/components/datasets/extra-info/__tests__/statistics.spec.tsx @@ -2,14 +2,7 @@ import type { RelatedApp, RelatedAppResponse } from '@/models/datasets' import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { AppModeEnum } from '@/types/app' -import Statistics from './statistics' - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import Statistics from '../statistics' // Mock useDocLink vi.mock('@/context/i18n', () => ({ @@ -43,7 +36,7 @@ describe('Statistics', () => { it('should render document label', () => { render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />) - expect(screen.getByText('datasetMenus.documents')).toBeInTheDocument() + expect(screen.getByText('common.datasetMenus.documents')).toBeInTheDocument() }) it('should render related apps total', () => { @@ -53,7 +46,7 @@ describe('Statistics', () => { it('should render related app label', () => { render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />) - expect(screen.getByText('datasetMenus.relatedApp')).toBeInTheDocument() + expect(screen.getByText('common.datasetMenus.relatedApp')).toBeInTheDocument() }) it('should render -- for undefined document count', () => { diff --git a/web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx b/web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx new file mode 100644 index 0000000000..3fa542f002 --- /dev/null +++ b/web/app/components/datasets/extra-info/api-access/__tests__/card.spec.tsx @@ -0,0 +1,186 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Card from '../card' + +// Shared mock state for context selectors +let mockDatasetId: string | undefined = 'dataset-123' +let mockMutateDatasetRes: ReturnType<typeof vi.fn> = vi.fn() +let mockIsCurrentWorkspaceManager = true + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: (selector: (state: Record<string, unknown>) => unknown) => + selector({ + dataset: { id: mockDatasetId }, + mutateDatasetRes: mockMutateDatasetRes, + }), +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: Record<string, unknown>) => unknown) => + selector({ isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager }), +})) + +const mockEnableApi = vi.fn() +const mockDisableApi = vi.fn() + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useEnableDatasetServiceApi: () => ({ + mutateAsync: mockEnableApi, + }), + useDisableDatasetServiceApi: () => ({ + mutateAsync: mockDisableApi, + }), +})) + +vi.mock('@/hooks/use-api-access-url', () => ({ + useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets', +})) + +describe('Card (API Access)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDatasetId = 'dataset-123' + mockMutateDatasetRes = vi.fn() + mockIsCurrentWorkspaceManager = true + }) + + // Rendering: verifies enabled/disabled states render correctly + describe('Rendering', () => { + it('should render without crashing when api is enabled', () => { + render(<Card apiEnabled={true} />) + expect(screen.getByText(/serviceApi\.enabled/)).toBeInTheDocument() + }) + + it('should render without crashing when api is disabled', () => { + render(<Card apiEnabled={false} />) + expect(screen.getByText(/serviceApi\.disabled/)).toBeInTheDocument() + }) + + it('should render API access tip text', () => { + render(<Card apiEnabled={true} />) + expect(screen.getByText(/appMenus\.apiAccessTip/)).toBeInTheDocument() + }) + + it('should render API reference link', () => { + render(<Card apiEnabled={true} />) + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets') + }) + + it('should render API doc text in link', () => { + render(<Card apiEnabled={true} />) + expect(screen.getByText(/apiInfo\.doc/)).toBeInTheDocument() + }) + + it('should open API reference link in new tab', () => { + render(<Card apiEnabled={true} />) + const link = screen.getByRole('link') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) + + // Props: tests enabled/disabled visual states + describe('Props', () => { + it('should show green indicator text when enabled', () => { + render(<Card apiEnabled={true} />) + const enabledText = screen.getByText(/serviceApi\.enabled/) + expect(enabledText).toHaveClass('text-text-success') + }) + + it('should show warning text when disabled', () => { + render(<Card apiEnabled={false} />) + const disabledText = screen.getByText(/serviceApi\.disabled/) + expect(disabledText).toHaveClass('text-text-warning') + }) + }) + + // User Interactions: tests toggle behavior + describe('User Interactions', () => { + it('should call enableDatasetServiceApi when toggling on', async () => { + mockEnableApi.mockResolvedValue({ result: 'success' }) + render(<Card apiEnabled={false} />) + + const switchButton = screen.getByRole('switch') + fireEvent.click(switchButton) + + await waitFor(() => { + expect(mockEnableApi).toHaveBeenCalledWith('dataset-123') + }) + }) + + it('should call disableDatasetServiceApi when toggling off', async () => { + mockDisableApi.mockResolvedValue({ result: 'success' }) + render(<Card apiEnabled={true} />) + + const switchButton = screen.getByRole('switch') + fireEvent.click(switchButton) + + await waitFor(() => { + expect(mockDisableApi).toHaveBeenCalledWith('dataset-123') + }) + }) + + it('should call mutateDatasetRes on successful toggle', async () => { + mockEnableApi.mockResolvedValue({ result: 'success' }) + render(<Card apiEnabled={false} />) + + const switchButton = screen.getByRole('switch') + fireEvent.click(switchButton) + + await waitFor(() => { + expect(mockMutateDatasetRes).toHaveBeenCalled() + }) + }) + + it('should not call mutateDatasetRes when result is not success', async () => { + mockEnableApi.mockResolvedValue({ result: 'fail' }) + render(<Card apiEnabled={false} />) + + const switchButton = screen.getByRole('switch') + fireEvent.click(switchButton) + + await waitFor(() => { + expect(mockEnableApi).toHaveBeenCalled() + }) + expect(mockMutateDatasetRes).not.toHaveBeenCalled() + }) + }) + + // Switch disabled state + describe('Switch State', () => { + it('should disable switch when user is not workspace manager', () => { + mockIsCurrentWorkspaceManager = false + render(<Card apiEnabled={true} />) + + const switchButton = screen.getByRole('switch') + expect(switchButton).toHaveAttribute('aria-checked', 'true') + // Headless UI Switch uses CSS classes for disabled state, not the disabled attribute + expect(switchButton).toHaveClass('!cursor-not-allowed', '!opacity-50') + }) + + it('should enable switch when user is workspace manager', () => { + mockIsCurrentWorkspaceManager = true + render(<Card apiEnabled={true} />) + + const switchButton = screen.getByRole('switch') + expect(switchButton).not.toBeDisabled() + }) + }) + + // Edge Cases: tests boundary scenarios + describe('Edge Cases', () => { + it('should handle undefined dataset id', async () => { + mockDatasetId = undefined + mockEnableApi.mockResolvedValue({ result: 'success' }) + render(<Card apiEnabled={false} />) + + const switchButton = screen.getByRole('switch') + fireEvent.click(switchButton) + + await waitFor(() => { + expect(mockEnableApi).toHaveBeenCalledWith('') + }) + }) + }) +}) diff --git a/web/app/components/datasets/extra-info/api-access/index.spec.tsx b/web/app/components/datasets/extra-info/api-access/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/datasets/extra-info/api-access/index.spec.tsx rename to web/app/components/datasets/extra-info/api-access/__tests__/index.spec.tsx index 19e6b1ebca..ff866921f2 100644 --- a/web/app/components/datasets/extra-info/api-access/index.spec.tsx +++ b/web/app/components/datasets/extra-info/api-access/__tests__/index.spec.tsx @@ -1,13 +1,6 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import ApiAccess from './index' - -// Mock react-i18next -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +import ApiAccess from '../index' // Mock context and hooks for Card component vi.mock('@/context/dataset-detail', () => ({ @@ -34,27 +27,27 @@ afterEach(() => { describe('ApiAccess', () => { it('should render without crashing', () => { render(<ApiAccess expand={true} apiEnabled={true} />) - expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument() + expect(screen.getByText('common.appMenus.apiAccess')).toBeInTheDocument() }) it('should render API access text when expanded', () => { render(<ApiAccess expand={true} apiEnabled={true} />) - expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument() + expect(screen.getByText('common.appMenus.apiAccess')).toBeInTheDocument() }) it('should not render API access text when collapsed', () => { render(<ApiAccess expand={false} apiEnabled={true} />) - expect(screen.queryByText('appMenus.apiAccess')).not.toBeInTheDocument() + expect(screen.queryByText('common.appMenus.apiAccess')).not.toBeInTheDocument() }) it('should render with apiEnabled=true', () => { render(<ApiAccess expand={true} apiEnabled={true} />) - expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument() + expect(screen.getByText('common.appMenus.apiAccess')).toBeInTheDocument() }) it('should render with apiEnabled=false', () => { render(<ApiAccess expand={true} apiEnabled={false} />) - expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument() + expect(screen.getByText('common.appMenus.apiAccess')).toBeInTheDocument() }) it('should be wrapped with React.memo', () => { @@ -67,7 +60,6 @@ describe('ApiAccess', () => { const trigger = container.querySelector('.cursor-pointer') expect(trigger).toBeInTheDocument() - // Click to open await act(async () => { fireEvent.click(trigger!) }) diff --git a/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx b/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx new file mode 100644 index 0000000000..1f3907bffc --- /dev/null +++ b/web/app/components/datasets/extra-info/service-api/__tests__/card.spec.tsx @@ -0,0 +1,168 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Card from '../card' + +vi.mock('@/hooks/use-api-access-url', () => ({ + useDatasetApiAccessUrl: () => 'https://docs.dify.ai/api-reference/datasets', +})) + +vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ + default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => + isShow ? <div data-testid="secret-key-modal"><button onClick={onClose}>close</button></div> : null, +})) + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return ({ children }: { children: React.ReactNode }) => ( + <QueryClientProvider client={queryClient}> + {children} + </QueryClientProvider> + ) +} + +const renderWithProviders = (ui: React.ReactElement) => { + return render(ui, { wrapper: createWrapper() }) +} + +describe('Card (Service API)', () => { + const defaultProps = { + apiBaseUrl: 'https://api.dify.ai/v1', + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering: verifies all key elements render + describe('Rendering', () => { + it('should render without crashing', () => { + renderWithProviders(<Card {...defaultProps} />) + expect(screen.getByText(/serviceApi\.card\.title/)).toBeInTheDocument() + }) + + it('should render card title', () => { + renderWithProviders(<Card {...defaultProps} />) + expect(screen.getByText(/serviceApi\.card\.title/)).toBeInTheDocument() + }) + + it('should render enabled status', () => { + renderWithProviders(<Card {...defaultProps} />) + expect(screen.getByText(/serviceApi\.enabled/)).toBeInTheDocument() + }) + + it('should render endpoint label', () => { + renderWithProviders(<Card {...defaultProps} />) + expect(screen.getByText(/serviceApi\.card\.endpoint/)).toBeInTheDocument() + }) + + it('should render the API base URL', () => { + renderWithProviders(<Card {...defaultProps} />) + expect(screen.getByText('https://api.dify.ai/v1')).toBeInTheDocument() + }) + + it('should render API key button', () => { + renderWithProviders(<Card {...defaultProps} />) + expect(screen.getByText(/serviceApi\.card\.apiKey/)).toBeInTheDocument() + }) + + it('should render API reference button', () => { + renderWithProviders(<Card {...defaultProps} />) + expect(screen.getByText(/serviceApi\.card\.apiReference/)).toBeInTheDocument() + }) + }) + + // Props: tests different apiBaseUrl values + describe('Props', () => { + it('should display provided apiBaseUrl', () => { + renderWithProviders(<Card apiBaseUrl="https://custom-api.example.com" />) + expect(screen.getByText('https://custom-api.example.com')).toBeInTheDocument() + }) + + it('should show green indicator when apiBaseUrl is provided', () => { + renderWithProviders(<Card apiBaseUrl="https://api.dify.ai" />) + // The Indicator component receives color="green" when apiBaseUrl is truthy + const statusText = screen.getByText(/serviceApi\.enabled/) + expect(statusText).toHaveClass('text-text-success') + }) + + it('should show yellow indicator when apiBaseUrl is empty', () => { + renderWithProviders(<Card apiBaseUrl="" />) + // Still shows "enabled" text but indicator color differs + expect(screen.getByText(/serviceApi\.enabled/)).toBeInTheDocument() + }) + }) + + // User Interactions: tests button clicks and modal + describe('User Interactions', () => { + it('should open secret key modal when API key button is clicked', () => { + renderWithProviders(<Card {...defaultProps} />) + + // Modal should not be visible before clicking + expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() + + const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/).closest('button') + fireEvent.click(apiKeyButton!) + + // Modal should appear after clicking + expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + }) + + it('should close secret key modal when onClose is called', () => { + renderWithProviders(<Card {...defaultProps} />) + + const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/).closest('button') + fireEvent.click(apiKeyButton!) + expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() + + fireEvent.click(screen.getByText('close')) + expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() + }) + + it('should render API reference as a link', () => { + renderWithProviders(<Card {...defaultProps} />) + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) + + // Styles: verifies container structure + describe('Styles', () => { + it('should have correct container width', () => { + const { container } = renderWithProviders(<Card {...defaultProps} />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('w-[360px]') + }) + + it('should have rounded corners', () => { + const { container } = renderWithProviders(<Card {...defaultProps} />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('rounded-xl') + }) + }) + + // Edge Cases: tests empty/long URLs + describe('Edge Cases', () => { + it('should handle empty apiBaseUrl', () => { + renderWithProviders(<Card apiBaseUrl="" />) + // Should still render the structure + expect(screen.getByText(/serviceApi\.card\.endpoint/)).toBeInTheDocument() + }) + + it('should handle very long apiBaseUrl', () => { + const longUrl = `https://api.dify.ai/${'path/'.repeat(50)}` + renderWithProviders(<Card apiBaseUrl={longUrl} />) + expect(screen.getByText(longUrl)).toBeInTheDocument() + }) + + it('should handle apiBaseUrl with special characters', () => { + const specialUrl = 'https://api.dify.ai/v1?key=value&foo=bar' + renderWithProviders(<Card apiBaseUrl={specialUrl} />) + expect(screen.getByText(specialUrl)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/extra-info/service-api/index.spec.tsx b/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx similarity index 88% rename from web/app/components/datasets/extra-info/service-api/index.spec.tsx rename to web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx index cf912b787f..b94508de6a 100644 --- a/web/app/components/datasets/extra-info/service-api/index.spec.tsx +++ b/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx @@ -2,18 +2,13 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' -// ============================================================================ // Component Imports (after mocks) -// ============================================================================ -import Card from './card' -import ServiceApi from './index' +import Card from '../card' +import ServiceApi from '../index' -// ============================================================================ // Mock Setup -// ============================================================================ -// Mock next/navigation vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), @@ -48,18 +43,13 @@ vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ ), })) -// ============================================================================ // ServiceApi Component Tests -// ============================================================================ describe('ServiceApi', () => { beforeEach(() => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { render(<ServiceApi apiBaseUrl="https://api.example.com" />) @@ -90,9 +80,7 @@ describe('ServiceApi', () => { }) }) - // -------------------------------------------------------------------------- // Props Variations Tests - // -------------------------------------------------------------------------- describe('Props Variations', () => { it('should show green Indicator when apiBaseUrl is provided', () => { const { container } = render(<ServiceApi apiBaseUrl="https://api.example.com" />) @@ -121,9 +109,6 @@ describe('ServiceApi', () => { }) }) - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should toggle popup open state on click', async () => { const user = userEvent.setup() @@ -188,9 +173,7 @@ describe('ServiceApi', () => { }) }) - // -------------------------------------------------------------------------- // Portal and Card Integration Tests - // -------------------------------------------------------------------------- describe('Portal and Card Integration', () => { it('should render Card component inside portal when open', async () => { const user = userEvent.setup() @@ -235,9 +218,6 @@ describe('ServiceApi', () => { }) }) - // -------------------------------------------------------------------------- - // Edge Cases Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle rapid toggle clicks gracefully', async () => { const user = userEvent.setup() @@ -279,9 +259,6 @@ describe('ServiceApi', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be memoized with React.memo', () => { const { rerender } = render(<ServiceApi apiBaseUrl="https://api.example.com" />) @@ -310,18 +287,13 @@ describe('ServiceApi', () => { }) }) -// ============================================================================ // Card Component Tests -// ============================================================================ describe('Card (service-api)', () => { beforeEach(() => { vi.clearAllMocks() }) - // -------------------------------------------------------------------------- - // Rendering Tests - // -------------------------------------------------------------------------- describe('Rendering', () => { it('should render without crashing', () => { render(<Card apiBaseUrl="https://api.example.com" />) @@ -380,9 +352,7 @@ describe('Card (service-api)', () => { }) }) - // -------------------------------------------------------------------------- // Props Variations Tests - // -------------------------------------------------------------------------- describe('Props Variations', () => { it('should show green Indicator when apiBaseUrl is provided', () => { const { container } = render(<Card apiBaseUrl="https://api.example.com" />) @@ -423,9 +393,6 @@ describe('Card (service-api)', () => { }) }) - // -------------------------------------------------------------------------- - // User Interactions Tests - // -------------------------------------------------------------------------- describe('User Interactions', () => { it('should open SecretKeyModal when API Key button is clicked', async () => { const user = userEvent.setup() @@ -448,7 +415,6 @@ describe('Card (service-api)', () => { render(<Card apiBaseUrl="https://api.example.com" />) - // Open modal const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') if (apiKeyButton) await user.click(apiKeyButton) @@ -457,7 +423,6 @@ describe('Card (service-api)', () => { expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() }) - // Close modal const closeButton = screen.getByTestId('close-modal-btn') await user.click(closeButton) @@ -489,7 +454,6 @@ describe('Card (service-api)', () => { // Initially modal should not be visible expect(screen.queryByTestId('secret-key-modal')).not.toBeInTheDocument() - // Open modal const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') if (apiKeyButton) await user.click(apiKeyButton) @@ -499,7 +463,6 @@ describe('Card (service-api)', () => { expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() }) - // Close modal const closeButton = screen.getByTestId('close-modal-btn') await user.click(closeButton) @@ -510,9 +473,7 @@ describe('Card (service-api)', () => { }) }) - // -------------------------------------------------------------------------- // Modal State Tests - // -------------------------------------------------------------------------- describe('Modal State', () => { it('should initialize with modal closed', () => { render(<Card apiBaseUrl="https://api.example.com" />) @@ -547,7 +508,6 @@ describe('Card (service-api)', () => { expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() }) - // Close modal const closeButton = screen.getByTestId('close-modal-btn') await user.click(closeButton) @@ -587,9 +547,6 @@ describe('Card (service-api)', () => { }) }) - // -------------------------------------------------------------------------- - // Edge Cases Tests - // -------------------------------------------------------------------------- describe('Edge Cases', () => { it('should handle empty apiBaseUrl gracefully', () => { render(<Card apiBaseUrl="" />) @@ -614,12 +571,10 @@ describe('Card (service-api)', () => { render(<Card apiBaseUrl="https://api.example.com" />) - // Click API Key button const apiKeyButton = screen.getByText(/serviceApi\.card\.apiKey/i).closest('button') if (apiKeyButton) await user.click(apiKeyButton) - // Close modal await waitFor(() => { expect(screen.getByTestId('secret-key-modal')).toBeInTheDocument() }) @@ -635,9 +590,6 @@ describe('Card (service-api)', () => { }) }) - // -------------------------------------------------------------------------- - // Memoization Tests - // -------------------------------------------------------------------------- describe('Memoization', () => { it('should be memoized with React.memo', () => { const { rerender } = render(<Card apiBaseUrl="https://api.example.com" />) @@ -667,9 +619,7 @@ describe('Card (service-api)', () => { }) }) - // -------------------------------------------------------------------------- // Copy Functionality Tests - // -------------------------------------------------------------------------- describe('Copy Functionality', () => { it('should render CopyFeedback component for apiBaseUrl', () => { const { container } = render(<Card apiBaseUrl="https://api.example.com" />) @@ -686,10 +636,6 @@ describe('Card (service-api)', () => { }) }) -// ============================================================================ -// Integration Tests -// ============================================================================ - describe('ServiceApi Integration', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/components/datasets/formatted-text/__tests__/formatted.spec.tsx b/web/app/components/datasets/formatted-text/__tests__/formatted.spec.tsx new file mode 100644 index 0000000000..2f7fe684ed --- /dev/null +++ b/web/app/components/datasets/formatted-text/__tests__/formatted.spec.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { FormattedText } from '../formatted' + +describe('FormattedText', () => { + it('should render children', () => { + render(<FormattedText>Hello World</FormattedText>) + expect(screen.getByText('Hello World')).toBeInTheDocument() + }) + + it('should apply leading-7 class by default', () => { + render(<FormattedText>Text</FormattedText>) + expect(screen.getByText('Text')).toHaveClass('leading-7') + }) + + it('should merge custom className', () => { + render(<FormattedText className="custom-class">Text</FormattedText>) + const el = screen.getByText('Text') + expect(el).toHaveClass('leading-7') + expect(el).toHaveClass('custom-class') + }) + + it('should render as a p element', () => { + render(<FormattedText>Text</FormattedText>) + expect(screen.getByText('Text').tagName).toBe('P') + }) +}) diff --git a/web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx b/web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx new file mode 100644 index 0000000000..13f7b4862d --- /dev/null +++ b/web/app/components/datasets/formatted-text/flavours/__tests__/edit-slice.spec.tsx @@ -0,0 +1,190 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Capture the onOpenChange callback to simulate hover interactions +let capturedOnOpenChange: ((open: boolean) => void) | null = null + +vi.mock('@floating-ui/react', () => ({ + autoUpdate: vi.fn(), + flip: vi.fn(), + shift: vi.fn(), + offset: vi.fn(), + FloatingFocusManager: ({ children }: { children: React.ReactNode }) => ( + <div data-testid="floating-focus-manager"> + {children} + </div> + ), + useFloating: ({ onOpenChange }: { onOpenChange?: (open: boolean) => void } = {}) => { + capturedOnOpenChange = onOpenChange ?? null + return { + refs: { setReference: vi.fn(), setFloating: vi.fn() }, + floatingStyles: {}, + context: { open: false, onOpenChange: vi.fn(), refs: { domReference: { current: null } }, nodeId: undefined }, + } + }, + useHover: () => ({}), + useDismiss: () => ({}), + useRole: () => ({}), + useInteractions: () => ({ + getReferenceProps: () => ({}), + getFloatingProps: () => ({}), + }), +})) + +vi.mock('@/app/components/base/action-button', () => { + const comp = ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( + <button data-testid="action-button" onClick={onClick}>{children}</button> + ) + return { + default: comp, + ActionButtonState: { Destructive: 'destructive' }, + } +}) + +const { EditSlice } = await import('../edit-slice') + +// Helper to find divider span (zero-width space) +const findDividerSpan = (container: HTMLElement) => + Array.from(container.querySelectorAll('span')).find(s => s.textContent?.includes('\u200B')) + +describe('EditSlice', () => { + const defaultProps = { + label: 'S1', + text: 'Sample text content', + onDelete: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + capturedOnOpenChange = null + }) + + // ---- Rendering Tests ---- + it('should render label and text', () => { + render(<EditSlice {...defaultProps} />) + expect(screen.getByText('S1')).toBeInTheDocument() + expect(screen.getByText('Sample text content')).toBeInTheDocument() + }) + + it('should render divider by default', () => { + const { container } = render(<EditSlice {...defaultProps} />) + expect(findDividerSpan(container)).toBeTruthy() + }) + + it('should not render divider when showDivider is false', () => { + const { container } = render(<EditSlice {...defaultProps} showDivider={false} />) + expect(findDividerSpan(container)).toBeFalsy() + }) + + // ---- Class Name Tests ---- + it('should apply custom labelClassName', () => { + render(<EditSlice {...defaultProps} labelClassName="label-extra" />) + const labelEl = screen.getByText('S1').parentElement + expect(labelEl).toHaveClass('label-extra') + }) + + it('should apply custom contentClassName', () => { + render(<EditSlice {...defaultProps} contentClassName="content-extra" />) + expect(screen.getByText('Sample text content')).toHaveClass('content-extra') + }) + + it('should apply labelInnerClassName to SliceLabel inner span', () => { + render(<EditSlice {...defaultProps} labelInnerClassName="inner-label" />) + expect(screen.getByText('S1')).toHaveClass('inner-label') + }) + + it('should apply custom className to wrapper', () => { + render(<EditSlice {...defaultProps} data-testid="edit-slice" className="custom-slice" />) + expect(screen.getByTestId('edit-slice')).toHaveClass('custom-slice') + }) + + it('should pass rest props to wrapper', () => { + render(<EditSlice {...defaultProps} data-testid="edit-slice" />) + expect(screen.getByTestId('edit-slice')).toBeInTheDocument() + }) + + // ---- Floating UI / Delete Button Tests ---- + it('should not show delete button when floating is closed', () => { + render(<EditSlice {...defaultProps} />) + expect(screen.queryByTestId('floating-focus-manager')).not.toBeInTheDocument() + }) + + it('should show delete button when onOpenChange triggers open', () => { + render(<EditSlice {...defaultProps} />) + act(() => { + capturedOnOpenChange?.(true) + }) + expect(screen.getByTestId('floating-focus-manager')).toBeInTheDocument() + }) + + it('should call onDelete when delete button is clicked', () => { + const onDelete = vi.fn() + render(<EditSlice {...defaultProps} onDelete={onDelete} />) + act(() => { + capturedOnOpenChange?.(true) + }) + fireEvent.click(screen.getByRole('button')) + expect(onDelete).toHaveBeenCalledTimes(1) + }) + + it('should close floating after delete button is clicked', () => { + render(<EditSlice {...defaultProps} />) + act(() => { + capturedOnOpenChange?.(true) + }) + expect(screen.getByTestId('floating-focus-manager')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button')) + expect(screen.queryByTestId('floating-focus-manager')).not.toBeInTheDocument() + }) + + it('should stop event propagation on delete click', () => { + const parentClick = vi.fn() + render( + <div onClick={parentClick}> + <EditSlice {...defaultProps} /> + </div>, + ) + act(() => { + capturedOnOpenChange?.(true) + }) + fireEvent.click(screen.getByRole('button')) + expect(parentClick).not.toHaveBeenCalled() + }) + + // ---- Destructive Hover Style Tests ---- + it('should apply destructive styles when hovering on delete button container', async () => { + render(<EditSlice {...defaultProps} />) + act(() => { + capturedOnOpenChange?.(true) + }) + const floatingSpan = screen.getByTestId('floating-focus-manager').firstElementChild as HTMLElement + fireEvent.mouseEnter(floatingSpan) + + await waitFor(() => { + const labelEl = screen.getByText('S1').parentElement + expect(labelEl).toHaveClass('!bg-state-destructive-solid') + expect(labelEl).toHaveClass('!text-text-primary-on-surface') + }) + expect(screen.getByText('Sample text content')).toHaveClass('!bg-state-destructive-hover-alt') + }) + + it('should remove destructive styles when mouse leaves delete button container', async () => { + render(<EditSlice {...defaultProps} />) + act(() => { + capturedOnOpenChange?.(true) + }) + const floatingSpan = screen.getByTestId('floating-focus-manager').firstElementChild as HTMLElement + fireEvent.mouseEnter(floatingSpan) + + await waitFor(() => { + expect(screen.getByText('S1').parentElement).toHaveClass('!bg-state-destructive-solid') + }) + + fireEvent.mouseLeave(floatingSpan) + + await waitFor(() => { + expect(screen.getByText('S1').parentElement).not.toHaveClass('!bg-state-destructive-solid') + expect(screen.getByText('Sample text content')).not.toHaveClass('!bg-state-destructive-hover-alt') + }) + }) +}) diff --git a/web/app/components/datasets/formatted-text/flavours/__tests__/preview-slice.spec.tsx b/web/app/components/datasets/formatted-text/flavours/__tests__/preview-slice.spec.tsx new file mode 100644 index 0000000000..88a5ee72e5 --- /dev/null +++ b/web/app/components/datasets/formatted-text/flavours/__tests__/preview-slice.spec.tsx @@ -0,0 +1,113 @@ +import { act, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Capture the onOpenChange callback to simulate hover interactions +let capturedOnOpenChange: ((open: boolean) => void) | null = null + +vi.mock('@floating-ui/react', () => ({ + autoUpdate: vi.fn(), + flip: vi.fn(), + shift: vi.fn(), + inline: vi.fn(), + useFloating: ({ onOpenChange }: { onOpenChange?: (open: boolean) => void } = {}) => { + capturedOnOpenChange = onOpenChange ?? null + return { + refs: { setReference: vi.fn(), setFloating: vi.fn() }, + floatingStyles: {}, + context: { open: false, onOpenChange: vi.fn(), refs: { domReference: { current: null } }, nodeId: undefined }, + } + }, + useHover: () => ({}), + useDismiss: () => ({}), + useRole: () => ({}), + useInteractions: () => ({ + getReferenceProps: () => ({}), + getFloatingProps: () => ({}), + }), +})) + +const { PreviewSlice } = await import('../preview-slice') + +// Helper to find divider span (zero-width space) +const findDividerSpan = (container: HTMLElement) => + Array.from(container.querySelectorAll('span')).find(s => s.textContent?.includes('\u200B')) + +describe('PreviewSlice', () => { + const defaultProps = { + label: 'P1', + text: 'Preview text', + tooltip: 'Tooltip content', + } + + beforeEach(() => { + vi.clearAllMocks() + capturedOnOpenChange = null + }) + + // ---- Rendering Tests ---- + it('should render label and text', () => { + render(<PreviewSlice {...defaultProps} />) + expect(screen.getByText('P1')).toBeInTheDocument() + expect(screen.getByText('Preview text')).toBeInTheDocument() + }) + + it('should not show tooltip by default', () => { + render(<PreviewSlice {...defaultProps} />) + expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument() + }) + + it('should always render a divider', () => { + const { container } = render(<PreviewSlice {...defaultProps} />) + expect(findDividerSpan(container)).toBeTruthy() + }) + + // ---- Class Name Tests ---- + it('should apply custom className', () => { + render(<PreviewSlice {...defaultProps} data-testid="preview-slice" className="preview-custom" />) + expect(screen.getByTestId('preview-slice')).toHaveClass('preview-custom') + }) + + it('should apply labelInnerClassName to the label inner span', () => { + render(<PreviewSlice {...defaultProps} labelInnerClassName="label-inner" />) + expect(screen.getByText('P1')).toHaveClass('label-inner') + }) + + it('should pass rest props to wrapper', () => { + render(<PreviewSlice {...defaultProps} data-testid="preview-slice" />) + expect(screen.getByTestId('preview-slice')).toBeInTheDocument() + }) + + // ---- Tooltip Interaction Tests ---- + it('should show tooltip when onOpenChange triggers open', () => { + render(<PreviewSlice {...defaultProps} />) + act(() => { + capturedOnOpenChange?.(true) + }) + expect(screen.getByText('Tooltip content')).toBeInTheDocument() + }) + + it('should hide tooltip when onOpenChange triggers close', () => { + render(<PreviewSlice {...defaultProps} />) + act(() => { + capturedOnOpenChange?.(true) + }) + expect(screen.getByText('Tooltip content')).toBeInTheDocument() + act(() => { + capturedOnOpenChange?.(false) + }) + expect(screen.queryByText('Tooltip content')).not.toBeInTheDocument() + }) + + it('should render ReactNode tooltip content when open', () => { + render(<PreviewSlice {...defaultProps} tooltip={<strong>Rich tooltip</strong>} />) + act(() => { + capturedOnOpenChange?.(true) + }) + expect(screen.getByText('Rich tooltip')).toBeInTheDocument() + }) + + it('should render ReactNode label', () => { + render(<PreviewSlice {...defaultProps} label={<em>Emphasis</em>} />) + expect(screen.getByText('Emphasis')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/formatted-text/flavours/__tests__/shared.spec.tsx b/web/app/components/datasets/formatted-text/flavours/__tests__/shared.spec.tsx new file mode 100644 index 0000000000..036c661e80 --- /dev/null +++ b/web/app/components/datasets/formatted-text/flavours/__tests__/shared.spec.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { SliceContainer, SliceContent, SliceDivider, SliceLabel } from '../shared' + +describe('SliceContainer', () => { + it('should render children', () => { + render(<SliceContainer>content</SliceContainer>) + expect(screen.getByText('content')).toBeInTheDocument() + }) + + it('should be a span element', () => { + render(<SliceContainer>text</SliceContainer>) + expect(screen.getByText('text').tagName).toBe('SPAN') + }) + + it('should merge custom className', () => { + render(<SliceContainer className="custom">text</SliceContainer>) + expect(screen.getByText('text')).toHaveClass('custom') + }) + + it('should have display name', () => { + expect(SliceContainer.displayName).toBe('SliceContainer') + }) +}) + +describe('SliceLabel', () => { + it('should render children with uppercase text', () => { + render(<SliceLabel>Label</SliceLabel>) + expect(screen.getByText('Label')).toBeInTheDocument() + }) + + it('should apply label styling', () => { + render(<SliceLabel>Label</SliceLabel>) + const outer = screen.getByText('Label').parentElement! + expect(outer).toHaveClass('uppercase') + }) + + it('should apply labelInnerClassName to inner span', () => { + render(<SliceLabel labelInnerClassName="inner-class">Label</SliceLabel>) + expect(screen.getByText('Label')).toHaveClass('inner-class') + }) + + it('should have display name', () => { + expect(SliceLabel.displayName).toBe('SliceLabel') + }) +}) + +describe('SliceContent', () => { + it('should render children', () => { + render(<SliceContent>Content</SliceContent>) + expect(screen.getByText('Content')).toBeInTheDocument() + }) + + it('should apply whitespace-pre-line and break-all', () => { + render(<SliceContent>Content</SliceContent>) + const el = screen.getByText('Content') + expect(el).toHaveClass('whitespace-pre-line') + expect(el).toHaveClass('break-all') + }) + + it('should have display name', () => { + expect(SliceContent.displayName).toBe('SliceContent') + }) +}) + +describe('SliceDivider', () => { + it('should render as span', () => { + const { container } = render(<SliceDivider />) + expect(container.querySelector('span')).toBeInTheDocument() + }) + + it('should contain zero-width space', () => { + const { container } = render(<SliceDivider />) + expect(container.textContent).toContain('\u200B') + }) + + it('should merge custom className', () => { + const { container } = render(<SliceDivider className="custom" />) + expect(container.querySelector('span')).toHaveClass('custom') + }) + + it('should have display name', () => { + expect(SliceDivider.displayName).toBe('SliceDivider') + }) +}) diff --git a/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx new file mode 100644 index 0000000000..0a5a55b744 --- /dev/null +++ b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx @@ -0,0 +1,1067 @@ +import type { ReactNode } from 'react' +import type { DataSet, HitTesting, HitTestingRecord, HitTestingResponse } from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { RETRIEVE_METHOD } from '@/types/app' +import HitTestingPage from '../index' + +// Note: These components use real implementations for integration testing: +// - Toast, FloatRightContainer, Drawer, Pagination, Loading +// - RetrievalMethodConfig, EconomicalRetrievalMethodConfig +// - ImageUploaderInRetrievalTesting, retrieval-method-info, check-rerank-model + +// Mock RetrievalSettings to allow triggering onChange +vi.mock('@/app/components/datasets/external-knowledge-base/create/RetrievalSettings', () => ({ + default: ({ onChange }: { onChange: (data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => void }) => { + return ( + <div data-testid="retrieval-settings-mock"> + <button data-testid="change-top-k" onClick={() => onChange({ top_k: 8 })}>Change Top K</button> + <button data-testid="change-score-threshold" onClick={() => onChange({ score_threshold: 0.9 })}>Change Score Threshold</button> + <button data-testid="change-score-enabled" onClick={() => onChange({ score_threshold_enabled: true })}>Change Score Enabled</button> + </div> + ) + }, +})) + +// Mock Setup + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + }), + usePathname: () => '/test', + useSearchParams: () => new URLSearchParams(), +})) + +// Mock use-context-selector +const mockDataset = { + id: 'dataset-1', + name: 'Test Dataset', + provider: 'vendor', + indexing_technique: 'high_quality' as const, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_mode: undefined, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + weights: undefined, + top_k: 10, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + is_multimodal: false, +} as Partial<DataSet> + +vi.mock('use-context-selector', () => ({ + useContext: vi.fn(() => ({ dataset: mockDataset })), + useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })), + createContext: vi.fn(() => ({})), +})) + +// Mock dataset detail context +vi.mock('@/context/dataset-detail', () => ({ + default: {}, + useDatasetDetailContext: vi.fn(() => ({ dataset: mockDataset })), + useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset }) => unknown) => + selector({ dataset: mockDataset as DataSet }), + ), +})) + +const mockRecordsRefetch = vi.fn() +const mockHitTestingMutateAsync = vi.fn() +const mockExternalHitTestingMutateAsync = vi.fn() + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetTestingRecords: vi.fn(() => ({ + data: { + data: [], + total: 0, + page: 1, + limit: 10, + has_more: false, + }, + refetch: mockRecordsRefetch, + isLoading: false, + })), +})) + +vi.mock('@/service/knowledge/use-hit-testing', () => ({ + useHitTesting: vi.fn(() => ({ + mutateAsync: mockHitTestingMutateAsync, + isPending: false, + })), + useExternalKnowledgeBaseHitTesting: vi.fn(() => ({ + mutateAsync: mockExternalHitTestingMutateAsync, + isPending: false, + })), +})) + +// Mock breakpoints hook +vi.mock('@/hooks/use-breakpoints', () => ({ + default: vi.fn(() => 'pc'), + MediaType: { + mobile: 'mobile', + pc: 'pc', + }, +})) + +// Mock timestamp hook +vi.mock('@/hooks/use-timestamp', () => ({ + default: vi.fn(() => ({ + formatTime: vi.fn((timestamp: number, _format: string) => new Date(timestamp * 1000).toISOString()), + })), +})) + +// Mock use-common to avoid QueryClient issues in nested hooks +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: vi.fn(() => ({ + data: { + file_size_limit: 10, + batch_count_limit: 5, + image_file_size_limit: 5, + }, + isLoading: false, + })), +})) + +// Store ref to ImageUploader onChange for testing +let _mockImageUploaderOnChange: ((files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void) | null = null + +// Mock ImageUploaderInRetrievalTesting to capture onChange +vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ + default: ({ textArea, actionButton, onChange }: { + textArea: React.ReactNode + actionButton: React.ReactNode + onChange: (files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void + }) => { + _mockImageUploaderOnChange = onChange + return ( + <div data-testid="image-uploader-mock"> + {textArea} + {actionButton} + <button + data-testid="trigger-image-change" + onClick={() => onChange([ + { + sourceUrl: 'http://example.com/new-image.png', + uploadedId: 'new-uploaded-id', + mimeType: 'image/png', + name: 'new-image.png', + size: 2000, + extension: 'png', + }, + ])} + > + Add Image + </button> + </div> + ) + }, +})) + +// Mock docLink hook +vi.mock('@/context/i18n', () => ({ + useDocLink: vi.fn(() => () => 'https://docs.example.com'), +})) + +// Mock provider context for retrieval method config +vi.mock('@/context/provider-context', () => ({ + useProviderContext: vi.fn(() => ({ + supportRetrievalMethods: [ + 'semantic_search', + 'full_text_search', + 'hybrid_search', + ], + })), +})) + +// Mock model list hook - include all exports used by child components +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: vi.fn(() => ({ + data: [], + isLoading: false, + })), + useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(() => ({ + modelList: [], + defaultModel: undefined, + currentProvider: undefined, + currentModel: undefined, + })), + useModelListAndDefaultModel: vi.fn(() => ({ + modelList: [], + defaultModel: undefined, + })), + useCurrentProviderAndModel: vi.fn(() => ({ + currentProvider: undefined, + currentModel: undefined, + })), + useDefaultModel: vi.fn(() => ({ + defaultModel: undefined, + })), +})) + +// Test Wrapper with QueryClientProvider + +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + mutations: { + retry: false, + }, + }, +}) + +const TestWrapper = ({ children }: { children: ReactNode }) => { + const queryClient = createTestQueryClient() + return ( + <QueryClientProvider client={queryClient}> + {children} + </QueryClientProvider> + ) +} + +const renderWithProviders = (ui: React.ReactElement) => { + return render(ui, { wrapper: TestWrapper }) +} + +// Test Factories + +const createMockSegment = (overrides = {}) => ({ + id: 'segment-1', + document: { + id: 'doc-1', + data_source_type: 'upload_file', + name: 'test-document.pdf', + doc_type: 'book' as const, + }, + content: 'Test segment content', + sign_content: 'Test signed content', + position: 1, + word_count: 100, + tokens: 50, + keywords: ['test', 'keyword'], + hit_count: 5, + index_node_hash: 'hash-123', + answer: '', + ...overrides, +}) + +const createMockHitTesting = (overrides = {}): HitTesting => ({ + segment: createMockSegment() as HitTesting['segment'], + content: createMockSegment() as HitTesting['content'], + score: 0.85, + tsne_position: { x: 0.5, y: 0.5 }, + child_chunks: null, + files: [], + ...overrides, +}) + +const createMockRecord = (overrides = {}): HitTestingRecord => ({ + id: 'record-1', + source: 'hit_testing', + source_app_id: 'app-1', + created_by_role: 'account', + created_by: 'user-1', + created_at: 1609459200, + queries: [ + { content: 'Test query', content_type: 'text_query', file_info: null }, + ], + ...overrides, +}) + +const _createMockRetrievalConfig = (overrides = {}): RetrievalConfig => ({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_mode: undefined, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + weights: undefined, + top_k: 10, + score_threshold_enabled: false, + score_threshold: 0.5, + ...overrides, +} as RetrievalConfig) + +// HitTestingPage Component Tests +// NOTE: Child component unit tests (Score, Mask, EmptyRecords, ResultItemMeta, +// ResultItemFooter, ChildChunksItem, ResultItem, ResultItemExternal, Textarea, +// Records, QueryInput, ModifyExternalRetrievalModal, ModifyRetrievalModal, +// ChunkDetailModal, extensionToFileType) have been moved to their own dedicated +// spec files under the ./components/ and ./utils/ directories. +// This file now focuses exclusively on HitTestingPage integration tests. + +describe('HitTestingPage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render page title', () => { + renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + // Look for heading element + const heading = screen.getByRole('heading', { level: 1 }) + expect(heading).toBeInTheDocument() + }) + + it('should render records section', () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + // The records section should be present + expect(container.querySelector('.flex-col')).toBeInTheDocument() + }) + + it('should render query input', () => { + renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + }) + + describe('Loading States', () => { + it('should show loading when records are loading', async () => { + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: undefined, + refetch: mockRecordsRefetch, + isLoading: true, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + // Loading component should be visible - look for the loading animation + const loadingElement = container.querySelector('[class*="animate"]') || container.querySelector('.flex-1') + expect(loadingElement).toBeInTheDocument() + }) + }) + + describe('Empty States', () => { + it('should show empty records when no data', () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + // EmptyRecords component should be rendered - check that the component is mounted + // The EmptyRecords has a specific structure with bg-workflow-process-bg class + const mainContainer = container.querySelector('.flex.h-full') + expect(mainContainer).toBeInTheDocument() + }) + }) + + describe('Records Display', () => { + it('should display records when data is present', async () => { + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { + data: [createMockRecord()], + total: 1, + page: 1, + limit: 10, + has_more: false, + }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + expect(screen.getByText('Test query')).toBeInTheDocument() + }) + }) + + describe('Pagination', () => { + it('should show pagination when total exceeds limit', async () => { + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { + data: Array.from({ length: 10 }, (_, i) => createMockRecord({ id: `record-${i}` })), + total: 25, + page: 1, + limit: 10, + has_more: true, + }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + // Pagination should be visible - look for pagination controls + const paginationElement = container.querySelector('[class*="pagination"]') || container.querySelector('nav') + expect(paginationElement || screen.getAllByText('Test query').length > 0).toBeTruthy() + }) + }) + + describe('Right Panel', () => { + it('should render right panel container', () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + // The right panel should be present (on non-mobile) + const rightPanel = container.querySelector('.rounded-tl-2xl') + expect(rightPanel).toBeInTheDocument() + }) + }) + + describe('Retrieval Modal', () => { + it('should open retrieval modal when method is clicked', async () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Find the method selector (cursor-pointer div with the retrieval method) + const methodSelectors = container.querySelectorAll('.cursor-pointer') + const methodSelector = Array.from(methodSelectors).find(el => !el.closest('button') && !el.closest('tr')) + + // Verify we found a method selector to click + expect(methodSelector).toBeTruthy() + + if (methodSelector) + fireEvent.click(methodSelector) + + // The component should still be functional after the click + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('Hit Results Display', () => { + it('should display hit results when hitResult has records', async () => { + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { + data: [], + total: 0, + page: 1, + limit: 10, + has_more: false, + }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // The right panel should show empty state initially + expect(container.querySelector('.rounded-tl-2xl')).toBeInTheDocument() + }) + + it('should render loading skeleton when retrieval is in progress', async () => { + const { useHitTesting } = await import('@/service/knowledge/use-hit-testing') + vi.mocked(useHitTesting).mockReturnValue({ + mutateAsync: mockHitTestingMutateAsync, + isPending: true, + } as unknown as ReturnType<typeof useHitTesting>) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Component should render without crashing + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render results when hit testing returns data', async () => { + // This test simulates the flow of getting hit results + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { + data: [], + total: 0, + page: 1, + limit: 10, + has_more: false, + }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // The component should render the result display area + expect(container.querySelector('.bg-background-body')).toBeInTheDocument() + }) + }) + + describe('Record Interaction', () => { + it('should update queries when a record is clicked', async () => { + const mockRecord = createMockRecord({ + queries: [ + { content: 'Record query text', content_type: 'text_query', file_info: null }, + ], + }) + + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { + data: [mockRecord], + total: 1, + page: 1, + limit: 10, + has_more: false, + }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Find and click the record row + const recordText = screen.getByText('Record query text') + const row = recordText.closest('tr') + if (row) + fireEvent.click(row) + + // The query input should be updated - this causes re-render with new key + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + }) + + describe('External Dataset', () => { + it('should render external dataset UI when provider is external', async () => { + // Mock dataset with external provider + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { + data: [], + total: 0, + page: 1, + limit: 10, + has_more: false, + }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Component should render + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('Mobile View', () => { + it('should handle mobile breakpoint', async () => { + // Mock mobile breakpoint + const useBreakpoints = await import('@/hooks/use-breakpoints') + vi.mocked(useBreakpoints.default).mockReturnValue('mobile' as unknown as ReturnType<typeof useBreakpoints.default>) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Component should still render + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('useEffect for mobile panel', () => { + it('should update right panel visibility based on mobile state', async () => { + const useBreakpoints = await import('@/hooks/use-breakpoints') + + // First render with desktop + vi.mocked(useBreakpoints.default).mockReturnValue('pc' as unknown as ReturnType<typeof useBreakpoints.default>) + + const { rerender, container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + expect(container.firstChild).toBeInTheDocument() + + // Re-render with mobile + vi.mocked(useBreakpoints.default).mockReturnValue('mobile' as unknown as ReturnType<typeof useBreakpoints.default>) + + rerender( + <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}> + <HitTestingPage datasetId="dataset-1" /> + </QueryClientProvider>, + ) + + expect(container.firstChild).toBeInTheDocument() + }) + }) +}) + +describe('Integration: Hit Testing Flow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHitTestingMutateAsync.mockReset() + mockExternalHitTestingMutateAsync.mockReset() + }) + + it('should complete a full hit testing flow', async () => { + const mockResponse: HitTestingResponse = { + query: { content: 'Test query', tsne_position: { x: 0, y: 0 } }, + records: [createMockHitTesting()], + } + + mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { + options?.onSuccess?.(mockResponse) + return mockResponse + }) + + renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Wait for textbox with timeout for CI + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Type query + fireEvent.change(textarea, { target: { value: 'Test query' } }) + + // Find submit button by class + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + expect(submitButton).not.toBeDisabled() + }) + + it('should handle API error gracefully', async () => { + mockHitTestingMutateAsync.mockRejectedValue(new Error('API Error')) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Wait for textbox with timeout for CI + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Type query + fireEvent.change(textarea, { target: { value: 'Test query' } }) + + // Component should still be functional - check for the main container + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render hit results after successful submission', async () => { + const mockHitTestingRecord = createMockHitTesting() + const mockResponse: HitTestingResponse = { + query: { content: 'Test query', tsne_position: { x: 0, y: 0 } }, + records: [mockHitTestingRecord], + } + + mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { + // Call onSuccess synchronously to ensure state is updated + if (options?.onSuccess) + options.onSuccess(mockResponse) + return mockResponse + }) + + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { + data: [], + total: 0, + page: 1, + limit: 10, + has_more: false, + }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + const { container: _container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Wait for textbox to be rendered with timeout for CI environment + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Type query + fireEvent.change(textarea, { target: { value: 'Test query' } }) + + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + if (submitButton) + fireEvent.click(submitButton) + + // Wait for the mutation to complete + await waitFor( + () => { + expect(mockHitTestingMutateAsync).toHaveBeenCalled() + }, + { timeout: 3000 }, + ) + }) + + it('should render ResultItem components for non-external results', async () => { + const mockResponse: HitTestingResponse = { + query: { content: 'Test query', tsne_position: { x: 0, y: 0 } }, + records: [ + createMockHitTesting({ score: 0.95 }), + createMockHitTesting({ score: 0.85 }), + ], + } + + mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { + if (options?.onSuccess) + options.onSuccess(mockResponse) + return mockResponse + }) + + const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetTestingRecords).mockReturnValue({ + data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, + refetch: mockRecordsRefetch, + isLoading: false, + } as unknown as ReturnType<typeof useDatasetTestingRecords>) + + const { container: _container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Wait for component to be fully rendered with longer timeout + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Submit a query + fireEvent.change(textarea, { target: { value: 'Test query' } }) + + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + if (submitButton) + fireEvent.click(submitButton) + + // Wait for mutation to complete with longer timeout + await waitFor( + () => { + expect(mockHitTestingMutateAsync).toHaveBeenCalled() + }, + { timeout: 3000 }, + ) + }) + + it('should render external results when dataset is external', async () => { + const mockExternalResponse = { + query: { content: 'test' }, + records: [ + { + title: 'External Result 1', + content: 'External content', + score: 0.9, + metadata: {}, + }, + ], + } + + mockExternalHitTestingMutateAsync.mockImplementation(async (_params, options) => { + if (options?.onSuccess) + options.onSuccess(mockExternalResponse) + return mockExternalResponse + }) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Component should render + expect(container.firstChild).toBeInTheDocument() + + // Wait for textbox with timeout for CI + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Type in textarea to verify component is functional + fireEvent.change(textarea, { target: { value: 'Test query' } }) + + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + if (submitButton) + fireEvent.click(submitButton) + + // Verify component is still functional after submission + await waitFor( + () => { + expect(screen.getByRole('textbox')).toBeInTheDocument() + }, + { timeout: 3000 }, + ) + }) +}) + +// Drawer and Modal Interaction Tests + +describe('Drawer and Modal Interactions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should save retrieval config when ModifyRetrievalModal onSave is called', async () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Find and click the retrieval method selector to open the drawer + const methodSelectors = container.querySelectorAll('.cursor-pointer') + const methodSelector = Array.from(methodSelectors).find( + el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), + ) + + if (methodSelector) { + fireEvent.click(methodSelector) + + await waitFor(() => { + // The drawer should open - verify container is still there + expect(container.firstChild).toBeInTheDocument() + }) + } + + // Component should still be functional - verify main container + expect(container.querySelector('.overflow-y-auto')).toBeInTheDocument() + }) + + it('should close retrieval modal when onHide is called', async () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Open the modal first + const methodSelectors = container.querySelectorAll('.cursor-pointer') + const methodSelector = Array.from(methodSelectors).find( + el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), + ) + + if (methodSelector) { + fireEvent.click(methodSelector) + } + + // Component should still be functional + expect(container.firstChild).toBeInTheDocument() + }) +}) + +// renderHitResults Coverage Tests + +describe('renderHitResults Coverage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHitTestingMutateAsync.mockReset() + }) + + it('should render hit results panel with records count', async () => { + const mockRecords = [ + createMockHitTesting({ score: 0.95 }), + createMockHitTesting({ score: 0.85 }), + ] + const mockResponse: HitTestingResponse = { + query: { content: 'test', tsne_position: { x: 0, y: 0 } }, + records: mockRecords, + } + + // Make mutation call onSuccess synchronously + mockHitTestingMutateAsync.mockImplementation(async (params, options) => { + // Simulate async behavior + await Promise.resolve() + if (options?.onSuccess) + options.onSuccess(mockResponse) + return mockResponse + }) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Wait for textbox with timeout for CI + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Enter query + fireEvent.change(textarea, { target: { value: 'test query' } }) + + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + + if (submitButton) + fireEvent.click(submitButton) + + // Verify component is functional + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) + + it('should iterate through records and render ResultItem for each', async () => { + const mockRecords = [ + createMockHitTesting({ score: 0.9 }), + ] + + mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { + const response = { query: { content: 'test' }, records: mockRecords } + if (options?.onSuccess) + options.onSuccess(response) + return response + }) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + const textarea = screen.getByRole('textbox') + fireEvent.change(textarea, { target: { value: 'test' } }) + + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + if (submitButton) + fireEvent.click(submitButton) + + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + }) +}) + +// Drawer onSave Coverage Tests + +describe('ModifyRetrievalModal onSave Coverage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should update retrieval config when onSave is triggered', async () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Open the drawer + const methodSelectors = container.querySelectorAll('.cursor-pointer') + const methodSelector = Array.from(methodSelectors).find( + el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), + ) + + if (methodSelector) { + fireEvent.click(methodSelector) + + // Wait for drawer to open + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + } + + // Verify component renders correctly + expect(container.querySelector('.overflow-y-auto')).toBeInTheDocument() + }) + + it('should close modal after saving', async () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Open the drawer + const methodSelectors = container.querySelectorAll('.cursor-pointer') + const methodSelector = Array.from(methodSelectors).find( + el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), + ) + + if (methodSelector) + fireEvent.click(methodSelector) + + // Component should still be rendered + expect(container.firstChild).toBeInTheDocument() + }) +}) + +// Direct Component Coverage Tests + +describe('HitTestingPage Internal Functions Coverage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockHitTestingMutateAsync.mockReset() + mockExternalHitTestingMutateAsync.mockReset() + }) + + it('should trigger renderHitResults when mutation succeeds with records', async () => { + // Create mock hit testing records + const mockHitRecords = [ + createMockHitTesting({ score: 0.95 }), + createMockHitTesting({ score: 0.85 }), + ] + + const mockResponse: HitTestingResponse = { + query: { content: 'test query', tsne_position: { x: 0, y: 0 } }, + records: mockHitRecords, + } + + // Setup mutation to call onSuccess synchronously + mockHitTestingMutateAsync.mockImplementation((_params, options) => { + // Synchronously call onSuccess + if (options?.onSuccess) + options.onSuccess(mockResponse) + return Promise.resolve(mockResponse) + }) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Wait for textbox with timeout for CI + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Enter query and submit + fireEvent.change(textarea, { target: { value: 'test query' } }) + + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + + if (submitButton) { + fireEvent.click(submitButton) + } + + // Wait for state updates + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }, { timeout: 3000 }) + + // Verify mutation was called + expect(mockHitTestingMutateAsync).toHaveBeenCalled() + }) + + it('should handle retrieval config update via ModifyRetrievalModal', async () => { + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Find and click retrieval method to open drawer + const methodSelectors = container.querySelectorAll('.cursor-pointer') + const methodSelector = Array.from(methodSelectors).find( + el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), + ) + + if (methodSelector) { + fireEvent.click(methodSelector) + + // Wait for drawer content + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }) + + // Try to find save button in the drawer + const saveButtons = screen.queryAllByText(/save/i) + if (saveButtons.length > 0) { + fireEvent.click(saveButtons[0]) + } + } + + // Component should still work + expect(container.firstChild).toBeInTheDocument() + }) + + it('should show hit count in results panel after successful query', async () => { + const mockRecords = [createMockHitTesting()] + const mockResponse: HitTestingResponse = { + query: { content: 'test', tsne_position: { x: 0, y: 0 } }, + records: mockRecords, + } + + mockHitTestingMutateAsync.mockResolvedValue(mockResponse) + + const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) + + // Wait for textbox with timeout for CI + const textarea = await waitFor( + () => screen.getByRole('textbox'), + { timeout: 3000 }, + ) + + // Submit a query + fireEvent.change(textarea, { target: { value: 'test' } }) + + const buttons = screen.getAllByRole('button') + const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) + + if (submitButton) + fireEvent.click(submitButton) + + // Verify the component renders + await waitFor(() => { + expect(container.firstChild).toBeInTheDocument() + }, { timeout: 3000 }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/__tests__/modify-external-retrieval-modal.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/modify-external-retrieval-modal.spec.tsx new file mode 100644 index 0000000000..6fe1f14983 --- /dev/null +++ b/web/app/components/datasets/hit-testing/__tests__/modify-external-retrieval-modal.spec.tsx @@ -0,0 +1,126 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ModifyExternalRetrievalModal from '../modify-external-retrieval-modal' + +vi.mock('@/app/components/base/action-button', () => ({ + default: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( + <button data-testid="action-button" onClick={onClick}>{children}</button> + ), +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick, variant }: { children: React.ReactNode, onClick: () => void, variant?: string }) => ( + <button data-testid={variant === 'primary' ? 'save-button' : 'cancel-button'} onClick={onClick}> + {children} + </button> + ), +})) + +vi.mock('../../external-knowledge-base/create/RetrievalSettings', () => ({ + default: ({ topK, scoreThreshold, _scoreThresholdEnabled, onChange }: { topK: number, scoreThreshold: number, _scoreThresholdEnabled: boolean, onChange: (data: Record<string, unknown>) => void }) => ( + <div data-testid="retrieval-settings"> + <span data-testid="top-k">{topK}</span> + <span data-testid="score-threshold">{scoreThreshold}</span> + <button data-testid="change-top-k" onClick={() => onChange({ top_k: 10 })}>change top k</button> + <button data-testid="change-score" onClick={() => onChange({ score_threshold: 0.9 })}>change score</button> + <button data-testid="change-enabled" onClick={() => onChange({ score_threshold_enabled: true })}>change enabled</button> + </div> + ), +})) + +describe('ModifyExternalRetrievalModal', () => { + const defaultProps = { + onClose: vi.fn(), + onSave: vi.fn(), + initialTopK: 4, + initialScoreThreshold: 0.5, + initialScoreThresholdEnabled: false, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render title', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + expect(screen.getByText('datasetHitTesting.settingTitle')).toBeInTheDocument() + }) + + it('should render retrieval settings with initial values', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + expect(screen.getByTestId('top-k')).toHaveTextContent('4') + expect(screen.getByTestId('score-threshold')).toHaveTextContent('0.5') + }) + + it('should call onClose when close button clicked', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('action-button')) + expect(defaultProps.onClose).toHaveBeenCalled() + }) + + it('should call onClose when cancel button clicked', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('cancel-button')) + expect(defaultProps.onClose).toHaveBeenCalled() + }) + + it('should call onSave with current values and close when save clicked', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('save-button')) + expect(defaultProps.onSave).toHaveBeenCalledWith({ + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }) + expect(defaultProps.onClose).toHaveBeenCalled() + }) + + it('should save updated values after settings change', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('change-top-k')) + fireEvent.click(screen.getByTestId('save-button')) + expect(defaultProps.onSave).toHaveBeenCalledWith( + expect.objectContaining({ top_k: 10 }), + ) + }) + + it('should save updated score threshold', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('change-score')) + fireEvent.click(screen.getByTestId('save-button')) + expect(defaultProps.onSave).toHaveBeenCalledWith( + expect.objectContaining({ score_threshold: 0.9 }), + ) + }) + + it('should save updated score threshold enabled', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('change-enabled')) + fireEvent.click(screen.getByTestId('save-button')) + expect(defaultProps.onSave).toHaveBeenCalledWith( + expect.objectContaining({ score_threshold_enabled: true }), + ) + }) + + it('should save multiple updated values at once', () => { + render(<ModifyExternalRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('change-top-k')) + fireEvent.click(screen.getByTestId('change-score')) + fireEvent.click(screen.getByTestId('save-button')) + expect(defaultProps.onSave).toHaveBeenCalledWith( + expect.objectContaining({ top_k: 10, score_threshold: 0.9 }), + ) + }) + + it('should render with different initial values', () => { + const props = { + ...defaultProps, + initialTopK: 10, + initialScoreThreshold: 0.8, + initialScoreThresholdEnabled: true, + } + render(<ModifyExternalRetrievalModal {...props} />) + expect(screen.getByTestId('top-k')).toHaveTextContent('10') + expect(screen.getByTestId('score-threshold')).toHaveTextContent('0.8') + }) +}) diff --git a/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx new file mode 100644 index 0000000000..dafa81971f --- /dev/null +++ b/web/app/components/datasets/hit-testing/__tests__/modify-retrieval-modal.spec.tsx @@ -0,0 +1,108 @@ +import type { RetrievalConfig } from '@/types/app' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RETRIEVE_METHOD } from '@/types/app' +import ModifyRetrievalModal from '../modify-retrieval-modal' + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick, variant }: { children: React.ReactNode, onClick: () => void, variant?: string }) => ( + <button data-testid={variant === 'primary' ? 'save-button' : 'cancel-button'} onClick={onClick}> + {children} + </button> + ), +})) + +vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({ + isReRankModelSelected: vi.fn(() => true), +})) + +vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({ + default: ({ value, onChange }: { value: RetrievalConfig, onChange: (v: RetrievalConfig) => void }) => ( + <div data-testid="retrieval-method-config"> + <span>{value.search_method}</span> + <button data-testid="change-config" onClick={() => onChange({ ...value, search_method: RETRIEVE_METHOD.hybrid })}>change</button> + </div> + ), +})) + +vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', () => ({ + default: () => <div data-testid="economical-config" />, +})) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: () => ({ data: [] }), +})) + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: () => 'model-name', +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, +})) + +vi.mock('../../../base/toast', () => ({ + default: { notify: vi.fn() }, +})) + +vi.mock('../../settings/utils', () => ({ + checkShowMultiModalTip: () => false, +})) + +describe('ModifyRetrievalModal', () => { + const defaultProps = { + indexMethod: 'high_quality', + value: { + search_method: 'semantic_search', + reranking_enable: false, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + } as RetrievalConfig, + isShow: true, + onHide: vi.fn(), + onSave: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return null when isShow is false', () => { + const { container } = render(<ModifyRetrievalModal {...defaultProps} isShow={false} />) + expect(container.firstChild).toBeNull() + }) + + it('should render title when isShow is true', () => { + render(<ModifyRetrievalModal {...defaultProps} />) + expect(screen.getByText('datasetSettings.form.retrievalSetting.title')).toBeInTheDocument() + }) + + it('should render high quality retrieval config for high_quality index', () => { + render(<ModifyRetrievalModal {...defaultProps} />) + expect(screen.getByTestId('retrieval-method-config')).toBeInTheDocument() + }) + + it('should render economical config for non high_quality index', () => { + render(<ModifyRetrievalModal {...defaultProps} indexMethod="economy" />) + expect(screen.getByTestId('economical-config')).toBeInTheDocument() + }) + + it('should call onHide when cancel button clicked', () => { + render(<ModifyRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('cancel-button')) + expect(defaultProps.onHide).toHaveBeenCalled() + }) + + it('should call onSave with retrieval config when save clicked', () => { + render(<ModifyRetrievalModal {...defaultProps} />) + fireEvent.click(screen.getByTestId('save-button')) + expect(defaultProps.onSave).toHaveBeenCalled() + }) + + it('should render learn more link', () => { + render(<ModifyRetrievalModal {...defaultProps} />) + expect(screen.getByText('datasetSettings.form.retrievalSetting.learnMore')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/child-chunks-item.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/child-chunks-item.spec.tsx new file mode 100644 index 0000000000..9428f0ad45 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/child-chunks-item.spec.tsx @@ -0,0 +1,97 @@ +import type { HitTestingChildChunk } from '@/models/datasets' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ChildChunksItem from '../child-chunks-item' + +const createChildChunkPayload = ( + overrides: Partial<HitTestingChildChunk> = {}, +): HitTestingChildChunk => ({ + id: 'chunk-1', + content: 'Child chunk content here', + position: 1, + score: 0.75, + ...overrides, +}) + +describe('ChildChunksItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for child chunk items + describe('Rendering', () => { + it('should render the position label', () => { + const payload = createChildChunkPayload({ position: 3 }) + + render(<ChildChunksItem payload={payload} isShowAll={false} />) + + expect(screen.getByText(/C-/)).toBeInTheDocument() + expect(screen.getByText(/3/)).toBeInTheDocument() + }) + + it('should render the score component', () => { + const payload = createChildChunkPayload({ score: 0.88 }) + + render(<ChildChunksItem payload={payload} isShowAll={false} />) + + expect(screen.getByText('0.88')).toBeInTheDocument() + }) + + it('should render the content text', () => { + const payload = createChildChunkPayload({ content: 'Sample chunk text' }) + + render(<ChildChunksItem payload={payload} isShowAll={false} />) + + expect(screen.getByText('Sample chunk text')).toBeInTheDocument() + }) + + it('should render with besideChunkName styling on Score', () => { + const payload = createChildChunkPayload({ score: 0.6 }) + + const { container } = render( + <ChildChunksItem payload={payload} isShowAll={false} />, + ) + + // Assert - Score with besideChunkName has h-[20.5px] and border-l-0 + const scoreEl = container.querySelector('[class*="h-\\[20\\.5px\\]"]') + expect(scoreEl).toBeInTheDocument() + }) + }) + + // Line clamping behavior tests + describe('Line Clamping', () => { + it('should apply line-clamp-2 when isShowAll is false', () => { + const payload = createChildChunkPayload() + + const { container } = render( + <ChildChunksItem payload={payload} isShowAll={false} />, + ) + + const root = container.firstElementChild + expect(root?.className).toContain('line-clamp-2') + }) + + it('should not apply line-clamp-2 when isShowAll is true', () => { + const payload = createChildChunkPayload() + + const { container } = render( + <ChildChunksItem payload={payload} isShowAll={true} />, + ) + + const root = container.firstElementChild + expect(root?.className).not.toContain('line-clamp-2') + }) + }) + + describe('Edge Cases', () => { + it('should render with score 0 (Score returns null)', () => { + const payload = createChildChunkPayload({ score: 0 }) + + render(<ChildChunksItem payload={payload} isShowAll={false} />) + + // Assert - content still renders, score returns null + expect(screen.getByText('Child chunk content here')).toBeInTheDocument() + expect(screen.queryByText('score')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx new file mode 100644 index 0000000000..109d2f9cfe --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/chunk-detail-modal.spec.tsx @@ -0,0 +1,137 @@ +import type { HitTesting } from '@/models/datasets' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ChunkDetailModal from '../chunk-detail-modal' + +vi.mock('@/app/components/base/file-uploader/file-type-icon', () => ({ + default: () => <span data-testid="file-icon" />, +})) + +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>, +})) + +vi.mock('@/app/components/base/modal', () => ({ + default: ({ children, title, onClose }: { children: React.ReactNode, title: string, onClose: () => void }) => ( + <div data-testid="modal"> + <div data-testid="modal-title">{title}</div> + <button data-testid="modal-close" onClick={onClose}>close</button> + {children} + </div> + ), +})) + +vi.mock('../../../common/image-list', () => ({ + default: () => <div data-testid="image-list" />, +})) + +vi.mock('../../../documents/detail/completed/common/dot', () => ({ + default: () => <span data-testid="dot" />, +})) + +vi.mock('../../../documents/detail/completed/common/segment-index-tag', () => ({ + SegmentIndexTag: ({ positionId }: { positionId: number }) => <span data-testid="segment-index-tag">{positionId}</span>, +})) + +vi.mock('../../../documents/detail/completed/common/summary-text', () => ({ + default: ({ value }: { value: string }) => <div data-testid="summary-text">{value}</div>, +})) + +vi.mock('@/app/components/datasets/documents/detail/completed/common/tag', () => ({ + default: ({ text }: { text: string }) => <span data-testid="tag">{text}</span>, +})) + +vi.mock('../child-chunks-item', () => ({ + default: ({ payload }: { payload: { id: string } }) => <div data-testid="child-chunk">{payload.id}</div>, +})) + +vi.mock('../mask', () => ({ + default: () => <div data-testid="mask" />, +})) + +vi.mock('../score', () => ({ + default: ({ value }: { value: number }) => <span data-testid="score">{value}</span>, +})) + +const makePayload = (overrides: Record<string, unknown> = {}): HitTesting => { + const segmentOverrides = (overrides.segment ?? {}) as Record<string, unknown> + const segment = { + position: 1, + content: 'chunk content', + sign_content: '', + keywords: [], + document: { name: 'file.pdf' }, + answer: '', + word_count: 100, + ...segmentOverrides, + } + return { + segment, + content: segment, + score: 0.85, + tsne_position: { x: 0, y: 0 }, + child_chunks: (overrides.child_chunks ?? []) as HitTesting['child_chunks'], + files: (overrides.files ?? []) as HitTesting['files'], + summary: (overrides.summary ?? '') as string, + } as unknown as HitTesting +} + +describe('ChunkDetailModal', () => { + const onHide = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render modal with title', () => { + render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />) + expect(screen.getByTestId('modal-title')).toHaveTextContent('chunkDetail') + }) + + it('should render segment index tag and score', () => { + render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />) + expect(screen.getByTestId('segment-index-tag')).toHaveTextContent('1') + expect(screen.getByTestId('score')).toHaveTextContent('0.85') + }) + + it('should render markdown content', () => { + render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />) + expect(screen.getByTestId('markdown')).toHaveTextContent('chunk content') + }) + + it('should render QA content when answer exists', () => { + const payload = makePayload({ + segment: { answer: 'answer text', content: 'question text' }, + }) + render(<ChunkDetailModal payload={payload} onHide={onHide} />) + expect(screen.getByText('question text')).toBeInTheDocument() + expect(screen.getByText('answer text')).toBeInTheDocument() + }) + + it('should render keywords when present and not parent-child', () => { + const payload = makePayload({ + segment: { keywords: ['k1', 'k2'] }, + }) + render(<ChunkDetailModal payload={payload} onHide={onHide} />) + expect(screen.getAllByTestId('tag')).toHaveLength(2) + }) + + it('should render child chunks section for parent-child retrieval', () => { + const payload = makePayload({ + child_chunks: [{ id: 'c1' }, { id: 'c2' }], + }) + render(<ChunkDetailModal payload={payload} onHide={onHide} />) + expect(screen.getAllByTestId('child-chunk')).toHaveLength(2) + }) + + it('should render summary text when summary exists', () => { + const payload = makePayload({ summary: 'test summary' }) + render(<ChunkDetailModal payload={payload} onHide={onHide} />) + expect(screen.getByTestId('summary-text')).toHaveTextContent('test summary') + }) + + it('should render mask overlay', () => { + render(<ChunkDetailModal payload={makePayload()} onHide={onHide} />) + expect(screen.getByTestId('mask')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/empty-records.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/empty-records.spec.tsx new file mode 100644 index 0000000000..7bcb88a845 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/empty-records.spec.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import EmptyRecords from '../empty-records' + +describe('EmptyRecords', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for the empty state component + describe('Rendering', () => { + it('should render the "no recent" tip text', () => { + render(<EmptyRecords />) + + expect(screen.getByText(/noRecentTip/i)).toBeInTheDocument() + }) + + it('should render the history icon', () => { + const { container } = render(<EmptyRecords />) + + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('should render inside a styled container', () => { + const { container } = render(<EmptyRecords />) + + const wrapper = container.firstElementChild + expect(wrapper?.className).toContain('rounded-2xl') + expect(wrapper?.className).toContain('bg-workflow-process-bg') + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/mask.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/mask.spec.tsx new file mode 100644 index 0000000000..8c4e2b3251 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/mask.spec.tsx @@ -0,0 +1,33 @@ +import { render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Mask from '../mask' + +describe('Mask', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for the gradient overlay component + describe('Rendering', () => { + it('should render a gradient overlay div', () => { + const { container } = render(<Mask />) + + const div = container.firstElementChild + expect(div).toBeInTheDocument() + expect(div?.className).toContain('h-12') + expect(div?.className).toContain('bg-gradient-to-b') + }) + + it('should apply custom className', () => { + const { container } = render(<Mask className="custom-mask" />) + + expect(container.firstElementChild?.className).toContain('custom-mask') + }) + + it('should render without custom className', () => { + const { container } = render(<Mask />) + + expect(container.firstElementChild).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/records.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/records.spec.tsx new file mode 100644 index 0000000000..649dcc4d25 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/records.spec.tsx @@ -0,0 +1,95 @@ +import type { HitTestingRecord } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Records from '../records' + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: (ts: number, _fmt: string) => `time-${ts}`, + }), +})) + +vi.mock('../../../common/image-list', () => ({ + default: () => <div data-testid="image-list" />, +})) + +const makeRecord = (id: string, source: string, created_at: number, content = 'query text') => ({ + id, + source, + created_at, + queries: [{ content, content_type: 'text_query', file_info: null }], +}) as unknown as HitTestingRecord + +describe('Records', () => { + const mockOnClick = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render table headers', () => { + render(<Records records={[]} onClickRecord={mockOnClick} />) + expect(screen.getByText('datasetHitTesting.table.header.queryContent')).toBeInTheDocument() + expect(screen.getByText('datasetHitTesting.table.header.source')).toBeInTheDocument() + expect(screen.getByText('datasetHitTesting.table.header.time')).toBeInTheDocument() + }) + + it('should render records', () => { + const records = [ + makeRecord('1', 'app', 1000), + makeRecord('2', 'hit_testing', 2000), + ] + render(<Records records={records} onClickRecord={mockOnClick} />) + expect(screen.getAllByText('query text')).toHaveLength(2) + }) + + it('should call onClickRecord when row clicked', () => { + const records = [makeRecord('1', 'app', 1000)] + render(<Records records={records} onClickRecord={mockOnClick} />) + fireEvent.click(screen.getByText('query text')) + expect(mockOnClick).toHaveBeenCalledWith(records[0]) + }) + + it('should sort records by time descending by default', () => { + const records = [ + makeRecord('1', 'app', 1000, 'early'), + makeRecord('2', 'app', 3000, 'late'), + makeRecord('3', 'app', 2000, 'mid'), + ] + render(<Records records={records} onClickRecord={mockOnClick} />) + const rows = screen.getAllByRole('row').slice(1) // skip header + expect(rows[0]).toHaveTextContent('late') + expect(rows[1]).toHaveTextContent('mid') + expect(rows[2]).toHaveTextContent('early') + }) + + it('should toggle sort order on time header click', () => { + const records = [ + makeRecord('1', 'app', 1000, 'early'), + makeRecord('2', 'app', 3000, 'late'), + ] + render(<Records records={records} onClickRecord={mockOnClick} />) + + // Default: desc, so late first + let rows = screen.getAllByRole('row').slice(1) + expect(rows[0]).toHaveTextContent('late') + + fireEvent.click(screen.getByText('datasetHitTesting.table.header.time')) + rows = screen.getAllByRole('row').slice(1) + expect(rows[0]).toHaveTextContent('early') + }) + + it('should render image list for image queries', () => { + const records = [{ + id: '1', + source: 'app', + created_at: 1000, + queries: [ + { content: '', content_type: 'text_query', file_info: null }, + { content: '', content_type: 'image_query', file_info: { name: 'img.png', mime_type: 'image/png', source_url: 'url', size: 100, extension: 'png' } }, + ], + }] as unknown as HitTestingRecord[] + render(<Records records={records} onClickRecord={mockOnClick} />) + expect(screen.getByTestId('image-list')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/result-item-external.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/result-item-external.spec.tsx new file mode 100644 index 0000000000..b1a4aa5f57 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/result-item-external.spec.tsx @@ -0,0 +1,173 @@ +import type { ExternalKnowledgeBaseHitTesting } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ResultItemExternal from '../result-item-external' + +let mockIsShowDetailModal = false +const mockShowDetailModal = vi.fn(() => { + mockIsShowDetailModal = true +}) +const mockHideDetailModal = vi.fn(() => { + mockIsShowDetailModal = false +}) + +// Mock useBoolean: required because tests control modal state externally +// (setting mockIsShowDetailModal before render) and verify mock fn calls. +vi.mock('ahooks', () => ({ + useBoolean: (_initial: boolean) => { + return [ + mockIsShowDetailModal, + { + setTrue: mockShowDetailModal, + setFalse: mockHideDetailModal, + toggle: vi.fn(), + set: vi.fn(), + }, + ] + }, +})) + +const createExternalPayload = ( + overrides: Partial<ExternalKnowledgeBaseHitTesting> = {}, +): ExternalKnowledgeBaseHitTesting => ({ + content: 'This is the chunk content for testing.', + title: 'Test Document Title', + score: 0.85, + metadata: { + 'x-amz-bedrock-kb-source-uri': 's3://bucket/key', + 'x-amz-bedrock-kb-data-source-id': 'ds-123', + }, + ...overrides, +}) + +describe('ResultItemExternal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsShowDetailModal = false + }) + + // Rendering tests for the external result item card + describe('Rendering', () => { + it('should render the content text', () => { + const payload = createExternalPayload({ content: 'External result content' }) + + render(<ResultItemExternal payload={payload} positionId={1} />) + + expect(screen.getByText('External result content')).toBeInTheDocument() + }) + + it('should render the meta info with position and score', () => { + const payload = createExternalPayload({ score: 0.92 }) + + render(<ResultItemExternal payload={payload} positionId={5} />) + + expect(screen.getByText('Chunk-05')).toBeInTheDocument() + expect(screen.getByText('0.92')).toBeInTheDocument() + }) + + it('should render the footer with document title', () => { + const payload = createExternalPayload({ title: 'Knowledge Base Doc' }) + + render(<ResultItemExternal payload={payload} positionId={1} />) + + expect(screen.getByText('Knowledge Base Doc')).toBeInTheDocument() + }) + + it('should render the word count from content length', () => { + const content = 'Hello World' // 11 chars + const payload = createExternalPayload({ content }) + + render(<ResultItemExternal payload={payload} positionId={1} />) + + expect(screen.getByText(/11/)).toBeInTheDocument() + }) + }) + + // Detail modal tests + describe('Detail Modal', () => { + it('should not render modal by default', () => { + const payload = createExternalPayload() + + render(<ResultItemExternal payload={payload} positionId={1} />) + + expect(screen.queryByText(/chunkDetail/i)).not.toBeInTheDocument() + }) + + it('should call showDetailModal when card is clicked', () => { + const payload = createExternalPayload() + mockIsShowDetailModal = false + + render(<ResultItemExternal payload={payload} positionId={1} />) + + // Act - click the card to open modal + const card = screen.getByText(payload.content).closest('.cursor-pointer') as HTMLElement + fireEvent.click(card) + + // Assert - showDetailModal (setTrue) was invoked + expect(mockShowDetailModal).toHaveBeenCalled() + }) + + it('should render modal content when isShowDetailModal is true', () => { + // Arrange - modal is already open + const payload = createExternalPayload() + mockIsShowDetailModal = true + + render(<ResultItemExternal payload={payload} positionId={1} />) + + // Assert - modal title should appear + expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument() + }) + + it('should render full content in the modal', () => { + const payload = createExternalPayload({ content: 'Full modal content text' }) + mockIsShowDetailModal = true + + render(<ResultItemExternal payload={payload} positionId={1} />) + + // Assert - content appears both in card and modal + const contentElements = screen.getAllByText('Full modal content text') + expect(contentElements.length).toBeGreaterThanOrEqual(2) + }) + + it('should render meta info in the modal', () => { + const payload = createExternalPayload({ score: 0.77 }) + mockIsShowDetailModal = true + + render(<ResultItemExternal payload={payload} positionId={3} />) + + // Assert - meta appears in both card and modal + const chunkTags = screen.getAllByText('Chunk-03') + expect(chunkTags.length).toBe(2) + const scores = screen.getAllByText('0.77') + expect(scores.length).toBe(2) + }) + }) + + describe('Edge Cases', () => { + it('should render with empty content', () => { + const payload = createExternalPayload({ content: '' }) + + render(<ResultItemExternal payload={payload} positionId={1} />) + + // Assert - component still renders + expect(screen.getByText('Test Document Title')).toBeInTheDocument() + }) + + it('should render with score of 0 (Score returns null)', () => { + const payload = createExternalPayload({ score: 0 }) + + render(<ResultItemExternal payload={payload} positionId={1} />) + + // Assert - no score displayed + expect(screen.queryByText('score')).not.toBeInTheDocument() + }) + + it('should handle large positionId values', () => { + const payload = createExternalPayload() + + render(<ResultItemExternal payload={payload} positionId={999} />) + + expect(screen.getByText('Chunk-999')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx new file mode 100644 index 0000000000..44a7dc2c89 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/result-item-footer.spec.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types' +import ResultItemFooter from '../result-item-footer' + +describe('ResultItemFooter', () => { + const mockShowDetailModal = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for the result item footer + describe('Rendering', () => { + it('should render the document title', () => { + render( + <ResultItemFooter + docType={FileAppearanceTypeEnum.document} + docTitle="My Document.pdf" + showDetailModal={mockShowDetailModal} + />, + ) + + expect(screen.getByText('My Document.pdf')).toBeInTheDocument() + }) + + it('should render the "open" button text', () => { + render( + <ResultItemFooter + docType={FileAppearanceTypeEnum.pdf} + docTitle="File.pdf" + showDetailModal={mockShowDetailModal} + />, + ) + + expect(screen.getByText(/open/i)).toBeInTheDocument() + }) + + it('should render the file icon', () => { + const { container } = render( + <ResultItemFooter + docType={FileAppearanceTypeEnum.document} + docTitle="File.txt" + showDetailModal={mockShowDetailModal} + />, + ) + + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + }) + }) + + // User interaction tests + describe('User Interactions', () => { + it('should call showDetailModal when open button is clicked', () => { + render( + <ResultItemFooter + docType={FileAppearanceTypeEnum.document} + docTitle="Doc" + showDetailModal={mockShowDetailModal} + />, + ) + + const openButton = screen.getByText(/open/i) + fireEvent.click(openButton) + + expect(mockShowDetailModal).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/result-item-meta.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/result-item-meta.spec.tsx new file mode 100644 index 0000000000..0cd32ee82c --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/result-item-meta.spec.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ResultItemMeta from '../result-item-meta' + +describe('ResultItemMeta', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for the result item meta component + describe('Rendering', () => { + it('should render the segment index tag with prefix and position', () => { + render( + <ResultItemMeta + labelPrefix="Chunk" + positionId={3} + wordCount={150} + score={0.9} + />, + ) + + expect(screen.getByText('Chunk-03')).toBeInTheDocument() + }) + + it('should render the word count', () => { + render( + <ResultItemMeta + labelPrefix="Chunk" + positionId={1} + wordCount={250} + score={0.8} + />, + ) + + expect(screen.getByText(/250/)).toBeInTheDocument() + expect(screen.getByText(/characters/i)).toBeInTheDocument() + }) + + it('should render the score component', () => { + render( + <ResultItemMeta + labelPrefix="Chunk" + positionId={1} + wordCount={100} + score={0.75} + />, + ) + + expect(screen.getByText('0.75')).toBeInTheDocument() + expect(screen.getByText('score')).toBeInTheDocument() + }) + + it('should apply custom className', () => { + const { container } = render( + <ResultItemMeta + className="custom-meta" + labelPrefix="Chunk" + positionId={1} + wordCount={100} + score={0.5} + />, + ) + + expect(container.firstElementChild?.className).toContain('custom-meta') + }) + + it('should render dot separator', () => { + render( + <ResultItemMeta + labelPrefix="Chunk" + positionId={1} + wordCount={100} + score={0.5} + />, + ) + + expect(screen.getByText('·')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx new file mode 100644 index 0000000000..c8ef054181 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/result-item.spec.tsx @@ -0,0 +1,144 @@ +import type { HitTesting } from '@/models/datasets' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ResultItem from '../result-item' + +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) => <div data-testid="markdown">{content}</div>, +})) + +vi.mock('../../../common/image-list', () => ({ + default: () => <div data-testid="image-list" />, +})) + +vi.mock('../child-chunks-item', () => ({ + default: ({ payload }: { payload: { id: string } }) => <div data-testid="child-chunk">{payload.id}</div>, +})) + +vi.mock('../chunk-detail-modal', () => ({ + default: () => <div data-testid="chunk-detail-modal" />, +})) + +vi.mock('../result-item-footer', () => ({ + default: ({ docTitle }: { docTitle: string }) => <div data-testid="result-item-footer">{docTitle}</div>, +})) + +vi.mock('../result-item-meta', () => ({ + default: ({ positionId }: { positionId: number }) => <div data-testid="result-item-meta">{positionId}</div>, +})) + +vi.mock('@/app/components/datasets/documents/detail/completed/common/summary-label', () => ({ + default: ({ summary }: { summary: string }) => <div data-testid="summary-label">{summary}</div>, +})) + +vi.mock('@/app/components/datasets/documents/detail/completed/common/tag', () => ({ + default: ({ text }: { text: string }) => <span data-testid="tag">{text}</span>, +})) + +vi.mock('@/app/components/datasets/hit-testing/utils/extension-to-file-type', () => ({ + extensionToFileType: () => 'pdf', +})) + +const makePayload = (overrides: Record<string, unknown> = {}): HitTesting => { + const segmentOverrides = (overrides.segment ?? {}) as Record<string, unknown> + const segment = { + position: 1, + word_count: 100, + content: 'test content', + sign_content: '', + keywords: [], + document: { name: 'file.pdf' }, + answer: '', + ...segmentOverrides, + } + return { + segment, + content: segment, + score: 0.95, + tsne_position: { x: 0, y: 0 }, + child_chunks: (overrides.child_chunks ?? []) as HitTesting['child_chunks'], + files: (overrides.files ?? []) as HitTesting['files'], + summary: (overrides.summary ?? '') as string, + } as unknown as HitTesting +} + +describe('ResultItem', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render meta, content, and footer', () => { + render(<ResultItem payload={makePayload()} />) + expect(screen.getByTestId('result-item-meta')).toHaveTextContent('1') + expect(screen.getByTestId('markdown')).toHaveTextContent('test content') + expect(screen.getByTestId('result-item-footer')).toHaveTextContent('file.pdf') + }) + + it('should render keywords when no child_chunks', () => { + const payload = makePayload({ + segment: { keywords: ['key1', 'key2'] }, + }) + render(<ResultItem payload={payload} />) + expect(screen.getAllByTestId('tag')).toHaveLength(2) + }) + + it('should render child chunks when present', () => { + const payload = makePayload({ + child_chunks: [{ id: 'c1' }, { id: 'c2' }], + }) + render(<ResultItem payload={payload} />) + expect(screen.getAllByTestId('child-chunk')).toHaveLength(2) + }) + + it('should render summary label when summary exists', () => { + const payload = makePayload({ summary: 'test summary' }) + render(<ResultItem payload={payload} />) + expect(screen.getByTestId('summary-label')).toHaveTextContent('test summary') + }) + + it('should show chunk detail modal on click', () => { + render(<ResultItem payload={makePayload()} />) + fireEvent.click(screen.getByTestId('markdown')) + expect(screen.getByTestId('chunk-detail-modal')).toBeInTheDocument() + }) + + it('should render images when files exist', () => { + const payload = makePayload({ + files: [{ name: 'img.png', mime_type: 'image/png', source_url: 'url', size: 100, extension: 'png' }], + }) + render(<ResultItem payload={payload} />) + expect(screen.getByTestId('image-list')).toBeInTheDocument() + }) + + it('should not render keywords when child_chunks are present', () => { + const payload = makePayload({ + segment: { keywords: ['k1'] }, + child_chunks: [{ id: 'c1' }], + }) + render(<ResultItem payload={payload} />) + expect(screen.queryByTestId('tag')).not.toBeInTheDocument() + }) + + it('should not render keywords section when keywords array is empty', () => { + const payload = makePayload({ + segment: { keywords: [] }, + }) + render(<ResultItem payload={payload} />) + expect(screen.queryByTestId('tag')).not.toBeInTheDocument() + }) + + it('should toggle child chunks fold state', async () => { + const payload = makePayload({ + child_chunks: [{ id: 'c1' }], + }) + render(<ResultItem payload={payload} />) + expect(screen.getByTestId('child-chunk')).toBeInTheDocument() + + const header = screen.getByText(/hitChunks/i) + fireEvent.click(header.closest('div')!) + + await waitFor(() => { + expect(screen.queryByTestId('child-chunk')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/__tests__/score.spec.tsx b/web/app/components/datasets/hit-testing/components/__tests__/score.spec.tsx new file mode 100644 index 0000000000..7fbaf45e5d --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/__tests__/score.spec.tsx @@ -0,0 +1,92 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Score from '../score' + +describe('Score', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for the score display component + describe('Rendering', () => { + it('should render score value with toFixed(2)', () => { + render(<Score value={0.85} />) + + expect(screen.getByText('0.85')).toBeInTheDocument() + expect(screen.getByText('score')).toBeInTheDocument() + }) + + it('should render score progress bar with correct width', () => { + const { container } = render(<Score value={0.75} />) + + const progressBar = container.querySelector('[style]') + expect(progressBar).toHaveStyle({ width: '75%' }) + }) + + it('should render with besideChunkName styling', () => { + const { container } = render(<Score value={0.5} besideChunkName />) + + const root = container.firstElementChild + expect(root?.className).toContain('h-[20.5px]') + expect(root?.className).toContain('border-l-0') + }) + + it('should render with default styling when besideChunkName is false', () => { + const { container } = render(<Score value={0.5} />) + + const root = container.firstElementChild + expect(root?.className).toContain('h-[20px]') + expect(root?.className).toContain('rounded-md') + }) + + it('should remove right border when value is exactly 1', () => { + const { container } = render(<Score value={1} />) + + const progressBar = container.querySelector('[style]') + expect(progressBar?.className).toContain('border-r-0') + expect(progressBar).toHaveStyle({ width: '100%' }) + }) + + it('should show right border when value is less than 1', () => { + const { container } = render(<Score value={0.5} />) + + const progressBar = container.querySelector('[style]') + expect(progressBar?.className).not.toContain('border-r-0') + }) + }) + + // Null return tests for edge cases + describe('Returns null', () => { + it('should return null when value is null', () => { + const { container } = render(<Score value={null} />) + + expect(container.innerHTML).toBe('') + }) + + it('should return null when value is 0', () => { + const { container } = render(<Score value={0} />) + + expect(container.innerHTML).toBe('') + }) + + it('should return null when value is NaN', () => { + const { container } = render(<Score value={Number.NaN} />) + + expect(container.innerHTML).toBe('') + }) + }) + + describe('Edge Cases', () => { + it('should render very small score values', () => { + render(<Score value={0.01} />) + + expect(screen.getByText('0.01')).toBeInTheDocument() + }) + + it('should render score with many decimals truncated to 2', () => { + render(<Score value={0.123456} />) + + expect(screen.getByText('0.12')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx b/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx new file mode 100644 index 0000000000..b00e430575 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/query-input/__tests__/index.spec.tsx @@ -0,0 +1,111 @@ +import type { Query } from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import QueryInput from '../index' + +vi.mock('uuid', () => ({ + v4: () => 'mock-uuid', +})) + +vi.mock('@/app/components/base/button', () => ({ + default: ({ children, onClick, disabled, loading }: { children: React.ReactNode, onClick: () => void, disabled?: boolean, loading?: boolean }) => ( + <button data-testid="submit-button" onClick={onClick} disabled={disabled || loading}> + {children} + </button> + ), +})) + +vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ + default: ({ textArea, actionButton }: { textArea: React.ReactNode, actionButton: React.ReactNode }) => ( + <div data-testid="image-uploader"> + {textArea} + {actionButton} + </div> + ), +})) + +vi.mock('@/app/components/datasets/common/retrieval-method-info', () => ({ + getIcon: () => '/test-icon.png', +})) + +vi.mock('@/app/components/datasets/hit-testing/modify-external-retrieval-modal', () => ({ + default: () => <div data-testid="external-retrieval-modal" />, +})) + +vi.mock('../textarea', () => ({ + default: ({ text }: { text: string }) => <textarea data-testid="textarea" defaultValue={text} />, +})) + +vi.mock('@/context/dataset-detail', () => ({ + useDatasetDetailContextWithSelector: () => false, +})) + +describe('QueryInput', () => { + const defaultProps = { + onUpdateList: vi.fn(), + setHitResult: vi.fn(), + setExternalHitResult: vi.fn(), + loading: false, + queries: [{ content: 'test query', content_type: 'text_query', file_info: null }] satisfies Query[], + setQueries: vi.fn(), + isExternal: false, + onClickRetrievalMethod: vi.fn(), + retrievalConfig: { search_method: 'semantic_search' } as RetrievalConfig, + isEconomy: false, + hitTestingMutation: vi.fn(), + externalKnowledgeBaseHitTestingMutation: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render title', () => { + render(<QueryInput {...defaultProps} />) + expect(screen.getByText('datasetHitTesting.input.title')).toBeInTheDocument() + }) + + it('should render textarea with query text', () => { + render(<QueryInput {...defaultProps} />) + expect(screen.getByTestId('textarea')).toBeInTheDocument() + }) + + it('should render submit button', () => { + render(<QueryInput {...defaultProps} />) + expect(screen.getByTestId('submit-button')).toBeInTheDocument() + }) + + it('should disable submit button when text is empty', () => { + const props = { + ...defaultProps, + queries: [{ content: '', content_type: 'text_query', file_info: null }] satisfies Query[], + } + render(<QueryInput {...props} />) + expect(screen.getByTestId('submit-button')).toBeDisabled() + }) + + it('should render retrieval method for non-external mode', () => { + render(<QueryInput {...defaultProps} />) + expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument() + }) + + it('should render settings button for external mode', () => { + render(<QueryInput {...defaultProps} isExternal={true} />) + expect(screen.getByText('datasetHitTesting.settingTitle')).toBeInTheDocument() + }) + + it('should disable submit button when text exceeds 200 characters', () => { + const props = { + ...defaultProps, + queries: [{ content: 'a'.repeat(201), content_type: 'text_query', file_info: null }] satisfies Query[], + } + render(<QueryInput {...props} />) + expect(screen.getByTestId('submit-button')).toBeDisabled() + }) + + it('should disable submit button when loading', () => { + render(<QueryInput {...defaultProps} loading={true} />) + expect(screen.getByTestId('submit-button')).toBeDisabled() + }) +}) diff --git a/web/app/components/datasets/hit-testing/components/query-input/__tests__/textarea.spec.tsx b/web/app/components/datasets/hit-testing/components/query-input/__tests__/textarea.spec.tsx new file mode 100644 index 0000000000..c7d5f8f799 --- /dev/null +++ b/web/app/components/datasets/hit-testing/components/query-input/__tests__/textarea.spec.tsx @@ -0,0 +1,120 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Textarea from '../textarea' + +describe('Textarea', () => { + const mockHandleTextChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for the textarea with character count + describe('Rendering', () => { + it('should render a textarea element', () => { + render(<Textarea text="" handleTextChange={mockHandleTextChange} />) + + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should display the current text', () => { + render(<Textarea text="Hello world" handleTextChange={mockHandleTextChange} />) + + expect(screen.getByRole('textbox')).toHaveValue('Hello world') + }) + + it('should show character count', () => { + render(<Textarea text="Hello" handleTextChange={mockHandleTextChange} />) + + expect(screen.getByText('5/200')).toBeInTheDocument() + }) + + it('should show 0/200 for empty text', () => { + render(<Textarea text="" handleTextChange={mockHandleTextChange} />) + + expect(screen.getByText('0/200')).toBeInTheDocument() + }) + + it('should render placeholder text', () => { + render(<Textarea text="" handleTextChange={mockHandleTextChange} />) + + expect(screen.getByRole('textbox')).toHaveAttribute('placeholder') + }) + }) + + // Warning state tests for exceeding character limit + describe('Warning state (>200 chars)', () => { + it('should apply warning border when text exceeds 200 characters', () => { + const longText = 'A'.repeat(201) + + const { container } = render( + <Textarea text={longText} handleTextChange={mockHandleTextChange} />, + ) + + const wrapper = container.firstElementChild + expect(wrapper?.className).toContain('border-state-destructive-active') + }) + + it('should not apply warning border when text is at 200 characters', () => { + const text200 = 'A'.repeat(200) + + const { container } = render( + <Textarea text={text200} handleTextChange={mockHandleTextChange} />, + ) + + const wrapper = container.firstElementChild + expect(wrapper?.className).not.toContain('border-state-destructive-active') + }) + + it('should not apply warning border when text is under 200 characters', () => { + const { container } = render( + <Textarea text="Short text" handleTextChange={mockHandleTextChange} />, + ) + + const wrapper = container.firstElementChild + expect(wrapper?.className).not.toContain('border-state-destructive-active') + }) + + it('should show warning count with red styling when over 200 chars', () => { + const longText = 'B'.repeat(250) + + render(<Textarea text={longText} handleTextChange={mockHandleTextChange} />) + + const countElement = screen.getByText('250/200') + expect(countElement.className).toContain('text-util-colors-red-red-600') + }) + + it('should show normal count styling when at or under 200 chars', () => { + render(<Textarea text="Short" handleTextChange={mockHandleTextChange} />) + + const countElement = screen.getByText('5/200') + expect(countElement.className).toContain('text-text-tertiary') + }) + + it('should show red corner icon when over 200 chars', () => { + const longText = 'C'.repeat(201) + + const { container } = render( + <Textarea text={longText} handleTextChange={mockHandleTextChange} />, + ) + + // Assert - Corner icon should have red class + const cornerWrapper = container.querySelector('.right-0.top-0') + const cornerSvg = cornerWrapper?.querySelector('svg') + expect(cornerSvg?.className.baseVal || cornerSvg?.getAttribute('class')).toContain('text-util-colors-red-red-100') + }) + }) + + // User interaction tests + describe('User Interactions', () => { + it('should call handleTextChange when text is entered', () => { + render(<Textarea text="" handleTextChange={mockHandleTextChange} />) + + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'New text' }, + }) + + expect(mockHandleTextChange).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/web/app/components/datasets/hit-testing/index.spec.tsx b/web/app/components/datasets/hit-testing/index.spec.tsx deleted file mode 100644 index 07a78cd55f..0000000000 --- a/web/app/components/datasets/hit-testing/index.spec.tsx +++ /dev/null @@ -1,2704 +0,0 @@ -import type { ReactNode } from 'react' -import type { DataSet, HitTesting, HitTestingChildChunk, HitTestingRecord, HitTestingResponse, Query } from '@/models/datasets' -import type { RetrievalConfig } from '@/types/app' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' -import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types' -import { RETRIEVE_METHOD } from '@/types/app' - -// ============================================================================ -// Imports (after mocks) -// ============================================================================ - -import ChildChunksItem from './components/child-chunks-item' -import ChunkDetailModal from './components/chunk-detail-modal' -import EmptyRecords from './components/empty-records' -import Mask from './components/mask' -import QueryInput from './components/query-input' -import Textarea from './components/query-input/textarea' -import Records from './components/records' -import ResultItem from './components/result-item' -import ResultItemExternal from './components/result-item-external' -import ResultItemFooter from './components/result-item-footer' -import ResultItemMeta from './components/result-item-meta' -import Score from './components/score' -import HitTestingPage from './index' -import ModifyExternalRetrievalModal from './modify-external-retrieval-modal' -import ModifyRetrievalModal from './modify-retrieval-modal' -import { extensionToFileType } from './utils/extension-to-file-type' - -// Mock Toast -// Note: These components use real implementations for integration testing: -// - Toast, FloatRightContainer, Drawer, Pagination, Loading -// - RetrievalMethodConfig, EconomicalRetrievalMethodConfig -// - ImageUploaderInRetrievalTesting, retrieval-method-info, check-rerank-model - -// Mock RetrievalSettings to allow triggering onChange -vi.mock('@/app/components/datasets/external-knowledge-base/create/RetrievalSettings', () => ({ - default: ({ onChange }: { onChange: (data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => void }) => { - return ( - <div data-testid="retrieval-settings-mock"> - <button data-testid="change-top-k" onClick={() => onChange({ top_k: 8 })}>Change Top K</button> - <button data-testid="change-score-threshold" onClick={() => onChange({ score_threshold: 0.9 })}>Change Score Threshold</button> - <button data-testid="change-score-enabled" onClick={() => onChange({ score_threshold_enabled: true })}>Change Score Enabled</button> - </div> - ) - }, -})) - -// ============================================================================ -// Mock Setup -// ============================================================================ - -// Mock next/navigation -vi.mock('next/navigation', () => ({ - useRouter: () => ({ - push: vi.fn(), - replace: vi.fn(), - }), - usePathname: () => '/test', - useSearchParams: () => new URLSearchParams(), -})) - -// Mock use-context-selector -const mockDataset = { - id: 'dataset-1', - name: 'Test Dataset', - provider: 'vendor', - indexing_technique: 'high_quality' as const, - retrieval_model_dict: { - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_mode: undefined, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - weights: undefined, - top_k: 10, - score_threshold_enabled: false, - score_threshold: 0.5, - }, - is_multimodal: false, -} as Partial<DataSet> - -vi.mock('use-context-selector', () => ({ - useContext: vi.fn(() => ({ dataset: mockDataset })), - useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })), - createContext: vi.fn(() => ({})), -})) - -// Mock dataset detail context -vi.mock('@/context/dataset-detail', () => ({ - default: {}, - useDatasetDetailContext: vi.fn(() => ({ dataset: mockDataset })), - useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset }) => unknown) => - selector({ dataset: mockDataset as DataSet }), - ), -})) - -// Mock service hooks -const mockRecordsRefetch = vi.fn() -const mockHitTestingMutateAsync = vi.fn() -const mockExternalHitTestingMutateAsync = vi.fn() - -vi.mock('@/service/knowledge/use-dataset', () => ({ - useDatasetTestingRecords: vi.fn(() => ({ - data: { - data: [], - total: 0, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - })), -})) - -vi.mock('@/service/knowledge/use-hit-testing', () => ({ - useHitTesting: vi.fn(() => ({ - mutateAsync: mockHitTestingMutateAsync, - isPending: false, - })), - useExternalKnowledgeBaseHitTesting: vi.fn(() => ({ - mutateAsync: mockExternalHitTestingMutateAsync, - isPending: false, - })), -})) - -// Mock breakpoints hook -vi.mock('@/hooks/use-breakpoints', () => ({ - default: vi.fn(() => 'pc'), - MediaType: { - mobile: 'mobile', - pc: 'pc', - }, -})) - -// Mock timestamp hook -vi.mock('@/hooks/use-timestamp', () => ({ - default: vi.fn(() => ({ - formatTime: vi.fn((timestamp: number, _format: string) => new Date(timestamp * 1000).toISOString()), - })), -})) - -// Mock use-common to avoid QueryClient issues in nested hooks -vi.mock('@/service/use-common', () => ({ - useFileUploadConfig: vi.fn(() => ({ - data: { - file_size_limit: 10, - batch_count_limit: 5, - image_file_size_limit: 5, - }, - isLoading: false, - })), -})) - -// Store ref to ImageUploader onChange for testing -let mockImageUploaderOnChange: ((files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void) | null = null - -// Mock ImageUploaderInRetrievalTesting to capture onChange -vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ - default: ({ textArea, actionButton, onChange }: { - textArea: React.ReactNode - actionButton: React.ReactNode - onChange: (files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void - }) => { - mockImageUploaderOnChange = onChange - return ( - <div data-testid="image-uploader-mock"> - {textArea} - {actionButton} - <button - data-testid="trigger-image-change" - onClick={() => onChange([ - { - sourceUrl: 'http://example.com/new-image.png', - uploadedId: 'new-uploaded-id', - mimeType: 'image/png', - name: 'new-image.png', - size: 2000, - extension: 'png', - }, - ])} - > - Add Image - </button> - </div> - ) - }, -})) - -// Mock docLink hook -vi.mock('@/context/i18n', () => ({ - useDocLink: vi.fn(() => () => 'https://docs.example.com'), -})) - -// Mock provider context for retrieval method config -vi.mock('@/context/provider-context', () => ({ - useProviderContext: vi.fn(() => ({ - supportRetrievalMethods: [ - 'semantic_search', - 'full_text_search', - 'hybrid_search', - ], - })), -})) - -// Mock model list hook - include all exports used by child components -vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ - useModelList: vi.fn(() => ({ - data: [], - isLoading: false, - })), - useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(() => ({ - modelList: [], - defaultModel: undefined, - currentProvider: undefined, - currentModel: undefined, - })), - useModelListAndDefaultModel: vi.fn(() => ({ - modelList: [], - defaultModel: undefined, - })), - useCurrentProviderAndModel: vi.fn(() => ({ - currentProvider: undefined, - currentModel: undefined, - })), - useDefaultModel: vi.fn(() => ({ - defaultModel: undefined, - })), -})) - -// ============================================================================ -// Test Wrapper with QueryClientProvider -// ============================================================================ - -const createTestQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, - }, - mutations: { - retry: false, - }, - }, -}) - -const TestWrapper = ({ children }: { children: ReactNode }) => { - const queryClient = createTestQueryClient() - return ( - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> - ) -} - -const renderWithProviders = (ui: React.ReactElement) => { - return render(ui, { wrapper: TestWrapper }) -} - -// ============================================================================ -// Test Factories -// ============================================================================ - -const createMockSegment = (overrides = {}) => ({ - id: 'segment-1', - document: { - id: 'doc-1', - data_source_type: 'upload_file', - name: 'test-document.pdf', - doc_type: 'book' as const, - }, - content: 'Test segment content', - sign_content: 'Test signed content', - position: 1, - word_count: 100, - tokens: 50, - keywords: ['test', 'keyword'], - hit_count: 5, - index_node_hash: 'hash-123', - answer: '', - ...overrides, -}) - -const createMockHitTesting = (overrides = {}): HitTesting => ({ - segment: createMockSegment() as HitTesting['segment'], - content: createMockSegment() as HitTesting['content'], - score: 0.85, - tsne_position: { x: 0.5, y: 0.5 }, - child_chunks: null, - files: [], - ...overrides, -}) - -const createMockChildChunk = (overrides = {}): HitTestingChildChunk => ({ - id: 'child-chunk-1', - content: 'Child chunk content', - position: 1, - score: 0.9, - ...overrides, -}) - -const createMockRecord = (overrides = {}): HitTestingRecord => ({ - id: 'record-1', - source: 'hit_testing', - source_app_id: 'app-1', - created_by_role: 'account', - created_by: 'user-1', - created_at: 1609459200, - queries: [ - { content: 'Test query', content_type: 'text_query', file_info: null }, - ], - ...overrides, -}) - -const createMockRetrievalConfig = (overrides = {}): RetrievalConfig => ({ - search_method: RETRIEVE_METHOD.semantic, - reranking_enable: false, - reranking_mode: undefined, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - weights: undefined, - top_k: 10, - score_threshold_enabled: false, - score_threshold: 0.5, - ...overrides, -} as RetrievalConfig) - -// ============================================================================ -// Utility Function Tests -// ============================================================================ - -describe('extensionToFileType', () => { - describe('PDF files', () => { - it('should return pdf type for pdf extension', () => { - expect(extensionToFileType('pdf')).toBe(FileAppearanceTypeEnum.pdf) - }) - }) - - describe('Word files', () => { - it('should return word type for doc extension', () => { - expect(extensionToFileType('doc')).toBe(FileAppearanceTypeEnum.word) - }) - - it('should return word type for docx extension', () => { - expect(extensionToFileType('docx')).toBe(FileAppearanceTypeEnum.word) - }) - }) - - describe('Markdown files', () => { - it('should return markdown type for md extension', () => { - expect(extensionToFileType('md')).toBe(FileAppearanceTypeEnum.markdown) - }) - - it('should return markdown type for mdx extension', () => { - expect(extensionToFileType('mdx')).toBe(FileAppearanceTypeEnum.markdown) - }) - - it('should return markdown type for markdown extension', () => { - expect(extensionToFileType('markdown')).toBe(FileAppearanceTypeEnum.markdown) - }) - }) - - describe('Excel files', () => { - it('should return excel type for csv extension', () => { - expect(extensionToFileType('csv')).toBe(FileAppearanceTypeEnum.excel) - }) - - it('should return excel type for xls extension', () => { - expect(extensionToFileType('xls')).toBe(FileAppearanceTypeEnum.excel) - }) - - it('should return excel type for xlsx extension', () => { - expect(extensionToFileType('xlsx')).toBe(FileAppearanceTypeEnum.excel) - }) - }) - - describe('Document files', () => { - it('should return document type for txt extension', () => { - expect(extensionToFileType('txt')).toBe(FileAppearanceTypeEnum.document) - }) - - it('should return document type for epub extension', () => { - expect(extensionToFileType('epub')).toBe(FileAppearanceTypeEnum.document) - }) - - it('should return document type for html extension', () => { - expect(extensionToFileType('html')).toBe(FileAppearanceTypeEnum.document) - }) - - it('should return document type for htm extension', () => { - expect(extensionToFileType('htm')).toBe(FileAppearanceTypeEnum.document) - }) - - it('should return document type for xml extension', () => { - expect(extensionToFileType('xml')).toBe(FileAppearanceTypeEnum.document) - }) - }) - - describe('PowerPoint files', () => { - it('should return ppt type for ppt extension', () => { - expect(extensionToFileType('ppt')).toBe(FileAppearanceTypeEnum.ppt) - }) - - it('should return ppt type for pptx extension', () => { - expect(extensionToFileType('pptx')).toBe(FileAppearanceTypeEnum.ppt) - }) - }) - - describe('Edge cases', () => { - it('should return custom type for unknown extension', () => { - expect(extensionToFileType('unknown')).toBe(FileAppearanceTypeEnum.custom) - }) - - it('should return custom type for empty string', () => { - expect(extensionToFileType('')).toBe(FileAppearanceTypeEnum.custom) - }) - }) -}) - -// ============================================================================ -// Score Component Tests -// ============================================================================ - -describe('Score', () => { - describe('Rendering', () => { - it('should render score with correct value', () => { - render(<Score value={0.85} />) - expect(screen.getByText('0.85')).toBeInTheDocument() - expect(screen.getByText('score')).toBeInTheDocument() - }) - - it('should render nothing when value is null', () => { - const { container } = render(<Score value={null} />) - expect(container.firstChild).toBeNull() - }) - - it('should render nothing when value is NaN', () => { - const { container } = render(<Score value={Number.NaN} />) - expect(container.firstChild).toBeNull() - }) - - it('should render nothing when value is 0', () => { - const { container } = render(<Score value={0} />) - expect(container.firstChild).toBeNull() - }) - }) - - describe('Props', () => { - it('should apply besideChunkName styles when prop is true', () => { - const { container } = render(<Score value={0.5} besideChunkName />) - const wrapper = container.firstChild as HTMLElement - expect(wrapper).toHaveClass('border-l-0') - }) - - it('should apply rounded styles when besideChunkName is false', () => { - const { container } = render(<Score value={0.5} besideChunkName={false} />) - const wrapper = container.firstChild as HTMLElement - expect(wrapper).toHaveClass('rounded-md') - }) - }) - - describe('Edge Cases', () => { - it('should display full score correctly', () => { - render(<Score value={1} />) - expect(screen.getByText('1.00')).toBeInTheDocument() - }) - - it('should display very small score correctly', () => { - render(<Score value={0.01} />) - expect(screen.getByText('0.01')).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// Mask Component Tests -// ============================================================================ - -describe('Mask', () => { - describe('Rendering', () => { - it('should render without crashing', () => { - const { container } = render(<Mask />) - expect(container.firstChild).toBeInTheDocument() - }) - - it('should have gradient background class', () => { - const { container } = render(<Mask />) - expect(container.firstChild).toHaveClass('bg-gradient-to-b') - }) - }) - - describe('Props', () => { - it('should apply custom className', () => { - const { container } = render(<Mask className="custom-class" />) - expect(container.firstChild).toHaveClass('custom-class') - }) - }) -}) - -// ============================================================================ -// EmptyRecords Component Tests -// ============================================================================ - -describe('EmptyRecords', () => { - describe('Rendering', () => { - it('should render without crashing', () => { - render(<EmptyRecords />) - expect(screen.getByText(/noRecentTip/i)).toBeInTheDocument() - }) - - it('should render history icon', () => { - const { container } = render(<EmptyRecords />) - const icon = container.querySelector('svg') - expect(icon).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// ResultItemMeta Component Tests -// ============================================================================ - -describe('ResultItemMeta', () => { - const defaultProps = { - labelPrefix: 'Chunk', - positionId: 1, - wordCount: 100, - score: 0.85, - } - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<ResultItemMeta {...defaultProps} />) - expect(screen.getByText(/100/)).toBeInTheDocument() - }) - - it('should render score component', () => { - render(<ResultItemMeta {...defaultProps} />) - expect(screen.getByText('0.85')).toBeInTheDocument() - }) - - it('should render word count', () => { - render(<ResultItemMeta {...defaultProps} />) - expect(screen.getByText(/100/)).toBeInTheDocument() - }) - }) - - describe('Props', () => { - it('should apply custom className', () => { - const { container } = render(<ResultItemMeta {...defaultProps} className="custom-class" />) - expect(container.firstChild).toHaveClass('custom-class') - }) - - it('should handle different position IDs', () => { - render(<ResultItemMeta {...defaultProps} positionId={42} />) - // Position ID is passed to SegmentIndexTag - expect(screen.getByText(/42/)).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// ResultItemFooter Component Tests -// ============================================================================ - -describe('ResultItemFooter', () => { - const mockShowDetailModal = vi.fn() - const defaultProps = { - docType: FileAppearanceTypeEnum.pdf, - docTitle: 'Test Document.pdf', - showDetailModal: mockShowDetailModal, - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<ResultItemFooter {...defaultProps} />) - expect(screen.getByText('Test Document.pdf')).toBeInTheDocument() - }) - - it('should render open button', () => { - render(<ResultItemFooter {...defaultProps} />) - expect(screen.getByText(/open/i)).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call showDetailModal when open button is clicked', async () => { - render(<ResultItemFooter {...defaultProps} />) - - const openButton = screen.getByText(/open/i).parentElement - if (openButton) - fireEvent.click(openButton) - - expect(mockShowDetailModal).toHaveBeenCalledTimes(1) - }) - }) -}) - -// ============================================================================ -// ChildChunksItem Component Tests -// ============================================================================ - -describe('ChildChunksItem', () => { - const mockChildChunk = createMockChildChunk() - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<ChildChunksItem payload={mockChildChunk} isShowAll={false} />) - expect(screen.getByText(/Child chunk content/)).toBeInTheDocument() - }) - - it('should render position identifier', () => { - render(<ChildChunksItem payload={mockChildChunk} isShowAll={false} />) - // The C- and position number are in the same element - expect(screen.getByText(/C-/)).toBeInTheDocument() - }) - - it('should render score', () => { - render(<ChildChunksItem payload={mockChildChunk} isShowAll={false} />) - expect(screen.getByText('0.90')).toBeInTheDocument() - }) - }) - - describe('Props', () => { - it('should apply line-clamp when isShowAll is false', () => { - const { container } = render(<ChildChunksItem payload={mockChildChunk} isShowAll={false} />) - expect(container.firstChild).toHaveClass('line-clamp-2') - }) - - it('should not apply line-clamp when isShowAll is true', () => { - const { container } = render(<ChildChunksItem payload={mockChildChunk} isShowAll={true} />) - expect(container.firstChild).not.toHaveClass('line-clamp-2') - }) - }) -}) - -// ============================================================================ -// ResultItem Component Tests -// ============================================================================ - -describe('ResultItem', () => { - const mockHitTesting = createMockHitTesting() - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<ResultItem payload={mockHitTesting} />) - // Document name should be visible - expect(screen.getByText('test-document.pdf')).toBeInTheDocument() - }) - - it('should render score', () => { - render(<ResultItem payload={mockHitTesting} />) - expect(screen.getByText('0.85')).toBeInTheDocument() - }) - - it('should render document name in footer', () => { - render(<ResultItem payload={mockHitTesting} />) - expect(screen.getByText('test-document.pdf')).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should open detail modal when clicked', async () => { - render(<ResultItem payload={mockHitTesting} />) - - const item = screen.getByText('test-document.pdf').closest('.cursor-pointer') - if (item) - fireEvent.click(item) - - await waitFor(() => { - expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument() - }) - }) - }) - - describe('Parent-Child Retrieval', () => { - it('should render child chunks when present', () => { - const payloadWithChildren = createMockHitTesting({ - child_chunks: [createMockChildChunk()], - }) - - render(<ResultItem payload={payloadWithChildren} />) - expect(screen.getByText(/hitChunks/i)).toBeInTheDocument() - }) - - it('should toggle fold state when child chunks header is clicked', async () => { - const payloadWithChildren = createMockHitTesting({ - child_chunks: [createMockChildChunk()], - }) - - render(<ResultItem payload={payloadWithChildren} />) - - // Child chunks should be visible by default (not folded) - expect(screen.getByText(/Child chunk content/)).toBeInTheDocument() - - // Click to fold - const toggleButton = screen.getByText(/hitChunks/i).parentElement - if (toggleButton) { - fireEvent.click(toggleButton) - - await waitFor(() => { - expect(screen.queryByText(/Child chunk content/)).not.toBeInTheDocument() - }) - } - }) - }) - - describe('Keywords', () => { - it('should render keywords when present and no child chunks', () => { - const payload = createMockHitTesting({ - segment: createMockSegment({ keywords: ['keyword1', 'keyword2'] }), - child_chunks: null, - }) - - render(<ResultItem payload={payload} />) - expect(screen.getByText('keyword1')).toBeInTheDocument() - expect(screen.getByText('keyword2')).toBeInTheDocument() - }) - - it('should not render keywords when child chunks are present', () => { - const payload = createMockHitTesting({ - segment: createMockSegment({ keywords: ['keyword1'] }), - child_chunks: [createMockChildChunk()], - }) - - render(<ResultItem payload={payload} />) - expect(screen.queryByText('keyword1')).not.toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// ResultItemExternal Component Tests -// ============================================================================ - -describe('ResultItemExternal', () => { - const defaultProps = { - payload: { - content: 'External content', - title: 'External Title', - score: 0.75, - metadata: { - 'x-amz-bedrock-kb-source-uri': 'source-uri', - 'x-amz-bedrock-kb-data-source-id': 'data-source-id', - }, - }, - positionId: 1, - } - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<ResultItemExternal {...defaultProps} />) - expect(screen.getByText('External content')).toBeInTheDocument() - }) - - it('should render title in footer', () => { - render(<ResultItemExternal {...defaultProps} />) - expect(screen.getByText('External Title')).toBeInTheDocument() - }) - - it('should render score', () => { - render(<ResultItemExternal {...defaultProps} />) - expect(screen.getByText('0.75')).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should open detail modal when clicked', async () => { - render(<ResultItemExternal {...defaultProps} />) - - const item = screen.getByText('External content').closest('.cursor-pointer') - if (item) - fireEvent.click(item) - - await waitFor(() => { - expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument() - }) - }) - }) -}) - -// ============================================================================ -// Textarea Component Tests -// ============================================================================ - -describe('Textarea', () => { - const mockHandleTextChange = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<Textarea text="" handleTextChange={mockHandleTextChange} />) - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - - it('should display text value', () => { - render(<Textarea text="Test input" handleTextChange={mockHandleTextChange} />) - expect(screen.getByDisplayValue('Test input')).toBeInTheDocument() - }) - - it('should display character count', () => { - render(<Textarea text="Hello" handleTextChange={mockHandleTextChange} />) - expect(screen.getByText('5/200')).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call handleTextChange when typing', async () => { - render(<Textarea text="" handleTextChange={mockHandleTextChange} />) - - const textarea = screen.getByRole('textbox') - fireEvent.change(textarea, { target: { value: 'New text' } }) - - expect(mockHandleTextChange).toHaveBeenCalled() - }) - }) - - describe('Validation', () => { - it('should show warning style when text exceeds 200 characters', () => { - const longText = 'a'.repeat(201) - const { container } = render(<Textarea text={longText} handleTextChange={mockHandleTextChange} />) - - expect(container.querySelector('.border-state-destructive-active')).toBeInTheDocument() - }) - - it('should show warning count when text exceeds 200 characters', () => { - const longText = 'a'.repeat(201) - render(<Textarea text={longText} handleTextChange={mockHandleTextChange} />) - - expect(screen.getByText('201/200')).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// Records Component Tests -// ============================================================================ - -describe('Records', () => { - const mockOnClickRecord = vi.fn() - const mockRecords = [ - createMockRecord({ id: 'record-1', created_at: 1609459200 }), - createMockRecord({ id: 'record-2', created_at: 1609545600 }), - ] - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<Records records={mockRecords} onClickRecord={mockOnClickRecord} />) - expect(screen.getByText(/queryContent/i)).toBeInTheDocument() - }) - - it('should render all records', () => { - render(<Records records={mockRecords} onClickRecord={mockOnClickRecord} />) - // Each record has "Test query" as content - expect(screen.getAllByText('Test query')).toHaveLength(2) - }) - - it('should render table headers', () => { - render(<Records records={mockRecords} onClickRecord={mockOnClickRecord} />) - expect(screen.getByText(/queryContent/i)).toBeInTheDocument() - expect(screen.getByText(/source/i)).toBeInTheDocument() - expect(screen.getByText(/time/i)).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call onClickRecord when a record row is clicked', async () => { - render(<Records records={mockRecords} onClickRecord={mockOnClickRecord} />) - - // Find the table body row with the query content - const queryText = screen.getAllByText('Test query')[0] - const row = queryText.closest('tr') - if (row) - fireEvent.click(row) - - expect(mockOnClickRecord).toHaveBeenCalledTimes(1) - }) - - it('should toggle sort order when time header is clicked', async () => { - render(<Records records={mockRecords} onClickRecord={mockOnClickRecord} />) - - const timeHeader = screen.getByText(/time/i) - fireEvent.click(timeHeader) - - // Sort order should have toggled (default is desc, now should be asc) - // The records should be reordered - await waitFor(() => { - const rows = screen.getAllByText('Test query') - expect(rows).toHaveLength(2) - }) - }) - }) - - describe('Source Display', () => { - it('should display source correctly for hit_testing', () => { - render(<Records records={mockRecords} onClickRecord={mockOnClickRecord} />) - expect(screen.getAllByText(/retrieval test/i)).toHaveLength(2) - }) - - it('should display source correctly for app', () => { - const appRecords = [createMockRecord({ source: 'app' })] - render(<Records records={appRecords} onClickRecord={mockOnClickRecord} />) - expect(screen.getByText('app')).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// ModifyExternalRetrievalModal Component Tests -// ============================================================================ - -describe('ModifyExternalRetrievalModal', () => { - const mockOnClose = vi.fn() - const mockOnSave = vi.fn() - const defaultProps = { - onClose: mockOnClose, - onSave: mockOnSave, - initialTopK: 4, - initialScoreThreshold: 0.5, - initialScoreThresholdEnabled: false, - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - expect(screen.getByText(/settingTitle/i)).toBeInTheDocument() - }) - - it('should render cancel and save buttons', () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - expect(screen.getByText(/cancel/i)).toBeInTheDocument() - expect(screen.getByText(/save/i)).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call onClose when cancel is clicked', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - fireEvent.click(screen.getByText(/cancel/i)) - - expect(mockOnClose).toHaveBeenCalledTimes(1) - }) - - it('should call onSave with settings when save is clicked', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - fireEvent.click(screen.getByText(/save/i)) - - expect(mockOnSave).toHaveBeenCalledWith({ - top_k: 4, - score_threshold: 0.5, - score_threshold_enabled: false, - }) - }) - - it('should call onClose when close button is clicked', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - const closeButton = screen.getByRole('button', { name: '' }) - fireEvent.click(closeButton) - - expect(mockOnClose).toHaveBeenCalled() - }) - }) - - describe('Settings Change Handling', () => { - it('should update top_k when settings change', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - // Click the button to change top_k - fireEvent.click(screen.getByTestId('change-top-k')) - - // Save to verify the change - fireEvent.click(screen.getByText(/save/i)) - - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - top_k: 8, - })) - }) - - it('should update score_threshold when settings change', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - // Click the button to change score_threshold - fireEvent.click(screen.getByTestId('change-score-threshold')) - - fireEvent.click(screen.getByText(/save/i)) - - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - score_threshold: 0.9, - })) - }) - - it('should update score_threshold_enabled when settings change', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - // Click the button to change score_threshold_enabled - fireEvent.click(screen.getByTestId('change-score-enabled')) - - fireEvent.click(screen.getByText(/save/i)) - - expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ - score_threshold_enabled: true, - })) - }) - - it('should call onClose after save', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - fireEvent.click(screen.getByText(/save/i)) - - // onClose should be called after onSave - expect(mockOnClose).toHaveBeenCalled() - }) - - it('should render with different initial values', () => { - render( - <ModifyExternalRetrievalModal - {...defaultProps} - initialTopK={10} - initialScoreThreshold={0.8} - initialScoreThresholdEnabled={true} - />, - ) - - fireEvent.click(screen.getByText(/save/i)) - - expect(mockOnSave).toHaveBeenCalledWith({ - top_k: 10, - score_threshold: 0.8, - score_threshold_enabled: true, - }) - }) - - it('should handle partial settings changes', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - // Change only top_k - fireEvent.click(screen.getByTestId('change-top-k')) - - fireEvent.click(screen.getByText(/save/i)) - - // Should have updated top_k while keeping other values - expect(mockOnSave).toHaveBeenCalledWith({ - top_k: 8, - score_threshold: 0.5, - score_threshold_enabled: false, - }) - }) - - it('should handle multiple settings changes', async () => { - render(<ModifyExternalRetrievalModal {...defaultProps} />) - - // Change multiple settings - fireEvent.click(screen.getByTestId('change-top-k')) - fireEvent.click(screen.getByTestId('change-score-threshold')) - fireEvent.click(screen.getByTestId('change-score-enabled')) - - fireEvent.click(screen.getByText(/save/i)) - - expect(mockOnSave).toHaveBeenCalledWith({ - top_k: 8, - score_threshold: 0.9, - score_threshold_enabled: true, - }) - }) - }) -}) - -// ============================================================================ -// ModifyRetrievalModal Component Tests -// ============================================================================ - -describe('ModifyRetrievalModal', () => { - const mockOnHide = vi.fn() - const mockOnSave = vi.fn() - const defaultProps = { - indexMethod: 'high_quality', - value: createMockRetrievalConfig(), - isShow: true, - onHide: mockOnHide, - onSave: mockOnSave, - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing when isShow is true', () => { - const { container } = renderWithProviders(<ModifyRetrievalModal {...defaultProps} />) - // Modal should be rendered - expect(container.firstChild).toBeInTheDocument() - }) - - it('should render nothing when isShow is false', () => { - const { container } = renderWithProviders(<ModifyRetrievalModal {...defaultProps} isShow={false} />) - expect(container.firstChild).toBeNull() - }) - - it('should render cancel and save buttons', () => { - renderWithProviders(<ModifyRetrievalModal {...defaultProps} />) - const buttons = screen.getAllByRole('button') - expect(buttons.length).toBeGreaterThanOrEqual(2) - }) - - it('should render learn more link', () => { - renderWithProviders(<ModifyRetrievalModal {...defaultProps} />) - const link = screen.getByRole('link') - expect(link).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should call onHide when cancel button is clicked', async () => { - renderWithProviders(<ModifyRetrievalModal {...defaultProps} />) - - // Find cancel button (second to last button typically) - const buttons = screen.getAllByRole('button') - const cancelButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('cancel')) - if (cancelButton) - fireEvent.click(cancelButton) - - expect(mockOnHide).toHaveBeenCalledTimes(1) - }) - - it('should call onHide when close icon is clicked', async () => { - const { container } = renderWithProviders(<ModifyRetrievalModal {...defaultProps} />) - - // Find close button by its position (usually has the close icon) - const closeButton = container.querySelector('.cursor-pointer') - if (closeButton) - fireEvent.click(closeButton) - - expect(mockOnHide).toHaveBeenCalled() - }) - - it('should call onSave when save button is clicked', async () => { - renderWithProviders(<ModifyRetrievalModal {...defaultProps} />) - - const buttons = screen.getAllByRole('button') - const saveButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('save')) - if (saveButton) - fireEvent.click(saveButton) - - expect(mockOnSave).toHaveBeenCalled() - }) - }) - - describe('Index Method', () => { - it('should render for high_quality index method', () => { - const { container } = renderWithProviders(<ModifyRetrievalModal {...defaultProps} indexMethod="high_quality" />) - expect(container.firstChild).toBeInTheDocument() - }) - - it('should render for economy index method', () => { - const { container } = renderWithProviders(<ModifyRetrievalModal {...defaultProps} indexMethod="economy" />) - expect(container.firstChild).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// ChunkDetailModal Component Tests -// ============================================================================ - -describe('ChunkDetailModal', () => { - const mockOnHide = vi.fn() - const mockPayload = createMockHitTesting() - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<ChunkDetailModal payload={mockPayload} onHide={mockOnHide} />) - expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument() - }) - - it('should render document name', () => { - render(<ChunkDetailModal payload={mockPayload} onHide={mockOnHide} />) - expect(screen.getByText('test-document.pdf')).toBeInTheDocument() - }) - - it('should render score', () => { - render(<ChunkDetailModal payload={mockPayload} onHide={mockOnHide} />) - expect(screen.getByText('0.85')).toBeInTheDocument() - }) - }) - - describe('Parent-Child Retrieval', () => { - it('should render child chunks section when present', () => { - const payloadWithChildren = createMockHitTesting({ - child_chunks: [createMockChildChunk()], - }) - - render(<ChunkDetailModal payload={payloadWithChildren} onHide={mockOnHide} />) - expect(screen.getByText(/hitChunks/i)).toBeInTheDocument() - }) - }) - - describe('Keywords', () => { - it('should render keywords section when present and no child chunks', () => { - const payload = createMockHitTesting({ - segment: createMockSegment({ keywords: ['keyword1', 'keyword2'] }), - child_chunks: null, - }) - - render(<ChunkDetailModal payload={payload} onHide={mockOnHide} />) - // Keywords should be rendered as tags - expect(screen.getByText('keyword1')).toBeInTheDocument() - expect(screen.getByText('keyword2')).toBeInTheDocument() - }) - }) - - describe('Q&A Mode', () => { - it('should render Q&A format when answer is present', () => { - const payload = createMockHitTesting({ - segment: createMockSegment({ - content: 'Question content', - answer: 'Answer content', - }), - }) - - render(<ChunkDetailModal payload={payload} onHide={mockOnHide} />) - expect(screen.getByText('Q')).toBeInTheDocument() - expect(screen.getByText('A')).toBeInTheDocument() - expect(screen.getByText('Question content')).toBeInTheDocument() - expect(screen.getByText('Answer content')).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// QueryInput Component Tests -// ============================================================================ - -describe('QueryInput', () => { - const mockSetHitResult = vi.fn() - const mockSetExternalHitResult = vi.fn() - const mockOnUpdateList = vi.fn() - const mockSetQueries = vi.fn() - const mockOnClickRetrievalMethod = vi.fn() - const mockOnSubmit = vi.fn() - - const defaultProps = { - setHitResult: mockSetHitResult, - setExternalHitResult: mockSetExternalHitResult, - onUpdateList: mockOnUpdateList, - loading: false, - queries: [] as Query[], - setQueries: mockSetQueries, - isExternal: false, - onClickRetrievalMethod: mockOnClickRetrievalMethod, - retrievalConfig: createMockRetrievalConfig(), - isEconomy: false, - onSubmit: mockOnSubmit, - hitTestingMutation: mockHitTestingMutateAsync, - externalKnowledgeBaseHitTestingMutation: mockExternalHitTestingMutateAsync, - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - const { container } = render(<QueryInput {...defaultProps} />) - expect(container.firstChild).toBeInTheDocument() - }) - - it('should render textarea', () => { - render(<QueryInput {...defaultProps} />) - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - - it('should render testing button', () => { - render(<QueryInput {...defaultProps} />) - // Find button by role - const buttons = screen.getAllByRole('button') - expect(buttons.length).toBeGreaterThan(0) - }) - }) - - describe('User Interactions', () => { - it('should update queries when text changes', async () => { - render(<QueryInput {...defaultProps} />) - - const textarea = screen.getByRole('textbox') - fireEvent.change(textarea, { target: { value: 'New query' } }) - - expect(mockSetQueries).toHaveBeenCalled() - }) - - it('should have disabled button when text is empty', () => { - render(<QueryInput {...defaultProps} />) - - // Find the primary/submit button - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - expect(submitButton).toBeDisabled() - }) - - it('should enable button when text is present', () => { - const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }] - render(<QueryInput {...defaultProps} queries={queries} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - expect(submitButton).not.toBeDisabled() - }) - - it('should disable button when text exceeds 200 characters', () => { - const longQuery: Query[] = [{ content: 'a'.repeat(201), content_type: 'text_query', file_info: null }] - render(<QueryInput {...defaultProps} queries={longQuery} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - expect(submitButton).toBeDisabled() - }) - - it('should show loading state on button when loading', () => { - const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }] - render(<QueryInput {...defaultProps} queries={queries} loading={true} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - // Button should have disabled styling classes - expect(submitButton).toHaveClass('disabled:btn-disabled') - }) - }) - - describe('External Mode', () => { - it('should render settings button for external mode', () => { - render(<QueryInput {...defaultProps} isExternal={true} />) - // In external mode, there should be a settings button - const buttons = screen.getAllByRole('button') - expect(buttons.length).toBeGreaterThanOrEqual(2) - }) - - it('should open settings modal when settings button is clicked', async () => { - renderWithProviders(<QueryInput {...defaultProps} isExternal={true} />) - - // Find the settings button (not the submit button) - const buttons = screen.getAllByRole('button') - const settingsButton = buttons.find(btn => !btn.classList.contains('w-[88px]')) - if (settingsButton) - fireEvent.click(settingsButton) - - await waitFor(() => { - // The modal should render - look for more buttons after modal opens - expect(screen.getAllByRole('button').length).toBeGreaterThan(2) - }) - }) - }) - - describe('Non-External Mode', () => { - it('should render retrieval method selector for non-external mode', () => { - const { container } = renderWithProviders(<QueryInput {...defaultProps} isExternal={false} />) - // Should have the retrieval method display (a clickable div) - const methodSelector = container.querySelector('.cursor-pointer') - expect(methodSelector).toBeInTheDocument() - }) - - it('should call onClickRetrievalMethod when clicked', async () => { - const { container } = renderWithProviders(<QueryInput {...defaultProps} isExternal={false} />) - - // Find the method selector (the cursor-pointer div that's not a button) - const methodSelectors = container.querySelectorAll('.cursor-pointer') - const methodSelector = Array.from(methodSelectors).find(el => !el.closest('button')) - if (methodSelector) - fireEvent.click(methodSelector) - - expect(mockOnClickRetrievalMethod).toHaveBeenCalledTimes(1) - }) - }) - - describe('Submission', () => { - it('should call hitTestingMutation when submit is clicked for non-external', async () => { - const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }] - mockHitTestingMutateAsync.mockResolvedValue({ records: [] }) - - render(<QueryInput {...defaultProps} queries={queries} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(mockHitTestingMutateAsync).toHaveBeenCalled() - }) - }) - - it('should call externalKnowledgeBaseHitTestingMutation when submit is clicked for external', async () => { - const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }] - mockExternalHitTestingMutateAsync.mockResolvedValue({ records: [] }) - - render(<QueryInput {...defaultProps} queries={queries} isExternal={true} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(mockExternalHitTestingMutateAsync).toHaveBeenCalled() - }) - }) - - it('should call setHitResult and onUpdateList on successful non-external submission', async () => { - const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }] - const mockResponse = { query: { content: 'test' }, records: [] } - - mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { - options?.onSuccess?.(mockResponse) - return mockResponse - }) - - renderWithProviders(<QueryInput {...defaultProps} queries={queries} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(mockSetHitResult).toHaveBeenCalledWith(mockResponse) - expect(mockOnUpdateList).toHaveBeenCalled() - expect(mockOnSubmit).toHaveBeenCalled() - }) - }) - - it('should call setExternalHitResult and onUpdateList on successful external submission', async () => { - const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }] - const mockResponse = { query: { content: 'test' }, records: [] } - - mockExternalHitTestingMutateAsync.mockImplementation(async (_params, options) => { - options?.onSuccess?.(mockResponse) - return mockResponse - }) - - renderWithProviders(<QueryInput {...defaultProps} queries={queries} isExternal={true} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(mockSetExternalHitResult).toHaveBeenCalledWith(mockResponse) - expect(mockOnUpdateList).toHaveBeenCalled() - }) - }) - }) - - describe('Image Queries', () => { - it('should handle queries with image_query type', () => { - const queriesWithImages: Query[] = [ - { content: 'Test query', content_type: 'text_query', file_info: null }, - { - content: 'http://example.com/image.png', - content_type: 'image_query', - file_info: { - id: 'file-1', - name: 'image.png', - size: 1000, - mime_type: 'image/png', - extension: 'png', - source_url: 'http://example.com/image.png', - }, - }, - ] - - const { container } = renderWithProviders(<QueryInput {...defaultProps} queries={queriesWithImages} />) - expect(container.firstChild).toBeInTheDocument() - }) - - it('should disable button when images are not all uploaded', () => { - const queriesWithUnuploadedImages: Query[] = [ - { - content: 'http://example.com/image.png', - content_type: 'image_query', - file_info: { - id: '', // Empty id means not uploaded - name: 'image.png', - size: 1000, - mime_type: 'image/png', - extension: 'png', - source_url: 'http://example.com/image.png', - }, - }, - ] - - renderWithProviders(<QueryInput {...defaultProps} queries={queriesWithUnuploadedImages} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - expect(submitButton).toBeDisabled() - }) - - it('should enable button when all images are uploaded', () => { - const queriesWithUploadedImages: Query[] = [ - { content: 'Test query', content_type: 'text_query', file_info: null }, - { - content: 'http://example.com/image.png', - content_type: 'image_query', - file_info: { - id: 'uploaded-file-1', - name: 'image.png', - size: 1000, - mime_type: 'image/png', - extension: 'png', - source_url: 'http://example.com/image.png', - }, - }, - ] - - renderWithProviders(<QueryInput {...defaultProps} queries={queriesWithUploadedImages} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - expect(submitButton).not.toBeDisabled() - }) - - it('should call setQueries with image queries when images are added', async () => { - renderWithProviders(<QueryInput {...defaultProps} />) - - // Trigger image change via mock button - fireEvent.click(screen.getByTestId('trigger-image-change')) - - expect(mockSetQueries).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - content_type: 'image_query', - file_info: expect.objectContaining({ - name: 'new-image.png', - mime_type: 'image/png', - }), - }), - ]), - ) - }) - - it('should replace existing image queries when new images are added', async () => { - const existingQueries: Query[] = [ - { content: 'text', content_type: 'text_query', file_info: null }, - { - content: 'old-image', - content_type: 'image_query', - file_info: { - id: 'old-id', - name: 'old.png', - size: 500, - mime_type: 'image/png', - extension: 'png', - source_url: 'http://example.com/old.png', - }, - }, - ] - - renderWithProviders(<QueryInput {...defaultProps} queries={existingQueries} />) - - // Trigger image change - should replace existing images - fireEvent.click(screen.getByTestId('trigger-image-change')) - - expect(mockSetQueries).toHaveBeenCalled() - }) - - it('should handle empty source URL in file', async () => { - // Mock the onChange to return file without sourceUrl - renderWithProviders(<QueryInput {...defaultProps} />) - - // The component should handle files with missing sourceUrl - if (mockImageUploaderOnChange) { - mockImageUploaderOnChange([ - { - sourceUrl: undefined, - uploadedId: 'id-1', - mimeType: 'image/png', - name: 'image.png', - size: 1000, - extension: 'png', - }, - ]) - } - - expect(mockSetQueries).toHaveBeenCalled() - }) - - it('should handle file without uploadedId', async () => { - renderWithProviders(<QueryInput {...defaultProps} />) - - if (mockImageUploaderOnChange) { - mockImageUploaderOnChange([ - { - sourceUrl: 'http://example.com/img.png', - uploadedId: undefined, - mimeType: 'image/png', - name: 'image.png', - size: 1000, - extension: 'png', - }, - ]) - } - - expect(mockSetQueries).toHaveBeenCalled() - }) - }) - - describe('Economy Mode', () => { - it('should use keyword search method when isEconomy is true', async () => { - const queries: Query[] = [{ content: 'Test query', content_type: 'text_query', file_info: null }] - mockHitTestingMutateAsync.mockResolvedValue({ records: [] }) - - renderWithProviders(<QueryInput {...defaultProps} queries={queries} isEconomy={true} />) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(mockHitTestingMutateAsync).toHaveBeenCalledWith( - expect.objectContaining({ - retrieval_model: expect.objectContaining({ - search_method: 'keyword_search', - }), - }), - expect.anything(), - ) - }) - }) - }) - - describe('Text Query Handling', () => { - it('should add new text query when none exists', async () => { - renderWithProviders(<QueryInput {...defaultProps} queries={[]} />) - - const textarea = screen.getByRole('textbox') - fireEvent.change(textarea, { target: { value: 'New query' } }) - - expect(mockSetQueries).toHaveBeenCalledWith([ - expect.objectContaining({ - content: 'New query', - content_type: 'text_query', - }), - ]) - }) - - it('should update existing text query', async () => { - const existingQueries: Query[] = [{ content: 'Old query', content_type: 'text_query', file_info: null }] - renderWithProviders(<QueryInput {...defaultProps} queries={existingQueries} />) - - const textarea = screen.getByRole('textbox') - fireEvent.change(textarea, { target: { value: 'Updated query' } }) - - expect(mockSetQueries).toHaveBeenCalled() - }) - }) - - describe('External Settings Modal', () => { - it('should save external retrieval settings when modal saves', async () => { - renderWithProviders(<QueryInput {...defaultProps} isExternal={true} />) - - // Open settings modal - const buttons = screen.getAllByRole('button') - const settingsButton = buttons.find(btn => !btn.classList.contains('w-[88px]')) - if (settingsButton) - fireEvent.click(settingsButton) - - await waitFor(() => { - // Modal should be open - look for save button in modal - const allButtons = screen.getAllByRole('button') - expect(allButtons.length).toBeGreaterThan(2) - }) - - // Click save in modal - const saveButton = screen.getByText(/save/i) - fireEvent.click(saveButton) - - // Modal should close - await waitFor(() => { - const buttonsAfterClose = screen.getAllByRole('button') - // Should have fewer buttons after modal closes - expect(buttonsAfterClose.length).toBeLessThanOrEqual(screen.getAllByRole('button').length) - }) - }) - - it('should close settings modal when close button is clicked', async () => { - renderWithProviders(<QueryInput {...defaultProps} isExternal={true} />) - - // Open settings modal - const buttons = screen.getAllByRole('button') - const settingsButton = buttons.find(btn => !btn.classList.contains('w-[88px]')) - if (settingsButton) - fireEvent.click(settingsButton) - - await waitFor(() => { - const allButtons = screen.getAllByRole('button') - expect(allButtons.length).toBeGreaterThan(2) - }) - - // Click cancel - const cancelButton = screen.getByText(/cancel/i) - fireEvent.click(cancelButton) - - // Component should still be functional - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// HitTestingPage Component Tests -// ============================================================================ - -describe('HitTestingPage', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - expect(container.firstChild).toBeInTheDocument() - }) - - it('should render page title', () => { - renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - // Look for heading element - const heading = screen.getByRole('heading', { level: 1 }) - expect(heading).toBeInTheDocument() - }) - - it('should render records section', () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - // The records section should be present - expect(container.querySelector('.flex-col')).toBeInTheDocument() - }) - - it('should render query input', () => { - renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - }) - - describe('Loading States', () => { - it('should show loading when records are loading', async () => { - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: undefined, - refetch: mockRecordsRefetch, - isLoading: true, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - // Loading component should be visible - look for the loading animation - const loadingElement = container.querySelector('[class*="animate"]') || container.querySelector('.flex-1') - expect(loadingElement).toBeInTheDocument() - }) - }) - - describe('Empty States', () => { - it('should show empty records when no data', () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - // EmptyRecords component should be rendered - check that the component is mounted - // The EmptyRecords has a specific structure with bg-workflow-process-bg class - const mainContainer = container.querySelector('.flex.h-full') - expect(mainContainer).toBeInTheDocument() - }) - }) - - describe('Records Display', () => { - it('should display records when data is present', async () => { - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { - data: [createMockRecord()], - total: 1, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - expect(screen.getByText('Test query')).toBeInTheDocument() - }) - }) - - describe('Pagination', () => { - it('should show pagination when total exceeds limit', async () => { - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { - data: Array.from({ length: 10 }, (_, i) => createMockRecord({ id: `record-${i}` })), - total: 25, - page: 1, - limit: 10, - has_more: true, - }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - // Pagination should be visible - look for pagination controls - const paginationElement = container.querySelector('[class*="pagination"]') || container.querySelector('nav') - expect(paginationElement || screen.getAllByText('Test query').length > 0).toBeTruthy() - }) - }) - - describe('Right Panel', () => { - it('should render right panel container', () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - // The right panel should be present (on non-mobile) - const rightPanel = container.querySelector('.rounded-tl-2xl') - expect(rightPanel).toBeInTheDocument() - }) - }) - - describe('Retrieval Modal', () => { - it('should open retrieval modal when method is clicked', async () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Find the method selector (cursor-pointer div with the retrieval method) - const methodSelectors = container.querySelectorAll('.cursor-pointer') - const methodSelector = Array.from(methodSelectors).find(el => !el.closest('button') && !el.closest('tr')) - - // Verify we found a method selector to click - expect(methodSelector).toBeTruthy() - - if (methodSelector) - fireEvent.click(methodSelector) - - // The component should still be functional after the click - expect(container.firstChild).toBeInTheDocument() - }) - }) - - describe('Hit Results Display', () => { - it('should display hit results when hitResult has records', async () => { - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { - data: [], - total: 0, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // The right panel should show empty state initially - expect(container.querySelector('.rounded-tl-2xl')).toBeInTheDocument() - }) - - it('should render loading skeleton when retrieval is in progress', async () => { - const { useHitTesting } = await import('@/service/knowledge/use-hit-testing') - vi.mocked(useHitTesting).mockReturnValue({ - mutateAsync: mockHitTestingMutateAsync, - isPending: true, - } as unknown as ReturnType<typeof useHitTesting>) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Component should render without crashing - expect(container.firstChild).toBeInTheDocument() - }) - - it('should render results when hit testing returns data', async () => { - // This test simulates the flow of getting hit results - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { - data: [], - total: 0, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // The component should render the result display area - expect(container.querySelector('.bg-background-body')).toBeInTheDocument() - }) - }) - - describe('Record Interaction', () => { - it('should update queries when a record is clicked', async () => { - const mockRecord = createMockRecord({ - queries: [ - { content: 'Record query text', content_type: 'text_query', file_info: null }, - ], - }) - - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { - data: [mockRecord], - total: 1, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Find and click the record row - const recordText = screen.getByText('Record query text') - const row = recordText.closest('tr') - if (row) - fireEvent.click(row) - - // The query input should be updated - this causes re-render with new key - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - }) - - describe('External Dataset', () => { - it('should render external dataset UI when provider is external', async () => { - // Mock dataset with external provider - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { - data: [], - total: 0, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Component should render - expect(container.firstChild).toBeInTheDocument() - }) - }) - - describe('Mobile View', () => { - it('should handle mobile breakpoint', async () => { - // Mock mobile breakpoint - const useBreakpoints = await import('@/hooks/use-breakpoints') - vi.mocked(useBreakpoints.default).mockReturnValue('mobile' as unknown as ReturnType<typeof useBreakpoints.default>) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Component should still render - expect(container.firstChild).toBeInTheDocument() - }) - }) - - describe('useEffect for mobile panel', () => { - it('should update right panel visibility based on mobile state', async () => { - const useBreakpoints = await import('@/hooks/use-breakpoints') - - // First render with desktop - vi.mocked(useBreakpoints.default).mockReturnValue('pc' as unknown as ReturnType<typeof useBreakpoints.default>) - - const { rerender, container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - expect(container.firstChild).toBeInTheDocument() - - // Re-render with mobile - vi.mocked(useBreakpoints.default).mockReturnValue('mobile' as unknown as ReturnType<typeof useBreakpoints.default>) - - rerender( - <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}> - <HitTestingPage datasetId="dataset-1" /> - </QueryClientProvider>, - ) - - expect(container.firstChild).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// Integration Tests -// ============================================================================ - -describe('Integration: Hit Testing Flow', () => { - beforeEach(() => { - vi.clearAllMocks() - mockHitTestingMutateAsync.mockReset() - mockExternalHitTestingMutateAsync.mockReset() - }) - - it('should complete a full hit testing flow', async () => { - const mockResponse: HitTestingResponse = { - query: { content: 'Test query', tsne_position: { x: 0, y: 0 } }, - records: [createMockHitTesting()], - } - - mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { - options?.onSuccess?.(mockResponse) - return mockResponse - }) - - renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Type query - fireEvent.change(textarea, { target: { value: 'Test query' } }) - - // Find submit button by class - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - expect(submitButton).not.toBeDisabled() - }) - - it('should handle API error gracefully', async () => { - mockHitTestingMutateAsync.mockRejectedValue(new Error('API Error')) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Type query - fireEvent.change(textarea, { target: { value: 'Test query' } }) - - // Component should still be functional - check for the main container - expect(container.firstChild).toBeInTheDocument() - }) - - it('should render hit results after successful submission', async () => { - const mockHitTestingRecord = createMockHitTesting() - const mockResponse: HitTestingResponse = { - query: { content: 'Test query', tsne_position: { x: 0, y: 0 } }, - records: [mockHitTestingRecord], - } - - mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { - // Call onSuccess synchronously to ensure state is updated - if (options?.onSuccess) - options.onSuccess(mockResponse) - return mockResponse - }) - - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { - data: [], - total: 0, - page: 1, - limit: 10, - has_more: false, - }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - const { container: _container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Wait for textbox to be rendered with timeout for CI environment - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Type query - fireEvent.change(textarea, { target: { value: 'Test query' } }) - - // Submit - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - // Wait for the mutation to complete - await waitFor( - () => { - expect(mockHitTestingMutateAsync).toHaveBeenCalled() - }, - { timeout: 3000 }, - ) - }) - - it('should render ResultItem components for non-external results', async () => { - const mockResponse: HitTestingResponse = { - query: { content: 'Test query', tsne_position: { x: 0, y: 0 } }, - records: [ - createMockHitTesting({ score: 0.95 }), - createMockHitTesting({ score: 0.85 }), - ], - } - - mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { - if (options?.onSuccess) - options.onSuccess(mockResponse) - return mockResponse - }) - - const { useDatasetTestingRecords } = await import('@/service/knowledge/use-dataset') - vi.mocked(useDatasetTestingRecords).mockReturnValue({ - data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, - refetch: mockRecordsRefetch, - isLoading: false, - } as unknown as ReturnType<typeof useDatasetTestingRecords>) - - const { container: _container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Wait for component to be fully rendered with longer timeout - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Submit a query - fireEvent.change(textarea, { target: { value: 'Test query' } }) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - // Wait for mutation to complete with longer timeout - await waitFor( - () => { - expect(mockHitTestingMutateAsync).toHaveBeenCalled() - }, - { timeout: 3000 }, - ) - }) - - it('should render external results when dataset is external', async () => { - const mockExternalResponse = { - query: { content: 'test' }, - records: [ - { - title: 'External Result 1', - content: 'External content', - score: 0.9, - metadata: {}, - }, - ], - } - - mockExternalHitTestingMutateAsync.mockImplementation(async (_params, options) => { - if (options?.onSuccess) - options.onSuccess(mockExternalResponse) - return mockExternalResponse - }) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Component should render - expect(container.firstChild).toBeInTheDocument() - - // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Type in textarea to verify component is functional - fireEvent.change(textarea, { target: { value: 'Test query' } }) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - // Verify component is still functional after submission - await waitFor( - () => { - expect(screen.getByRole('textbox')).toBeInTheDocument() - }, - { timeout: 3000 }, - ) - }) -}) - -// ============================================================================ -// Drawer and Modal Interaction Tests -// ============================================================================ - -describe('Drawer and Modal Interactions', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should save retrieval config when ModifyRetrievalModal onSave is called', async () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Find and click the retrieval method selector to open the drawer - const methodSelectors = container.querySelectorAll('.cursor-pointer') - const methodSelector = Array.from(methodSelectors).find( - el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), - ) - - if (methodSelector) { - fireEvent.click(methodSelector) - - await waitFor(() => { - // The drawer should open - verify container is still there - expect(container.firstChild).toBeInTheDocument() - }) - } - - // Component should still be functional - verify main container - expect(container.querySelector('.overflow-y-auto')).toBeInTheDocument() - }) - - it('should close retrieval modal when onHide is called', async () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Open the modal first - const methodSelectors = container.querySelectorAll('.cursor-pointer') - const methodSelector = Array.from(methodSelectors).find( - el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), - ) - - if (methodSelector) { - fireEvent.click(methodSelector) - } - - // Component should still be functional - expect(container.firstChild).toBeInTheDocument() - }) -}) - -// ============================================================================ -// renderHitResults Coverage Tests -// ============================================================================ - -describe('renderHitResults Coverage', () => { - beforeEach(() => { - vi.clearAllMocks() - mockHitTestingMutateAsync.mockReset() - }) - - it('should render hit results panel with records count', async () => { - const mockRecords = [ - createMockHitTesting({ score: 0.95 }), - createMockHitTesting({ score: 0.85 }), - ] - const mockResponse: HitTestingResponse = { - query: { content: 'test', tsne_position: { x: 0, y: 0 } }, - records: mockRecords, - } - - // Make mutation call onSuccess synchronously - mockHitTestingMutateAsync.mockImplementation(async (params, options) => { - // Simulate async behavior - await Promise.resolve() - if (options?.onSuccess) - options.onSuccess(mockResponse) - return mockResponse - }) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Enter query - fireEvent.change(textarea, { target: { value: 'test query' } }) - - // Submit - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - - if (submitButton) - fireEvent.click(submitButton) - - // Verify component is functional - await waitFor(() => { - expect(container.firstChild).toBeInTheDocument() - }) - }) - - it('should iterate through records and render ResultItem for each', async () => { - const mockRecords = [ - createMockHitTesting({ score: 0.9 }), - ] - - mockHitTestingMutateAsync.mockImplementation(async (_params, options) => { - const response = { query: { content: 'test' }, records: mockRecords } - if (options?.onSuccess) - options.onSuccess(response) - return response - }) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - const textarea = screen.getByRole('textbox') - fireEvent.change(textarea, { target: { value: 'test' } }) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - if (submitButton) - fireEvent.click(submitButton) - - await waitFor(() => { - expect(container.firstChild).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// Drawer onSave Coverage Tests -// ============================================================================ - -describe('ModifyRetrievalModal onSave Coverage', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should update retrieval config when onSave is triggered', async () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Open the drawer - const methodSelectors = container.querySelectorAll('.cursor-pointer') - const methodSelector = Array.from(methodSelectors).find( - el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), - ) - - if (methodSelector) { - fireEvent.click(methodSelector) - - // Wait for drawer to open - await waitFor(() => { - expect(container.firstChild).toBeInTheDocument() - }) - } - - // Verify component renders correctly - expect(container.querySelector('.overflow-y-auto')).toBeInTheDocument() - }) - - it('should close modal after saving', async () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Open the drawer - const methodSelectors = container.querySelectorAll('.cursor-pointer') - const methodSelector = Array.from(methodSelectors).find( - el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), - ) - - if (methodSelector) - fireEvent.click(methodSelector) - - // Component should still be rendered - expect(container.firstChild).toBeInTheDocument() - }) -}) - -// ============================================================================ -// Direct Component Coverage Tests -// ============================================================================ - -describe('HitTestingPage Internal Functions Coverage', () => { - beforeEach(() => { - vi.clearAllMocks() - mockHitTestingMutateAsync.mockReset() - mockExternalHitTestingMutateAsync.mockReset() - }) - - it('should trigger renderHitResults when mutation succeeds with records', async () => { - // Create mock hit testing records - const mockHitRecords = [ - createMockHitTesting({ score: 0.95 }), - createMockHitTesting({ score: 0.85 }), - ] - - const mockResponse: HitTestingResponse = { - query: { content: 'test query', tsne_position: { x: 0, y: 0 } }, - records: mockHitRecords, - } - - // Setup mutation to call onSuccess synchronously - mockHitTestingMutateAsync.mockImplementation((_params, options) => { - // Synchronously call onSuccess - if (options?.onSuccess) - options.onSuccess(mockResponse) - return Promise.resolve(mockResponse) - }) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Enter query and submit - fireEvent.change(textarea, { target: { value: 'test query' } }) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - - if (submitButton) { - fireEvent.click(submitButton) - } - - // Wait for state updates - await waitFor(() => { - expect(container.firstChild).toBeInTheDocument() - }, { timeout: 3000 }) - - // Verify mutation was called - expect(mockHitTestingMutateAsync).toHaveBeenCalled() - }) - - it('should handle retrieval config update via ModifyRetrievalModal', async () => { - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Find and click retrieval method to open drawer - const methodSelectors = container.querySelectorAll('.cursor-pointer') - const methodSelector = Array.from(methodSelectors).find( - el => !el.closest('button') && !el.closest('tr') && el.querySelector('.text-xs'), - ) - - if (methodSelector) { - fireEvent.click(methodSelector) - - // Wait for drawer content - await waitFor(() => { - expect(container.firstChild).toBeInTheDocument() - }) - - // Try to find save button in the drawer - const saveButtons = screen.queryAllByText(/save/i) - if (saveButtons.length > 0) { - fireEvent.click(saveButtons[0]) - } - } - - // Component should still work - expect(container.firstChild).toBeInTheDocument() - }) - - it('should show hit count in results panel after successful query', async () => { - const mockRecords = [createMockHitTesting()] - const mockResponse: HitTestingResponse = { - query: { content: 'test', tsne_position: { x: 0, y: 0 } }, - records: mockRecords, - } - - mockHitTestingMutateAsync.mockResolvedValue(mockResponse) - - const { container } = renderWithProviders(<HitTestingPage datasetId="dataset-1" />) - - // Wait for textbox with timeout for CI - const textarea = await waitFor( - () => screen.getByRole('textbox'), - { timeout: 3000 }, - ) - - // Submit a query - fireEvent.change(textarea, { target: { value: 'test' } }) - - const buttons = screen.getAllByRole('button') - const submitButton = buttons.find(btn => btn.classList.contains('w-[88px]')) - - if (submitButton) - fireEvent.click(submitButton) - - // Verify the component renders - await waitFor(() => { - expect(container.firstChild).toBeInTheDocument() - }, { timeout: 3000 }) - }) -}) - -// ============================================================================ -// Memoization Tests -// ============================================================================ - -describe('Memoization', () => { - describe('Score component memoization', () => { - it('should be memoized', () => { - // Score is wrapped in React.memo - const { rerender } = render(<Score value={0.5} />) - - // Rerender with same props should not cause re-render - rerender(<Score value={0.5} />) - - expect(screen.getByText('0.50')).toBeInTheDocument() - }) - }) - - describe('Mask component memoization', () => { - it('should be memoized', () => { - const { rerender, container } = render(<Mask />) - - rerender(<Mask />) - - // Mask should still be rendered - expect(container.querySelector('.bg-gradient-to-b')).toBeInTheDocument() - }) - }) - - describe('EmptyRecords component memoization', () => { - it('should be memoized', () => { - const { rerender } = render(<EmptyRecords />) - - rerender(<EmptyRecords />) - - expect(screen.getByText(/noRecentTip/i)).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// Accessibility Tests -// ============================================================================ - -describe('Accessibility', () => { - describe('Textarea', () => { - it('should have placeholder text', () => { - render(<Textarea text="" handleTextChange={vi.fn()} />) - expect(screen.getByPlaceholderText(/placeholder/i)).toBeInTheDocument() - }) - }) - - describe('Buttons', () => { - it('should have accessible buttons in QueryInput', () => { - render( - <QueryInput - setHitResult={vi.fn()} - setExternalHitResult={vi.fn()} - onUpdateList={vi.fn()} - loading={false} - queries={[]} - setQueries={vi.fn()} - isExternal={false} - onClickRetrievalMethod={vi.fn()} - retrievalConfig={createMockRetrievalConfig()} - isEconomy={false} - hitTestingMutation={vi.fn()} - externalKnowledgeBaseHitTestingMutation={vi.fn()} - />, - ) - expect(screen.getAllByRole('button').length).toBeGreaterThan(0) - }) - }) - - describe('Tables', () => { - it('should render table with proper structure', () => { - render( - <Records - records={[createMockRecord()]} - onClickRecord={vi.fn()} - />, - ) - expect(screen.getByRole('table')).toBeInTheDocument() - }) - }) -}) - -// ============================================================================ -// Edge Cases -// ============================================================================ - -describe('Edge Cases', () => { - describe('Score with edge values', () => { - it('should handle very small scores', () => { - render(<Score value={0.001} />) - expect(screen.getByText('0.00')).toBeInTheDocument() - }) - - it('should handle scores close to 1', () => { - render(<Score value={0.999} />) - expect(screen.getByText('1.00')).toBeInTheDocument() - }) - }) - - describe('Records with various sources', () => { - it('should handle plugin source', () => { - const record = createMockRecord({ source: 'plugin' }) - render(<Records records={[record]} onClickRecord={vi.fn()} />) - expect(screen.getByText('plugin')).toBeInTheDocument() - }) - - it('should handle app source', () => { - const record = createMockRecord({ source: 'app' }) - render(<Records records={[record]} onClickRecord={vi.fn()} />) - expect(screen.getByText('app')).toBeInTheDocument() - }) - }) - - describe('ResultItem with various data', () => { - it('should handle empty keywords', () => { - const payload = createMockHitTesting({ - segment: createMockSegment({ keywords: [] }), - child_chunks: null, - }) - - render(<ResultItem payload={payload} />) - // Should not render keywords section - expect(screen.queryByText('keyword')).not.toBeInTheDocument() - }) - - it('should handle missing sign_content', () => { - const payload = createMockHitTesting({ - segment: createMockSegment({ sign_content: '', content: 'Fallback content' }), - }) - - render(<ResultItem payload={payload} />) - // The document name should still be visible - expect(screen.getByText('test-document.pdf')).toBeInTheDocument() - }) - }) - - describe('Records with images', () => { - it('should handle records with image queries', () => { - const recordWithImages = createMockRecord({ - queries: [ - { content: 'Text query', content_type: 'text_query', file_info: null }, - { - content: 'image-url', - content_type: 'image_query', - file_info: { - id: 'file-1', - name: 'image.png', - size: 1000, - mime_type: 'image/png', - extension: 'png', - source_url: 'http://example.com/image.png', - }, - }, - ], - }) - - render(<Records records={[recordWithImages]} onClickRecord={vi.fn()} />) - expect(screen.getByText('Text query')).toBeInTheDocument() - }) - }) - - describe('ChunkDetailModal with files', () => { - it('should handle payload with image files', () => { - const payload = createMockHitTesting({ - files: [ - { - id: 'file-1', - name: 'image.png', - size: 1000, - mime_type: 'image/png', - extension: 'png', - source_url: 'http://example.com/image.png', - }, - ], - }) - - render(<ChunkDetailModal payload={payload} onHide={vi.fn()} />) - expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument() - }) - }) -}) diff --git a/web/app/components/datasets/hit-testing/utils/__tests__/extension-to-file-type.spec.ts b/web/app/components/datasets/hit-testing/utils/__tests__/extension-to-file-type.spec.ts new file mode 100644 index 0000000000..fa493905a1 --- /dev/null +++ b/web/app/components/datasets/hit-testing/utils/__tests__/extension-to-file-type.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types' +import { extensionToFileType } from '../extension-to-file-type' + +describe('extensionToFileType', () => { + // PDF extension + describe('pdf', () => { + it('should return pdf type when extension is pdf', () => { + expect(extensionToFileType('pdf')).toBe(FileAppearanceTypeEnum.pdf) + }) + }) + + // Word extensions + describe('word', () => { + it('should return word type when extension is doc', () => { + expect(extensionToFileType('doc')).toBe(FileAppearanceTypeEnum.word) + }) + + it('should return word type when extension is docx', () => { + expect(extensionToFileType('docx')).toBe(FileAppearanceTypeEnum.word) + }) + }) + + // Markdown extensions + describe('markdown', () => { + it('should return markdown type when extension is md', () => { + expect(extensionToFileType('md')).toBe(FileAppearanceTypeEnum.markdown) + }) + + it('should return markdown type when extension is mdx', () => { + expect(extensionToFileType('mdx')).toBe(FileAppearanceTypeEnum.markdown) + }) + + it('should return markdown type when extension is markdown', () => { + expect(extensionToFileType('markdown')).toBe(FileAppearanceTypeEnum.markdown) + }) + }) + + // Excel / CSV extensions + describe('excel', () => { + it('should return excel type when extension is csv', () => { + expect(extensionToFileType('csv')).toBe(FileAppearanceTypeEnum.excel) + }) + + it('should return excel type when extension is xls', () => { + expect(extensionToFileType('xls')).toBe(FileAppearanceTypeEnum.excel) + }) + + it('should return excel type when extension is xlsx', () => { + expect(extensionToFileType('xlsx')).toBe(FileAppearanceTypeEnum.excel) + }) + }) + + // Document extensions + describe('document', () => { + it('should return document type when extension is txt', () => { + expect(extensionToFileType('txt')).toBe(FileAppearanceTypeEnum.document) + }) + + it('should return document type when extension is epub', () => { + expect(extensionToFileType('epub')).toBe(FileAppearanceTypeEnum.document) + }) + + it('should return document type when extension is html', () => { + expect(extensionToFileType('html')).toBe(FileAppearanceTypeEnum.document) + }) + + it('should return document type when extension is htm', () => { + expect(extensionToFileType('htm')).toBe(FileAppearanceTypeEnum.document) + }) + + it('should return document type when extension is xml', () => { + expect(extensionToFileType('xml')).toBe(FileAppearanceTypeEnum.document) + }) + }) + + // PPT extensions + describe('ppt', () => { + it('should return ppt type when extension is ppt', () => { + expect(extensionToFileType('ppt')).toBe(FileAppearanceTypeEnum.ppt) + }) + + it('should return ppt type when extension is pptx', () => { + expect(extensionToFileType('pptx')).toBe(FileAppearanceTypeEnum.ppt) + }) + }) + + // Default / unknown extensions + describe('custom (default)', () => { + it('should return custom type when extension is empty string', () => { + expect(extensionToFileType('')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom type when extension is unknown', () => { + expect(extensionToFileType('zip')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom type when extension is uppercase (case-sensitive match)', () => { + expect(extensionToFileType('PDF')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom type when extension is mixed case', () => { + expect(extensionToFileType('Docx')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom type when extension has leading dot', () => { + expect(extensionToFileType('.pdf')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom type when extension has whitespace', () => { + expect(extensionToFileType(' pdf ')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom type for image-like extensions', () => { + expect(extensionToFileType('png')).toBe(FileAppearanceTypeEnum.custom) + expect(extensionToFileType('jpg')).toBe(FileAppearanceTypeEnum.custom) + }) + }) +}) diff --git a/web/app/components/datasets/list/datasets.spec.tsx b/web/app/components/datasets/list/__tests__/datasets.spec.tsx similarity index 97% rename from web/app/components/datasets/list/datasets.spec.tsx rename to web/app/components/datasets/list/__tests__/datasets.spec.tsx index 38843ab2e0..49bda88c8b 100644 --- a/web/app/components/datasets/list/datasets.spec.tsx +++ b/web/app/components/datasets/list/__tests__/datasets.spec.tsx @@ -4,22 +4,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import Datasets from './datasets' +import Datasets from '../datasets' -// Mock next/navigation vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), })) -// Mock ahooks -vi.mock('ahooks', async (importOriginal) => { - const actual = await importOriginal<typeof import('ahooks')>() - return { - ...actual, - useHover: () => false, - } -}) - // Mock useFormatTimeFromNow hook vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ @@ -64,7 +54,7 @@ vi.mock('@/context/app-context', () => ({ })) // Mock useDatasetCardState hook -vi.mock('./dataset-card/hooks/use-dataset-card-state', () => ({ +vi.mock('../dataset-card/hooks/use-dataset-card-state', () => ({ useDatasetCardState: () => ({ tags: [], setTags: vi.fn(), @@ -83,7 +73,7 @@ vi.mock('./dataset-card/hooks/use-dataset-card-state', () => ({ })) // Mock RenameDatasetModal -vi.mock('../rename-modal', () => ({ +vi.mock('../../rename-modal', () => ({ default: () => null, })) diff --git a/web/app/components/datasets/list/index.spec.tsx b/web/app/components/datasets/list/__tests__/index.spec.tsx similarity index 91% rename from web/app/components/datasets/list/index.spec.tsx rename to web/app/components/datasets/list/__tests__/index.spec.tsx index ff48774c87..3e6d696c5b 100644 --- a/web/app/components/datasets/list/index.spec.tsx +++ b/web/app/components/datasets/list/__tests__/index.spec.tsx @@ -1,8 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import List from './index' +import List from '../index' -// Mock next/navigation const mockPush = vi.fn() const mockReplace = vi.fn() vi.mock('next/navigation', () => ({ @@ -12,17 +11,6 @@ vi.mock('next/navigation', () => ({ }), })) -// Mock ahooks -vi.mock('ahooks', async (importOriginal) => { - const actual = await importOriginal<typeof import('ahooks')>() - return { - ...actual, - useBoolean: () => [false, { toggle: vi.fn(), setTrue: vi.fn(), setFalse: vi.fn() }], - useDebounceFn: (fn: () => void) => ({ run: fn }), - useHover: () => false, - } -}) - // Mock app context vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ @@ -74,7 +62,6 @@ vi.mock('@/hooks/use-knowledge', () => ({ }), })) -// Mock service hooks vi.mock('@/service/knowledge/use-dataset', () => ({ useDatasetList: vi.fn(() => ({ data: { pages: [{ data: [] }] }, @@ -90,7 +77,7 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ })) // Mock Datasets component -vi.mock('./datasets', () => ({ +vi.mock('../datasets', () => ({ default: ({ tags, keywords, includeAll }: { tags: string[], keywords: string, includeAll: boolean }) => ( <div data-testid="datasets-component"> <span data-testid="tags">{tags.join(',')}</span> @@ -101,12 +88,12 @@ vi.mock('./datasets', () => ({ })) // Mock DatasetFooter component -vi.mock('./dataset-footer', () => ({ +vi.mock('../dataset-footer', () => ({ default: () => <footer data-testid="dataset-footer">Footer</footer>, })) // Mock ExternalAPIPanel component -vi.mock('../external-api/external-api-panel', () => ({ +vi.mock('../../external-api/external-api-panel', () => ({ default: ({ onClose }: { onClose: () => void }) => ( <div data-testid="external-api-panel"> <button onClick={onClose}>Close Panel</button> @@ -257,7 +244,7 @@ describe('List', () => { // Clear module cache and re-import vi.resetModules() - const { default: ListComponent } = await import('./index') + const { default: ListComponent } = await import('../index') render(<ListComponent />) @@ -292,7 +279,7 @@ describe('List', () => { })) vi.resetModules() - const { default: ListComponent } = await import('./index') + const { default: ListComponent } = await import('../index') render(<ListComponent />) @@ -308,7 +295,7 @@ describe('List', () => { })) vi.resetModules() - const { default: ListComponent } = await import('./index') + const { default: ListComponent } = await import('../index') render(<ListComponent />) @@ -324,7 +311,7 @@ describe('List', () => { })) vi.resetModules() - const { default: ListComponent } = await import('./index') + const { default: ListComponent } = await import('../index') render(<ListComponent />) @@ -341,7 +328,7 @@ describe('List', () => { })) vi.resetModules() - const { default: ListComponent } = await import('./index') + const { default: ListComponent } = await import('../index') render(<ListComponent />) @@ -358,7 +345,7 @@ describe('List', () => { })) vi.resetModules() - const { default: ListComponent } = await import('./index') + const { default: ListComponent } = await import('../index') render(<ListComponent />) diff --git a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx new file mode 100644 index 0000000000..ebe80e4686 --- /dev/null +++ b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx @@ -0,0 +1,422 @@ +import type { DataSet } from '@/models/datasets' +import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { IndexingType } from '@/app/components/datasets/create/step-two' +import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' +import DatasetCardFooter from '../components/dataset-card-footer' +import Description from '../components/description' +import DatasetCard from '../index' +import OperationItem from '../operation-item' +import Operations from '../operations' + +// Mock external hooks only +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: (timestamp: number) => { + const date = new Date(timestamp) + return `${date.toLocaleDateString()}` + }, + }), +})) + +const mockPush = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('@/context/app-context', () => ({ + useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => boolean) => selector({ isCurrentWorkspaceDatasetOperator: false }), +})) + +vi.mock('../hooks/use-dataset-card-state', () => ({ + useDatasetCardState: () => ({ + tags: [], + setTags: vi.fn(), + modalState: { + showRenameModal: false, + showConfirmDelete: false, + confirmMessage: '', + }, + openRenameModal: vi.fn(), + closeRenameModal: vi.fn(), + closeConfirmDelete: vi.fn(), + handleExportPipeline: vi.fn(), + detectIsUsedByApp: vi.fn(), + onConfirmDelete: vi.fn(), + }), +})) + +vi.mock('../components/corner-labels', () => ({ + default: () => <div data-testid="corner-labels" />, +})) +vi.mock('../components/dataset-card-header', () => ({ + default: ({ dataset }: { dataset: DataSet }) => <div data-testid="card-header">{dataset.name}</div>, +})) +vi.mock('../components/dataset-card-modals', () => ({ + default: () => <div data-testid="card-modals" />, +})) +vi.mock('../components/tag-area', () => ({ + default: React.forwardRef<HTMLDivElement, { onClick: (e: React.MouseEvent) => void }>(({ onClick }, ref) => ( + <div ref={ref} data-testid="tag-area" onClick={onClick} /> + )), +})) +vi.mock('../components/operations-popover', () => ({ + default: () => <div data-testid="operations-popover" />, +})) + +// Factory function for DataSet mock data +const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ + id: 'dataset-1', + name: 'Test Dataset', + description: 'Test description', + provider: 'vendor', + permission: DatasetPermission.allTeamMembers, + data_source_type: DataSourceType.FILE, + indexing_technique: IndexingType.QUALIFIED, + embedding_available: true, + app_count: 5, + document_count: 10, + word_count: 1000, + created_at: 1609459200, + updated_at: 1609545600, + tags: [], + embedding_model: 'text-embedding-ada-002', + embedding_model_provider: 'openai', + created_by: 'user-1', + doc_form: ChunkingMode.text, + total_available_documents: 10, + runtime_mode: 'general', + ...overrides, +} as DataSet) + +describe('DatasetCard Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Integration tests for Description component + describe('Description', () => { + describe('Rendering', () => { + it('should render description text from dataset', () => { + const dataset = createMockDataset({ description: 'My knowledge base' }) + render(<Description dataset={dataset} />) + expect(screen.getByText('My knowledge base')).toBeInTheDocument() + }) + + it('should set title attribute to description', () => { + const dataset = createMockDataset({ description: 'Hover text' }) + render(<Description dataset={dataset} />) + expect(screen.getByTitle('Hover text')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply opacity-30 when embedding_available is false', () => { + const dataset = createMockDataset({ embedding_available: false }) + render(<Description dataset={dataset} />) + const descDiv = screen.getByTitle(dataset.description) + expect(descDiv).toHaveClass('opacity-30') + }) + + it('should not apply opacity-30 when embedding_available is true', () => { + const dataset = createMockDataset({ embedding_available: true }) + render(<Description dataset={dataset} />) + const descDiv = screen.getByTitle(dataset.description) + expect(descDiv).not.toHaveClass('opacity-30') + }) + }) + + describe('Edge Cases', () => { + it('should handle empty description', () => { + const dataset = createMockDataset({ description: '' }) + render(<Description dataset={dataset} />) + const descDiv = screen.getByTitle('') + expect(descDiv).toBeInTheDocument() + expect(descDiv).toHaveTextContent('') + }) + + it('should handle long description', () => { + const longDesc = 'X'.repeat(500) + const dataset = createMockDataset({ description: longDesc }) + render(<Description dataset={dataset} />) + expect(screen.getByText(longDesc)).toBeInTheDocument() + }) + }) + }) + + // Integration tests for DatasetCardFooter component + describe('DatasetCardFooter', () => { + describe('Rendering', () => { + it('should render document count', () => { + const dataset = createMockDataset({ document_count: 15, total_available_documents: 15 }) + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.getByText('15')).toBeInTheDocument() + }) + + it('should render app count for non-external provider', () => { + const dataset = createMockDataset({ app_count: 7, provider: 'vendor' }) + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.getByText('7')).toBeInTheDocument() + }) + + it('should render update time', () => { + const dataset = createMockDataset() + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.getByText(/updated/i)).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should show partial count when total_available_documents < document_count', () => { + const dataset = createMockDataset({ + document_count: 20, + total_available_documents: 12, + }) + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.getByText('12 / 20')).toBeInTheDocument() + }) + + it('should show single count when all documents are available', () => { + const dataset = createMockDataset({ + document_count: 20, + total_available_documents: 20, + }) + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.getByText('20')).toBeInTheDocument() + }) + + it('should not show app count when provider is external', () => { + const dataset = createMockDataset({ provider: 'external', app_count: 99 }) + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.queryByText('99')).not.toBeInTheDocument() + }) + + it('should have opacity when embedding_available is false', () => { + const dataset = createMockDataset({ embedding_available: false }) + const { container } = render(<DatasetCardFooter dataset={dataset} />) + const footer = container.firstChild as HTMLElement + expect(footer).toHaveClass('opacity-30') + }) + }) + + describe('Edge Cases', () => { + it('should handle undefined total_available_documents', () => { + const dataset = createMockDataset({ + document_count: 10, + total_available_documents: undefined, + }) + render(<DatasetCardFooter dataset={dataset} />) + // total_available_documents defaults to 0, which is < 10 + expect(screen.getByText('0 / 10')).toBeInTheDocument() + }) + + it('should handle zero document count', () => { + const dataset = createMockDataset({ + document_count: 0, + total_available_documents: 0, + }) + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.getByText('0')).toBeInTheDocument() + }) + + it('should handle large numbers', () => { + const dataset = createMockDataset({ + document_count: 100000, + total_available_documents: 100000, + app_count: 50000, + }) + render(<DatasetCardFooter dataset={dataset} />) + expect(screen.getByText('100000')).toBeInTheDocument() + expect(screen.getByText('50000')).toBeInTheDocument() + }) + }) + }) + + // Integration tests for OperationItem component + describe('OperationItem', () => { + const MockIcon = ({ className }: { className?: string }) => ( + <svg data-testid="mock-icon" className={className} /> + ) + + describe('Rendering', () => { + it('should render icon and name', () => { + render(<OperationItem Icon={MockIcon as never} name="Edit" />) + expect(screen.getByText('Edit')).toBeInTheDocument() + expect(screen.getByTestId('mock-icon')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call handleClick when clicked', () => { + const handleClick = vi.fn() + render(<OperationItem Icon={MockIcon as never} name="Delete" handleClick={handleClick} />) + + const item = screen.getByText('Delete').closest('div') + fireEvent.click(item!) + + expect(handleClick).toHaveBeenCalledTimes(1) + }) + + it('should prevent default and stop propagation on click', () => { + const handleClick = vi.fn() + render(<OperationItem Icon={MockIcon as never} name="Action" handleClick={handleClick} />) + + const item = screen.getByText('Action').closest('div') + const event = new MouseEvent('click', { bubbles: true, cancelable: true }) + const preventDefaultSpy = vi.spyOn(event, 'preventDefault') + const stopPropagationSpy = vi.spyOn(event, 'stopPropagation') + + item!.dispatchEvent(event) + + expect(preventDefaultSpy).toHaveBeenCalled() + expect(stopPropagationSpy).toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should not throw when handleClick is undefined', () => { + render(<OperationItem Icon={MockIcon as never} name="No handler" />) + const item = screen.getByText('No handler').closest('div') + expect(() => { + fireEvent.click(item!) + }).not.toThrow() + }) + + it('should handle empty name', () => { + render(<OperationItem Icon={MockIcon as never} name="" />) + expect(screen.getByTestId('mock-icon')).toBeInTheDocument() + }) + }) + }) + + // Integration tests for Operations component + describe('Operations', () => { + const defaultProps = { + showDelete: true, + showExportPipeline: true, + openRenameModal: vi.fn(), + handleExportPipeline: vi.fn(), + detectIsUsedByApp: vi.fn(), + } + + describe('Rendering', () => { + it('should always render edit operation', () => { + render(<Operations {...defaultProps} />) + expect(screen.getByText(/operation\.edit/)).toBeInTheDocument() + }) + + it('should render export pipeline when showExportPipeline is true', () => { + render(<Operations {...defaultProps} showExportPipeline={true} />) + expect(screen.getByText(/exportPipeline/)).toBeInTheDocument() + }) + + it('should not render export pipeline when showExportPipeline is false', () => { + render(<Operations {...defaultProps} showExportPipeline={false} />) + expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument() + }) + + it('should render delete when showDelete is true', () => { + render(<Operations {...defaultProps} showDelete={true} />) + expect(screen.getByText(/operation\.delete/)).toBeInTheDocument() + }) + + it('should not render delete when showDelete is false', () => { + render(<Operations {...defaultProps} showDelete={false} />) + expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call openRenameModal when edit is clicked', () => { + const openRenameModal = vi.fn() + render(<Operations {...defaultProps} openRenameModal={openRenameModal} />) + + const editItem = screen.getByText(/operation\.edit/).closest('div') + fireEvent.click(editItem!) + + expect(openRenameModal).toHaveBeenCalledTimes(1) + }) + + it('should call handleExportPipeline when export is clicked', () => { + const handleExportPipeline = vi.fn() + render(<Operations {...defaultProps} handleExportPipeline={handleExportPipeline} />) + + const exportItem = screen.getByText(/exportPipeline/).closest('div') + fireEvent.click(exportItem!) + + expect(handleExportPipeline).toHaveBeenCalledTimes(1) + }) + + it('should call detectIsUsedByApp when delete is clicked', () => { + const detectIsUsedByApp = vi.fn() + render(<Operations {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />) + + const deleteItem = screen.getByText(/operation\.delete/).closest('div') + fireEvent.click(deleteItem!) + + expect(detectIsUsedByApp).toHaveBeenCalledTimes(1) + }) + }) + + describe('Edge Cases', () => { + it('should render only edit when both showDelete and showExportPipeline are false', () => { + render(<Operations {...defaultProps} showDelete={false} showExportPipeline={false} />) + expect(screen.getByText(/operation\.edit/)).toBeInTheDocument() + expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument() + expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument() + }) + + it('should render divider before delete section when showDelete is true', () => { + const { container } = render(<Operations {...defaultProps} showDelete={true} />) + expect(container.querySelector('.bg-divider-subtle')).toBeInTheDocument() + }) + + it('should not render divider when showDelete is false', () => { + const { container } = render(<Operations {...defaultProps} showDelete={false} />) + expect(container.querySelector('.bg-divider-subtle')).toBeNull() + }) + }) + }) +}) + +describe('DatasetCard Component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render and navigate to documents when clicked', () => { + const dataset = createMockDataset() + render(<DatasetCard dataset={dataset} />) + + fireEvent.click(screen.getByText('Test Dataset')) + expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents') + }) + + it('should navigate to hitTesting for external provider', () => { + const dataset = createMockDataset({ provider: 'external' }) + render(<DatasetCard dataset={dataset} />) + + fireEvent.click(screen.getByText('Test Dataset')) + expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/hitTesting') + }) + + it('should navigate to pipeline for unpublished pipeline', () => { + const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: false }) + render(<DatasetCard dataset={dataset} />) + + fireEvent.click(screen.getByText('Test Dataset')) + expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/pipeline') + }) + + it('should stop propagation when tag area is clicked', () => { + const dataset = createMockDataset() + render(<DatasetCard dataset={dataset} />) + + const tagArea = screen.getByTestId('tag-area') + fireEvent.click(tagArea) + // Tag area click should not trigger card navigation + expect(mockPush).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/datasets/list/dataset-card/operation-item.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/operation-item.spec.tsx similarity index 98% rename from web/app/components/datasets/list/dataset-card/operation-item.spec.tsx rename to web/app/components/datasets/list/dataset-card/__tests__/operation-item.spec.tsx index 848d83c416..335f193146 100644 --- a/web/app/components/datasets/list/dataset-card/operation-item.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/operation-item.spec.tsx @@ -1,7 +1,7 @@ import { RiEditLine } from '@remixicon/react' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import OperationItem from './operation-item' +import OperationItem from '../operation-item' describe('OperationItem', () => { const defaultProps = { diff --git a/web/app/components/datasets/list/dataset-card/operations.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx similarity index 99% rename from web/app/components/datasets/list/dataset-card/operations.spec.tsx rename to web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx index 66a799baa5..edb54cba0c 100644 --- a/web/app/components/datasets/list/dataset-card/operations.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/operations.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import Operations from './operations' +import Operations from '../operations' describe('Operations', () => { const defaultProps = { diff --git a/web/app/components/datasets/list/dataset-card/components/corner-labels.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/corner-labels.spec.tsx similarity index 99% rename from web/app/components/datasets/list/dataset-card/components/corner-labels.spec.tsx rename to web/app/components/datasets/list/dataset-card/components/__tests__/corner-labels.spec.tsx index 00ee1f85f9..7fff6f4dc1 100644 --- a/web/app/components/datasets/list/dataset-card/components/corner-labels.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/corner-labels.spec.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import CornerLabels from './corner-labels' +import CornerLabels from '../corner-labels' describe('CornerLabels', () => { const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-footer.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-footer.spec.tsx similarity index 99% rename from web/app/components/datasets/list/dataset-card/components/dataset-card-footer.spec.tsx rename to web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-footer.spec.tsx index 6ca0363097..e170de2340 100644 --- a/web/app/components/datasets/list/dataset-card/components/dataset-card-footer.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-footer.spec.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import DatasetCardFooter from './dataset-card-footer' +import DatasetCardFooter from '../dataset-card-footer' // Mock the useFormatTimeFromNow hook vi.mock('@/hooks/use-format-time-from-now', () => ({ diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-header.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-header.spec.tsx similarity index 99% rename from web/app/components/datasets/list/dataset-card/components/dataset-card-header.spec.tsx rename to web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-header.spec.tsx index c7121287b3..7d1a239f43 100644 --- a/web/app/components/datasets/list/dataset-card/components/dataset-card-header.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-header.spec.tsx @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import DatasetCardHeader from './dataset-card-header' +import DatasetCardHeader from '../dataset-card-header' // Mock AppIcon component to avoid emoji-mart initialization issues vi.mock('@/app/components/base/app-icon', () => ({ diff --git a/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx similarity index 98% rename from web/app/components/datasets/list/dataset-card/components/dataset-card-modals.spec.tsx rename to web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx index 607830661d..e3e4a70936 100644 --- a/web/app/components/datasets/list/dataset-card/components/dataset-card-modals.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/dataset-card-modals.spec.tsx @@ -3,10 +3,10 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import DatasetCardModals from './dataset-card-modals' +import DatasetCardModals from '../dataset-card-modals' // Mock RenameDatasetModal since it's from a different feature folder -vi.mock('../../../rename-modal', () => ({ +vi.mock('../../../../rename-modal', () => ({ default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess?: () => void }) => ( show ? ( diff --git a/web/app/components/datasets/list/dataset-card/components/description.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/description.spec.tsx similarity index 99% rename from web/app/components/datasets/list/dataset-card/components/description.spec.tsx rename to web/app/components/datasets/list/dataset-card/components/__tests__/description.spec.tsx index e7599f31e3..1a4d6c57cc 100644 --- a/web/app/components/datasets/list/dataset-card/components/description.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/description.spec.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import Description from './description' +import Description from '../description' describe('Description', () => { const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ diff --git a/web/app/components/datasets/list/dataset-card/components/operations-popover.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-popover.spec.tsx similarity index 97% rename from web/app/components/datasets/list/dataset-card/components/operations-popover.spec.tsx rename to web/app/components/datasets/list/dataset-card/components/__tests__/operations-popover.spec.tsx index bf8daae0c3..d79bf1aaa8 100644 --- a/web/app/components/datasets/list/dataset-card/components/operations-popover.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-popover.spec.tsx @@ -3,7 +3,7 @@ import { fireEvent, render } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import OperationsPopover from './operations-popover' +import OperationsPopover from '../operations-popover' describe('OperationsPopover', () => { const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ @@ -63,7 +63,6 @@ describe('OperationsPopover', () => { it('should show delete option when not workspace dataset operator', () => { render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={false} />) - // Click to open popover const triggerButton = document.querySelector('[class*="cursor-pointer"]') if (triggerButton) fireEvent.click(triggerButton) @@ -75,7 +74,6 @@ describe('OperationsPopover', () => { it('should hide delete option when is workspace dataset operator', () => { render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={true} />) - // Click to open popover const triggerButton = document.querySelector('[class*="cursor-pointer"]') if (triggerButton) fireEvent.click(triggerButton) @@ -87,7 +85,6 @@ describe('OperationsPopover', () => { const dataset = createMockDataset({ runtime_mode: 'rag_pipeline' }) render(<OperationsPopover {...defaultProps} dataset={dataset} />) - // Click to open popover const triggerButton = document.querySelector('[class*="cursor-pointer"]') if (triggerButton) fireEvent.click(triggerButton) @@ -99,7 +96,6 @@ describe('OperationsPopover', () => { const dataset = createMockDataset({ runtime_mode: 'general' }) render(<OperationsPopover {...defaultProps} dataset={dataset} />) - // Click to open popover const triggerButton = document.querySelector('[class*="cursor-pointer"]') if (triggerButton) fireEvent.click(triggerButton) diff --git a/web/app/components/datasets/list/dataset-card/components/tag-area.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/tag-area.spec.tsx similarity index 99% rename from web/app/components/datasets/list/dataset-card/components/tag-area.spec.tsx rename to web/app/components/datasets/list/dataset-card/components/__tests__/tag-area.spec.tsx index d55628bd6c..2858469cdb 100644 --- a/web/app/components/datasets/list/dataset-card/components/tag-area.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/tag-area.spec.tsx @@ -5,7 +5,7 @@ import { useRef } from 'react' import { describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import TagArea from './tag-area' +import TagArea from '../tag-area' // Mock TagSelector as it's a complex component from base vi.mock('@/app/components/base/tag-management/selector', () => ({ diff --git a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.spec.ts b/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts similarity index 99% rename from web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.spec.ts rename to web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts index 6eda57ae5b..63ac30630e 100644 --- a/web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.spec.ts +++ b/web/app/components/datasets/list/dataset-card/hooks/__tests__/use-dataset-card-state.spec.ts @@ -3,16 +3,14 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import { useDatasetCardState } from './use-dataset-card-state' +import { useDatasetCardState } from '../use-dataset-card-state' -// Mock Toast vi.mock('@/app/components/base/toast', () => ({ default: { notify: vi.fn(), }, })) -// Mock service hooks const mockCheckUsage = vi.fn() const mockDeleteDataset = vi.fn() const mockExportPipeline = vi.fn() diff --git a/web/app/components/datasets/list/dataset-card/index.spec.tsx b/web/app/components/datasets/list/dataset-card/index.spec.tsx deleted file mode 100644 index dd27eaa262..0000000000 --- a/web/app/components/datasets/list/dataset-card/index.spec.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import type { DataSet } from '@/models/datasets' -import { fireEvent, render, screen } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { IndexingType } from '@/app/components/datasets/create/step-two' -import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import { RETRIEVE_METHOD } from '@/types/app' -import DatasetCard from './index' - -// Mock next/navigation -const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: mockPush }), -})) - -// Mock ahooks useHover -vi.mock('ahooks', async (importOriginal) => { - const actual = await importOriginal<typeof import('ahooks')>() - return { - ...actual, - useHover: () => false, - } -}) - -// Mock app context -vi.mock('@/context/app-context', () => ({ - useSelector: () => false, -})) - -// Mock the useDatasetCardState hook -vi.mock('./hooks/use-dataset-card-state', () => ({ - useDatasetCardState: () => ({ - tags: [], - setTags: vi.fn(), - modalState: { - showRenameModal: false, - showConfirmDelete: false, - confirmMessage: '', - }, - openRenameModal: vi.fn(), - closeRenameModal: vi.fn(), - closeConfirmDelete: vi.fn(), - handleExportPipeline: vi.fn(), - detectIsUsedByApp: vi.fn(), - onConfirmDelete: vi.fn(), - }), -})) - -// Mock the RenameDatasetModal -vi.mock('../../rename-modal', () => ({ - default: () => null, -})) - -// Mock useFormatTimeFromNow hook -vi.mock('@/hooks/use-format-time-from-now', () => ({ - useFormatTimeFromNow: () => ({ - formatTimeFromNow: (timestamp: number) => { - const date = new Date(timestamp) - return date.toLocaleDateString() - }, - }), -})) - -// Mock useKnowledge hook -vi.mock('@/hooks/use-knowledge', () => ({ - useKnowledge: () => ({ - formatIndexingTechniqueAndMethod: () => 'High Quality', - }), -})) - -describe('DatasetCard', () => { - const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({ - id: 'dataset-1', - name: 'Test Dataset', - description: 'Test description', - provider: 'vendor', - permission: DatasetPermission.allTeamMembers, - data_source_type: DataSourceType.FILE, - indexing_technique: IndexingType.QUALIFIED, - embedding_available: true, - app_count: 5, - document_count: 10, - word_count: 1000, - created_at: 1609459200, - updated_at: 1609545600, - tags: [], - embedding_model: 'text-embedding-ada-002', - embedding_model_provider: 'openai', - created_by: 'user-1', - doc_form: ChunkingMode.text, - runtime_mode: 'general', - is_published: true, - total_available_documents: 10, - icon_info: { - icon: '📙', - icon_type: 'emoji' as const, - icon_background: '#FFF4ED', - icon_url: '', - }, - retrieval_model_dict: { - search_method: RETRIEVE_METHOD.semantic, - }, - author_name: 'Test User', - ...overrides, - } as DataSet) - - const defaultProps = { - dataset: createMockDataset(), - onSuccess: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('Rendering', () => { - it('should render without crashing', () => { - render(<DatasetCard {...defaultProps} />) - expect(screen.getByText('Test Dataset')).toBeInTheDocument() - }) - - it('should render dataset name', () => { - const dataset = createMockDataset({ name: 'Custom Dataset Name' }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - expect(screen.getByText('Custom Dataset Name')).toBeInTheDocument() - }) - - it('should render dataset description', () => { - const dataset = createMockDataset({ description: 'Custom Description' }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - expect(screen.getByText('Custom Description')).toBeInTheDocument() - }) - - it('should render document count', () => { - render(<DatasetCard {...defaultProps} />) - expect(screen.getByText('10')).toBeInTheDocument() - }) - - it('should render app count', () => { - render(<DatasetCard {...defaultProps} />) - expect(screen.getByText('5')).toBeInTheDocument() - }) - }) - - describe('Props', () => { - it('should handle external provider', () => { - const dataset = createMockDataset({ provider: 'external' }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - expect(screen.getByText('Test Dataset')).toBeInTheDocument() - }) - - it('should handle rag_pipeline runtime mode', () => { - const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: true }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - expect(screen.getByText('Test Dataset')).toBeInTheDocument() - }) - }) - - describe('User Interactions', () => { - it('should navigate to documents page on click for regular dataset', () => { - const dataset = createMockDataset({ provider: 'vendor' }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - - const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]') - fireEvent.click(card!) - - expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents') - }) - - it('should navigate to hitTesting page on click for external provider', () => { - const dataset = createMockDataset({ provider: 'external' }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - - const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]') - fireEvent.click(card!) - - expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/hitTesting') - }) - - it('should navigate to pipeline page when pipeline is unpublished', () => { - const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: false }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - - const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]') - fireEvent.click(card!) - - expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/pipeline') - }) - }) - - describe('Styles', () => { - it('should have correct card styling', () => { - render(<DatasetCard {...defaultProps} />) - const card = screen.getByText('Test Dataset').closest('.group') - expect(card).toHaveClass('h-[190px]', 'cursor-pointer', 'flex-col', 'rounded-xl') - }) - - it('should have data-disable-nprogress attribute', () => { - render(<DatasetCard {...defaultProps} />) - const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]') - expect(card).toHaveAttribute('data-disable-nprogress', 'true') - }) - }) - - describe('Edge Cases', () => { - it('should handle dataset without description', () => { - const dataset = createMockDataset({ description: '' }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - expect(screen.getByText('Test Dataset')).toBeInTheDocument() - }) - - it('should handle embedding not available', () => { - const dataset = createMockDataset({ embedding_available: false }) - render(<DatasetCard {...defaultProps} dataset={dataset} />) - expect(screen.getByText('Test Dataset')).toBeInTheDocument() - }) - - it('should handle undefined onSuccess', () => { - render(<DatasetCard dataset={createMockDataset()} />) - expect(screen.getByText('Test Dataset')).toBeInTheDocument() - }) - }) - - describe('Tag Area Click', () => { - it('should stop propagation and prevent default when tag area is clicked', () => { - render(<DatasetCard {...defaultProps} />) - - // Find tag area element (it's inside the card) - const tagAreaWrapper = document.querySelector('[class*="px-3"]') - if (tagAreaWrapper) { - const stopPropagationSpy = vi.fn() - const preventDefaultSpy = vi.fn() - - const clickEvent = new MouseEvent('click', { bubbles: true }) - Object.defineProperty(clickEvent, 'stopPropagation', { value: stopPropagationSpy }) - Object.defineProperty(clickEvent, 'preventDefault', { value: preventDefaultSpy }) - - tagAreaWrapper.dispatchEvent(clickEvent) - - expect(stopPropagationSpy).toHaveBeenCalled() - expect(preventDefaultSpy).toHaveBeenCalled() - } - }) - - it('should not navigate when clicking on tag area', () => { - render(<DatasetCard {...defaultProps} />) - - // Click on tag area should not trigger card navigation - const tagArea = document.querySelector('[class*="px-3"]') - if (tagArea) { - fireEvent.click(tagArea) - // mockPush should NOT be called when clicking tag area - // (stopPropagation prevents it from reaching the card click handler) - } - }) - }) -}) diff --git a/web/app/components/datasets/list/dataset-footer/index.spec.tsx b/web/app/components/datasets/list/dataset-footer/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/datasets/list/dataset-footer/index.spec.tsx rename to web/app/components/datasets/list/dataset-footer/__tests__/index.spec.tsx index 1bea093c91..f95eb4c6b6 100644 --- a/web/app/components/datasets/list/dataset-footer/index.spec.tsx +++ b/web/app/components/datasets/list/dataset-footer/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import DatasetFooter from './index' +import DatasetFooter from '../index' describe('DatasetFooter', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/list/new-dataset-card/__tests__/index.spec.tsx b/web/app/components/datasets/list/new-dataset-card/__tests__/index.spec.tsx new file mode 100644 index 0000000000..133e97871f --- /dev/null +++ b/web/app/components/datasets/list/new-dataset-card/__tests__/index.spec.tsx @@ -0,0 +1,134 @@ +import { RiAddLine } from '@remixicon/react' +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import CreateAppCard from '../index' +import Option from '../option' + +describe('New Dataset Card Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Integration tests for Option component + describe('Option', () => { + describe('Rendering', () => { + it('should render a link with text and icon', () => { + render(<Option Icon={RiAddLine} text="Create" href="/create" />) + const link = screen.getByRole('link') + expect(link).toBeInTheDocument() + expect(screen.getByText('Create')).toBeInTheDocument() + }) + + it('should render icon with correct sizing class', () => { + const { container } = render(<Option Icon={RiAddLine} text="Test" href="/test" />) + const icon = container.querySelector('.h-4.w-4') + expect(icon).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should set correct href on the link', () => { + render(<Option Icon={RiAddLine} text="Go" href="/datasets/create" />) + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', '/datasets/create') + }) + + it('should render different text based on props', () => { + render(<Option Icon={RiAddLine} text="Custom Text" href="/path" />) + expect(screen.getByText('Custom Text')).toBeInTheDocument() + }) + + it('should render different href based on props', () => { + render(<Option Icon={RiAddLine} text="Link" href="/custom-path" />) + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', '/custom-path') + }) + }) + + describe('Styles', () => { + it('should have correct link styling', () => { + render(<Option Icon={RiAddLine} text="Styled" href="/style" />) + const link = screen.getByRole('link') + expect(link).toHaveClass('flex', 'w-full', 'items-center', 'gap-x-2', 'rounded-lg') + }) + + it('should have text span with correct styling', () => { + render(<Option Icon={RiAddLine} text="Text Style" href="/s" />) + const textSpan = screen.getByText('Text Style') + expect(textSpan).toHaveClass('system-sm-medium', 'grow', 'text-left') + }) + }) + + describe('Edge Cases', () => { + it('should handle empty text', () => { + render(<Option Icon={RiAddLine} text="" href="/empty" />) + const link = screen.getByRole('link') + expect(link).toBeInTheDocument() + }) + + it('should handle long text', () => { + const longText = 'Z'.repeat(200) + render(<Option Icon={RiAddLine} text={longText} href="/long" />) + expect(screen.getByText(longText)).toBeInTheDocument() + }) + }) + }) + + // Integration tests for CreateAppCard component + describe('CreateAppCard', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + render(<CreateAppCard />) + // All 3 options should be visible + const links = screen.getAllByRole('link') + expect(links).toHaveLength(3) + }) + + it('should render the create dataset option', () => { + render(<CreateAppCard />) + expect(screen.getByText(/createDataset/)).toBeInTheDocument() + }) + + it('should render the create from pipeline option', () => { + render(<CreateAppCard />) + expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument() + }) + + it('should render the connect dataset option', () => { + render(<CreateAppCard />) + expect(screen.getByText(/connectDataset/)).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should have correct href for create dataset', () => { + render(<CreateAppCard />) + const links = screen.getAllByRole('link') + const createLink = links.find(link => link.getAttribute('href') === '/datasets/create') + expect(createLink).toBeDefined() + }) + + it('should have correct href for create from pipeline', () => { + render(<CreateAppCard />) + const links = screen.getAllByRole('link') + const pipelineLink = links.find(link => link.getAttribute('href') === '/datasets/create-from-pipeline') + expect(pipelineLink).toBeDefined() + }) + + it('should have correct href for connect dataset', () => { + render(<CreateAppCard />) + const links = screen.getAllByRole('link') + const connectLink = links.find(link => link.getAttribute('href') === '/datasets/connect') + expect(connectLink).toBeDefined() + }) + }) + + describe('Styles', () => { + it('should have correct container styling', () => { + const { container } = render(<CreateAppCard />) + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex', 'flex-col', 'rounded-xl') + }) + }) + }) +}) diff --git a/web/app/components/datasets/list/new-dataset-card/option.spec.tsx b/web/app/components/datasets/list/new-dataset-card/__tests__/option.spec.tsx similarity index 98% rename from web/app/components/datasets/list/new-dataset-card/option.spec.tsx rename to web/app/components/datasets/list/new-dataset-card/__tests__/option.spec.tsx index 0aefaa261e..3f0d1f952c 100644 --- a/web/app/components/datasets/list/new-dataset-card/option.spec.tsx +++ b/web/app/components/datasets/list/new-dataset-card/__tests__/option.spec.tsx @@ -1,7 +1,7 @@ import { RiAddLine } from '@remixicon/react' import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import Option from './option' +import Option from '../option' describe('Option', () => { const defaultProps = { diff --git a/web/app/components/datasets/list/new-dataset-card/index.spec.tsx b/web/app/components/datasets/list/new-dataset-card/index.spec.tsx deleted file mode 100644 index 2ce66e134b..0000000000 --- a/web/app/components/datasets/list/new-dataset-card/index.spec.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' -import CreateAppCard from './index' - -describe('CreateAppCard', () => { - describe('Rendering', () => { - it('should render without crashing', () => { - render(<CreateAppCard />) - expect(screen.getAllByRole('link')).toHaveLength(3) - }) - - it('should render create dataset option', () => { - render(<CreateAppCard />) - expect(screen.getByText(/createDataset/)).toBeInTheDocument() - }) - - it('should render create from pipeline option', () => { - render(<CreateAppCard />) - expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument() - }) - - it('should render connect dataset option', () => { - render(<CreateAppCard />) - expect(screen.getByText(/connectDataset/)).toBeInTheDocument() - }) - }) - - describe('Props', () => { - it('should have correct displayName', () => { - expect(CreateAppCard.displayName).toBe('CreateAppCard') - }) - }) - - describe('Links', () => { - it('should have correct href for create dataset', () => { - render(<CreateAppCard />) - const links = screen.getAllByRole('link') - expect(links[0]).toHaveAttribute('href', '/datasets/create') - }) - - it('should have correct href for create from pipeline', () => { - render(<CreateAppCard />) - const links = screen.getAllByRole('link') - expect(links[1]).toHaveAttribute('href', '/datasets/create-from-pipeline') - }) - - it('should have correct href for connect dataset', () => { - render(<CreateAppCard />) - const links = screen.getAllByRole('link') - expect(links[2]).toHaveAttribute('href', '/datasets/connect') - }) - }) - - describe('Styles', () => { - it('should have correct card styling', () => { - const { container } = render(<CreateAppCard />) - const card = container.firstChild as HTMLElement - expect(card).toHaveClass('h-[190px]', 'flex', 'flex-col', 'rounded-xl') - }) - - it('should have border separator for connect option', () => { - const { container } = render(<CreateAppCard />) - const borderDiv = container.querySelector('.border-t-\\[0\\.5px\\]') - expect(borderDiv).toBeInTheDocument() - }) - }) - - describe('Icons', () => { - it('should render three icons for three options', () => { - const { container } = render(<CreateAppCard />) - // Each option has an icon - const icons = container.querySelectorAll('svg') - expect(icons.length).toBeGreaterThanOrEqual(3) - }) - }) -}) diff --git a/web/app/components/datasets/metadata/add-metadata-button.spec.tsx b/web/app/components/datasets/metadata/__tests__/add-metadata-button.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/add-metadata-button.spec.tsx rename to web/app/components/datasets/metadata/__tests__/add-metadata-button.spec.tsx index 642b8b71ec..2dbfb6febe 100644 --- a/web/app/components/datasets/metadata/add-metadata-button.spec.tsx +++ b/web/app/components/datasets/metadata/__tests__/add-metadata-button.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import AddedMetadataButton from './add-metadata-button' +import AddedMetadataButton from '../add-metadata-button' describe('AddedMetadataButton', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/metadata/base/date-picker.spec.tsx b/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx similarity index 99% rename from web/app/components/datasets/metadata/base/date-picker.spec.tsx rename to web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx index c8d0addaa2..2684278777 100644 --- a/web/app/components/datasets/metadata/base/date-picker.spec.tsx +++ b/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import WrappedDatePicker from './date-picker' +import WrappedDatePicker from '../date-picker' type TriggerArgs = { handleClickTrigger: () => void diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/add-row.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/add-row.spec.tsx similarity index 97% rename from web/app/components/datasets/metadata/edit-metadata-batch/add-row.spec.tsx rename to web/app/components/datasets/metadata/edit-metadata-batch/__tests__/add-row.spec.tsx index 0c47873b31..342bddc33f 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/add-row.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/add-row.spec.tsx @@ -1,8 +1,8 @@ -import type { MetadataItemWithEdit } from '../types' +import type { MetadataItemWithEdit } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import AddRow from './add-row' +import { DataType } from '../../types' +import AddRow from '../add-row' type InputCombinedProps = { type: DataType @@ -15,7 +15,7 @@ type LabelProps = { } // Mock InputCombined component -vi.mock('./input-combined', () => ({ +vi.mock('../input-combined', () => ({ default: ({ type, value, onChange }: InputCombinedProps) => ( <input data-testid="input-combined" @@ -27,7 +27,7 @@ vi.mock('./input-combined', () => ({ })) // Mock Label component -vi.mock('./label', () => ({ +vi.mock('../label', () => ({ default: ({ text }: LabelProps) => <div data-testid="label">{text}</div>, })) diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/edit-row.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edit-row.spec.tsx similarity index 97% rename from web/app/components/datasets/metadata/edit-metadata-batch/edit-row.spec.tsx rename to web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edit-row.spec.tsx index a2d743e8be..19c02198b2 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/edit-row.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edit-row.spec.tsx @@ -1,8 +1,8 @@ -import type { MetadataItemWithEdit } from '../types' +import type { MetadataItemWithEdit } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType, UpdateType } from '../types' -import EditMetadatabatchItem from './edit-row' +import { DataType, UpdateType } from '../../types' +import EditMetadatabatchItem from '../edit-row' type InputCombinedProps = { type: DataType @@ -26,7 +26,7 @@ type EditedBeaconProps = { } // Mock InputCombined component -vi.mock('./input-combined', () => ({ +vi.mock('../input-combined', () => ({ default: ({ type, value, onChange, readOnly }: InputCombinedProps) => ( <input data-testid="input-combined" @@ -39,7 +39,7 @@ vi.mock('./input-combined', () => ({ })) // Mock InputHasSetMultipleValue component -vi.mock('./input-has-set-multiple-value', () => ({ +vi.mock('../input-has-set-multiple-value', () => ({ default: ({ onClear, readOnly }: MultipleValueInputProps) => ( <div data-testid="multiple-value-input" data-readonly={readOnly}> <button data-testid="clear-multiple" onClick={onClear}>Clear Multiple</button> @@ -48,14 +48,14 @@ vi.mock('./input-has-set-multiple-value', () => ({ })) // Mock Label component -vi.mock('./label', () => ({ +vi.mock('../label', () => ({ default: ({ text, isDeleted }: LabelProps) => ( <div data-testid="label" data-deleted={isDeleted}>{text}</div> ), })) // Mock EditedBeacon component -vi.mock('./edited-beacon', () => ({ +vi.mock('../edited-beacon', () => ({ default: ({ onReset }: EditedBeaconProps) => ( <button data-testid="edited-beacon" onClick={onReset}>Reset</button> ), diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edited-beacon.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.spec.tsx rename to web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edited-beacon.spec.tsx index 0ab4287b4c..39c8c9effc 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/edited-beacon.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import EditedBeacon from './edited-beacon' +import EditedBeacon from '../edited-beacon' describe('EditedBeacon', () => { describe('Rendering', () => { @@ -115,7 +115,6 @@ describe('EditedBeacon', () => { const handleReset = vi.fn() const { container } = render(<EditedBeacon onReset={handleReset} />) - // Click on the wrapper when not hovering const wrapper = container.firstChild as HTMLElement fireEvent.click(wrapper) diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-combined.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/edit-metadata-batch/input-combined.spec.tsx rename to web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-combined.spec.tsx index 2a4d092822..debfa63dc7 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/input-combined.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-combined.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import InputCombined from './input-combined' +import { DataType } from '../../types' +import InputCombined from '../input-combined' type DatePickerProps = { value: number | null @@ -10,7 +10,7 @@ type DatePickerProps = { } // Mock the base date-picker component -vi.mock('../base/date-picker', () => ({ +vi.mock('../../base/date-picker', () => ({ default: ({ value, onChange, className }: DatePickerProps) => ( <div data-testid="date-picker" className={className} onClick={() => onChange(Date.now())}> {value || 'Pick date'} diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/input-has-set-multiple-value.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-has-set-multiple-value.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/edit-metadata-batch/input-has-set-multiple-value.spec.tsx rename to web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-has-set-multiple-value.spec.tsx index 40dd7a83b9..ef76fd361a 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/input-has-set-multiple-value.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/input-has-set-multiple-value.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import InputHasSetMultipleValue from './input-has-set-multiple-value' +import InputHasSetMultipleValue from '../input-has-set-multiple-value' describe('InputHasSetMultipleValue', () => { describe('Rendering', () => { @@ -89,7 +89,6 @@ describe('InputHasSetMultipleValue', () => { const handleClear = vi.fn() const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />) - // Click on the wrapper fireEvent.click(container.firstChild as HTMLElement) expect(handleClear).not.toHaveBeenCalled() diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/label.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/label.spec.tsx similarity index 99% rename from web/app/components/datasets/metadata/edit-metadata-batch/label.spec.tsx rename to web/app/components/datasets/metadata/edit-metadata-batch/__tests__/label.spec.tsx index 1ec62ebb94..bce0de4118 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/label.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/label.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import Label from './label' +import Label from '../label' describe('Label', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/metadata/edit-metadata-batch/modal.spec.tsx b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/edit-metadata-batch/modal.spec.tsx rename to web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx index 55cce87a40..025f3f47ae 100644 --- a/web/app/components/datasets/metadata/edit-metadata-batch/modal.spec.tsx +++ b/web/app/components/datasets/metadata/edit-metadata-batch/__tests__/modal.spec.tsx @@ -1,8 +1,8 @@ -import type { MetadataItemInBatchEdit, MetadataItemWithEdit } from '../types' +import type { MetadataItemInBatchEdit, MetadataItemWithEdit } from '../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType, UpdateType } from '../types' -import EditMetadataBatchModal from './modal' +import { DataType, UpdateType } from '../../types' +import EditMetadataBatchModal from '../modal' // Mock service/API calls const mockDoAddMetaData = vi.fn().mockResolvedValue({}) @@ -22,7 +22,7 @@ vi.mock('@/service/knowledge/use-metadata', () => ({ // Mock check name hook to control validation let mockCheckNameResult = { errorMsg: '' } -vi.mock('../hooks/use-check-metadata-name', () => ({ +vi.mock('../../hooks/use-check-metadata-name', () => ({ default: () => ({ checkName: () => mockCheckNameResult, }), @@ -58,7 +58,7 @@ type SelectModalProps = { } // Mock child components with callback exposure -vi.mock('./edit-row', () => ({ +vi.mock('../edit-row', () => ({ default: ({ payload, onChange, onRemove, onReset }: EditRowProps) => ( <div data-testid="edit-row" data-id={payload.id}> <span data-testid="edit-row-name">{payload.name}</span> @@ -69,7 +69,7 @@ vi.mock('./edit-row', () => ({ ), })) -vi.mock('./add-row', () => ({ +vi.mock('../add-row', () => ({ default: ({ payload, onChange, onRemove }: AddRowProps) => ( <div data-testid="add-row" data-id={payload.id}> <span data-testid="add-row-name">{payload.name}</span> @@ -79,7 +79,7 @@ vi.mock('./add-row', () => ({ ), })) -vi.mock('../metadata-dataset/select-metadata-modal', () => ({ +vi.mock('../../metadata-dataset/select-metadata-modal', () => ({ default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => ( <div data-testid="select-modal"> {trigger} @@ -505,7 +505,6 @@ describe('EditMetadataBatchModal', () => { // Remove an item fireEvent.click(screen.getByTestId('remove-1')) - // Save const saveButtons = screen.getAllByText(/save/i) const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary')) if (saveBtn) diff --git a/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts similarity index 99% rename from web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.spec.ts rename to web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts index c508a45dc7..bdcd2004d7 100644 --- a/web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-batch-edit-document-metadata.spec.ts @@ -1,7 +1,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType, UpdateType } from '../types' -import useBatchEditDocumentMetadata from './use-batch-edit-document-metadata' +import { DataType, UpdateType } from '../../types' +import useBatchEditDocumentMetadata from '../use-batch-edit-document-metadata' type DocMetadataItem = { id: string @@ -33,7 +33,6 @@ vi.mock('@/service/knowledge/use-metadata', () => ({ }), })) -// Mock Toast vi.mock('@/app/components/base/toast', () => ({ default: { notify: vi.fn(), diff --git a/web/app/components/datasets/metadata/hooks/use-check-metadata-name.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-check-metadata-name.spec.ts similarity index 99% rename from web/app/components/datasets/metadata/hooks/use-check-metadata-name.spec.ts rename to web/app/components/datasets/metadata/hooks/__tests__/use-check-metadata-name.spec.ts index 14081908c0..7c06be39a9 100644 --- a/web/app/components/datasets/metadata/hooks/use-check-metadata-name.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-check-metadata-name.spec.ts @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import useCheckMetadataName from './use-check-metadata-name' +import useCheckMetadataName from '../use-check-metadata-name' describe('useCheckMetadataName', () => { describe('Hook Initialization', () => { diff --git a/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts similarity index 97% rename from web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.spec.ts rename to web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts index 132660302d..5712e82b71 100644 --- a/web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-edit-dataset-metadata.spec.ts @@ -1,9 +1,8 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import useEditDatasetMetadata from './use-edit-dataset-metadata' +import { DataType } from '../../types' +import useEditDatasetMetadata from '../use-edit-dataset-metadata' -// Mock service hooks const mockDoAddMetaData = vi.fn().mockResolvedValue({}) const mockDoRenameMetaData = vi.fn().mockResolvedValue({}) const mockDoDeleteMetaData = vi.fn().mockResolvedValue({}) @@ -41,7 +40,6 @@ vi.mock('@/service/knowledge/use-metadata', () => ({ }), })) -// Mock Toast vi.mock('@/app/components/base/toast', () => ({ default: { notify: vi.fn(), @@ -49,7 +47,7 @@ vi.mock('@/app/components/base/toast', () => ({ })) // Mock useCheckMetadataName -vi.mock('./use-check-metadata-name', () => ({ +vi.mock('../use-check-metadata-name', () => ({ default: () => ({ checkName: (name: string) => ({ errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name', diff --git a/web/app/components/datasets/metadata/hooks/use-metadata-document.spec.ts b/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts similarity index 98% rename from web/app/components/datasets/metadata/hooks/use-metadata-document.spec.ts rename to web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts index bbe84aaf1d..351f7fac08 100644 --- a/web/app/components/datasets/metadata/hooks/use-metadata-document.spec.ts +++ b/web/app/components/datasets/metadata/hooks/__tests__/use-metadata-document.spec.ts @@ -1,7 +1,7 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import useMetadataDocument from './use-metadata-document' +import { DataType } from '../../types' +import useMetadataDocument from '../use-metadata-document' type DocDetail = { id: string @@ -13,7 +13,6 @@ type DocDetail = { segment_count?: number } -// Mock service hooks const mockMutateAsync = vi.fn().mockResolvedValue({}) const mockDoAddMetaData = vi.fn().mockResolvedValue({}) @@ -82,7 +81,6 @@ vi.mock('@/hooks/use-metadata', () => ({ }), })) -// Mock Toast vi.mock('@/app/components/base/toast', () => ({ default: { notify: vi.fn(), @@ -90,7 +88,7 @@ vi.mock('@/app/components/base/toast', () => ({ })) // Mock useCheckMetadataName -vi.mock('./use-check-metadata-name', () => ({ +vi.mock('../use-check-metadata-name', () => ({ default: () => ({ checkName: (name: string) => ({ errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name', diff --git a/web/app/components/datasets/metadata/metadata-dataset/create-content.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx similarity index 97% rename from web/app/components/datasets/metadata/metadata-dataset/create-content.spec.tsx rename to web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx index fd064bc928..8070061776 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/create-content.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-content.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import CreateContent from './create-content' +import { DataType } from '../../types' +import CreateContent from '../create-content' type ModalLikeWrapProps = { children: React.ReactNode @@ -23,7 +23,7 @@ type FieldProps = { } // Mock ModalLikeWrap -vi.mock('../../../base/modal-like-wrap', () => ({ +vi.mock('../../../../base/modal-like-wrap', () => ({ default: ({ children, title, onClose, onConfirm, beforeHeader }: ModalLikeWrapProps) => ( <div data-testid="modal-wrap"> <div data-testid="modal-title">{title}</div> @@ -36,7 +36,7 @@ vi.mock('../../../base/modal-like-wrap', () => ({ })) // Mock OptionCard -vi.mock('../../../workflow/nodes/_base/components/option-card', () => ({ +vi.mock('../../../../workflow/nodes/_base/components/option-card', () => ({ default: ({ title, selected, onSelect }: OptionCardProps) => ( <button data-testid={`option-${title.toLowerCase()}`} @@ -49,7 +49,7 @@ vi.mock('../../../workflow/nodes/_base/components/option-card', () => ({ })) // Mock Field -vi.mock('./field', () => ({ +vi.mock('../field', () => ({ default: ({ label, children }: FieldProps) => ( <div data-testid="field"> <label data-testid="field-label">{label}</label> diff --git a/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx similarity index 97% rename from web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.spec.tsx rename to web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx index 5e86521a87..3a8ed6b909 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/create-metadata-modal.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import CreateMetadataModal from './create-metadata-modal' +import { DataType } from '../../types' +import CreateMetadataModal from '../create-metadata-modal' type PortalProps = { children: React.ReactNode @@ -26,7 +26,7 @@ type CreateContentProps = { } // Mock PortalToFollowElem components -vi.mock('../../../base/portal-to-follow-elem', () => ({ +vi.mock('../../../../base/portal-to-follow-elem', () => ({ PortalToFollowElem: ({ children, open }: PortalProps) => ( <div data-testid="portal-wrapper" data-open={open}>{children}</div> ), @@ -39,7 +39,7 @@ vi.mock('../../../base/portal-to-follow-elem', () => ({ })) // Mock CreateContent component -vi.mock('./create-content', () => ({ +vi.mock('../create-content', () => ({ default: ({ onSave, onClose, onBack, hasBack }: CreateContentProps) => ( <div data-testid="create-content"> <span data-testid="has-back">{String(hasBack)}</span> diff --git a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.spec.tsx rename to web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx index fc1f0d0990..89ddb76694 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/dataset-metadata-drawer.spec.tsx @@ -1,8 +1,8 @@ -import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '../types' +import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import DatasetMetadataDrawer from './dataset-metadata-drawer' +import { DataType } from '../../types' +import DatasetMetadataDrawer from '../dataset-metadata-drawer' // Mock service/API calls vi.mock('@/service/knowledge/use-metadata', () => ({ @@ -16,13 +16,12 @@ vi.mock('@/service/knowledge/use-metadata', () => ({ })) // Mock check name hook -vi.mock('../hooks/use-check-metadata-name', () => ({ +vi.mock('../../hooks/use-check-metadata-name', () => ({ default: () => ({ checkName: () => ({ errorMsg: '' }), }), })) -// Mock Toast const mockToastNotify = vi.fn() vi.mock('@/app/components/base/toast', () => ({ default: { @@ -213,7 +212,6 @@ describe('DatasetMetadataDrawer', () => { expect(screen.getByTestId('create-modal')).toBeInTheDocument() }) - // Save fireEvent.click(screen.getByTestId('create-save')) await waitFor(() => { @@ -400,7 +398,6 @@ describe('DatasetMetadataDrawer', () => { const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive') expect(deleteContainer).toBeTruthy() - // Click delete icon if (deleteContainer) { const deleteIcon = deleteContainer.querySelector('svg') if (deleteIcon) @@ -444,7 +441,6 @@ describe('DatasetMetadataDrawer', () => { expect(hasConfirmBtn).toBe(true) }) - // Click confirm const confirmBtns = screen.getAllByRole('button') const confirmBtn = confirmBtns.find(btn => btn.textContent?.toLowerCase().includes('confirm'), @@ -491,7 +487,6 @@ describe('DatasetMetadataDrawer', () => { expect(hasConfirmBtn).toBe(true) }) - // Click cancel const cancelBtns = screen.getAllByRole('button') const cancelBtn = cancelBtns.find(btn => btn.textContent?.toLowerCase().includes('cancel'), diff --git a/web/app/components/datasets/metadata/metadata-dataset/field.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/field.spec.tsx similarity index 99% rename from web/app/components/datasets/metadata/metadata-dataset/field.spec.tsx rename to web/app/components/datasets/metadata/metadata-dataset/__tests__/field.spec.tsx index e3a34f9d98..030ab4bdb0 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/field.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/field.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import Field from './field' +import Field from '../field' describe('Field', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata-modal.spec.tsx similarity index 97% rename from web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.spec.tsx rename to web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata-modal.spec.tsx index 6e565c0b07..800ffc3586 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata-modal.spec.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import SelectMetadataModal from './select-metadata-modal' +import { DataType } from '../../types' +import SelectMetadataModal from '../select-metadata-modal' type MetadataItem = { id: string @@ -50,7 +50,7 @@ vi.mock('@/service/knowledge/use-metadata', () => ({ })) // Mock PortalToFollowElem components -vi.mock('../../../base/portal-to-follow-elem', () => ({ +vi.mock('../../../../base/portal-to-follow-elem', () => ({ PortalToFollowElem: ({ children, open }: PortalProps) => ( <div data-testid="portal-wrapper" data-open={open}>{children}</div> ), @@ -63,7 +63,7 @@ vi.mock('../../../base/portal-to-follow-elem', () => ({ })) // Mock SelectMetadata component -vi.mock('./select-metadata', () => ({ +vi.mock('../select-metadata', () => ({ default: ({ onSelect, onNew, onManage, list }: SelectMetadataProps) => ( <div data-testid="select-metadata"> <span data-testid="list-count">{list?.length || 0}</span> @@ -75,7 +75,7 @@ vi.mock('./select-metadata', () => ({ })) // Mock CreateContent component -vi.mock('./create-content', () => ({ +vi.mock('../create-content', () => ({ default: ({ onSave, onBack, onClose, hasBack }: CreateContentProps) => ( <div data-testid="create-content"> <button data-testid="save-btn" onClick={() => onSave({ type: DataType.string, name: 'new_field' })}>Save</button> diff --git a/web/app/components/datasets/metadata/metadata-dataset/select-metadata.spec.tsx b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/metadata-dataset/select-metadata.spec.tsx rename to web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata.spec.tsx index 2602fd145f..c1406d1233 100644 --- a/web/app/components/datasets/metadata/metadata-dataset/select-metadata.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-dataset/__tests__/select-metadata.spec.tsx @@ -1,15 +1,15 @@ -import type { MetadataItem } from '../types' +import type { MetadataItem } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import SelectMetadata from './select-metadata' +import { DataType } from '../../types' +import SelectMetadata from '../select-metadata' type IconProps = { className?: string } // Mock getIcon utility -vi.mock('../utils/get-icon', () => ({ +vi.mock('../../utils/get-icon', () => ({ getIcon: () => (props: IconProps) => <span data-testid="icon" className={props.className}>Icon</span>, })) diff --git a/web/app/components/datasets/metadata/metadata-document/field.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/field.spec.tsx similarity index 99% rename from web/app/components/datasets/metadata/metadata-document/field.spec.tsx rename to web/app/components/datasets/metadata/metadata-document/__tests__/field.spec.tsx index 50aad1a6cc..714dd0c6bb 100644 --- a/web/app/components/datasets/metadata/metadata-document/field.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/field.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' -import Field from './field' +import Field from '../field' describe('Field', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/metadata/metadata-document/index.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/datasets/metadata/metadata-document/index.spec.tsx rename to web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx index f80b6ca010..e56fe46422 100644 --- a/web/app/components/datasets/metadata/metadata-document/index.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx @@ -1,8 +1,8 @@ -import type { MetadataItemWithValue } from '../types' +import type { MetadataItemWithValue } from '../../types' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import MetadataDocument from './index' +import { DataType } from '../../types' +import MetadataDocument from '../index' type MockHookReturn = { embeddingAvailable: boolean @@ -25,7 +25,7 @@ type MockHookReturn = { // Mock useMetadataDocument hook - need to control state const mockUseMetadataDocument = vi.fn<() => MockHookReturn>() -vi.mock('../hooks/use-metadata-document', () => ({ +vi.mock('../../hooks/use-metadata-document', () => ({ default: () => mockUseMetadataDocument(), })) @@ -39,13 +39,12 @@ vi.mock('@/service/knowledge/use-metadata', () => ({ })) // Mock check name hook -vi.mock('../hooks/use-check-metadata-name', () => ({ +vi.mock('../../hooks/use-check-metadata-name', () => ({ default: () => ({ checkName: () => ({ errorMsg: '' }), }), })) -// Mock next/navigation vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), @@ -483,7 +482,6 @@ describe('MetadataDocument', () => { const deleteContainers = container.querySelectorAll('.hover\\:bg-state-destructive-hover') expect(deleteContainers.length).toBeGreaterThan(0) - // Click the delete icon (SVG inside the container) if (deleteContainers.length > 0) { const deleteIcon = deleteContainers[0].querySelector('svg') if (deleteIcon) diff --git a/web/app/components/datasets/metadata/metadata-document/info-group.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx similarity index 96% rename from web/app/components/datasets/metadata/metadata-document/info-group.spec.tsx rename to web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx index d8585d0170..f30e188cd7 100644 --- a/web/app/components/datasets/metadata/metadata-document/info-group.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx @@ -1,8 +1,8 @@ -import type { MetadataItemWithValue } from '../types' +import type { MetadataItemWithValue } from '../../types' import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { DataType } from '../types' -import InfoGroup from './info-group' +import { DataType } from '../../types' +import InfoGroup from '../info-group' type SelectModalProps = { trigger: React.ReactNode @@ -22,7 +22,6 @@ type InputCombinedProps = { type: DataType } -// Mock next/navigation vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), @@ -41,12 +40,12 @@ vi.mock('@/hooks/use-timestamp', () => ({ })) // Mock AddMetadataButton -vi.mock('../add-metadata-button', () => ({ +vi.mock('../../add-metadata-button', () => ({ default: () => <button data-testid="add-metadata-btn">Add Metadata</button>, })) // Mock InputCombined -vi.mock('../edit-metadata-batch/input-combined', () => ({ +vi.mock('../../edit-metadata-batch/input-combined', () => ({ default: ({ value, onChange, type }: InputCombinedProps) => ( <input data-testid="input-combined" @@ -58,7 +57,7 @@ vi.mock('../edit-metadata-batch/input-combined', () => ({ })) // Mock SelectMetadataModal -vi.mock('../metadata-dataset/select-metadata-modal', () => ({ +vi.mock('../../metadata-dataset/select-metadata-modal', () => ({ default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => ( <div data-testid="select-metadata-modal"> {trigger} @@ -70,7 +69,7 @@ vi.mock('../metadata-dataset/select-metadata-modal', () => ({ })) // Mock Field -vi.mock('./field', () => ({ +vi.mock('../field', () => ({ default: ({ label, children }: FieldProps) => ( <div data-testid="field"> <span data-testid="field-label">{label}</span> diff --git a/web/app/components/datasets/metadata/metadata-document/no-data.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/no-data.spec.tsx similarity index 99% rename from web/app/components/datasets/metadata/metadata-document/no-data.spec.tsx rename to web/app/components/datasets/metadata/metadata-document/__tests__/no-data.spec.tsx index 84079cda1d..975c923db7 100644 --- a/web/app/components/datasets/metadata/metadata-document/no-data.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/no-data.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import NoData from './no-data' +import NoData from '../no-data' describe('NoData', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/metadata/utils/get-icon.spec.ts b/web/app/components/datasets/metadata/utils/__tests__/get-icon.spec.ts similarity index 94% rename from web/app/components/datasets/metadata/utils/get-icon.spec.ts rename to web/app/components/datasets/metadata/utils/__tests__/get-icon.spec.ts index f5a34bd264..07eef6c320 100644 --- a/web/app/components/datasets/metadata/utils/get-icon.spec.ts +++ b/web/app/components/datasets/metadata/utils/__tests__/get-icon.spec.ts @@ -1,7 +1,7 @@ import { RiHashtag, RiTextSnippet, RiTimeLine } from '@remixicon/react' import { describe, expect, it } from 'vitest' -import { DataType } from '../types' -import { getIcon } from './get-icon' +import { DataType } from '../../types' +import { getIcon } from '../get-icon' describe('getIcon', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/preview/__tests__/container.spec.tsx b/web/app/components/datasets/preview/__tests__/container.spec.tsx new file mode 100644 index 0000000000..86f6e3f85b --- /dev/null +++ b/web/app/components/datasets/preview/__tests__/container.spec.tsx @@ -0,0 +1,173 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import PreviewContainer from '../container' + +// Tests for PreviewContainer - a layout wrapper with header and scrollable main area +describe('PreviewContainer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render header content in a header element', () => { + render(<PreviewContainer header={<span>Header Title</span>}>Body</PreviewContainer>) + + expect(screen.getByText('Header Title')).toBeInTheDocument() + const headerEl = screen.getByText('Header Title').closest('header') + expect(headerEl).toBeInTheDocument() + }) + + it('should render children in a main element', () => { + render(<PreviewContainer header="Header">Main content</PreviewContainer>) + + const mainEl = screen.getByRole('main') + expect(mainEl).toHaveTextContent('Main content') + }) + + it('should render both header and children simultaneously', () => { + render( + <PreviewContainer header={<h2>My Header</h2>}> + <p>Body paragraph</p> + </PreviewContainer>, + ) + + expect(screen.getByText('My Header')).toBeInTheDocument() + expect(screen.getByText('Body paragraph')).toBeInTheDocument() + }) + + it('should render without children', () => { + render(<PreviewContainer header="Header" />) + + expect(screen.getByRole('main')).toBeInTheDocument() + expect(screen.getByRole('main').childElementCount).toBe(0) + }) + }) + + describe('Props', () => { + it('should apply className to the outer wrapper div', () => { + const { container } = render( + <PreviewContainer header="Header" className="outer-class">Content</PreviewContainer>, + ) + + expect(container.firstElementChild).toHaveClass('outer-class') + }) + + it('should apply mainClassName to the main element', () => { + render( + <PreviewContainer header="Header" mainClassName="custom-main">Content</PreviewContainer>, + ) + + const mainEl = screen.getByRole('main') + expect(mainEl).toHaveClass('custom-main') + // Default classes should still be present + expect(mainEl).toHaveClass('w-full', 'grow', 'overflow-y-auto', 'px-6', 'py-5') + }) + + it('should forward ref to the inner container div', () => { + const ref = vi.fn() + render( + <PreviewContainer header="Header" ref={ref}>Content</PreviewContainer>, + ) + + expect(ref).toHaveBeenCalled() + const refArg = ref.mock.calls[0][0] + expect(refArg).toBeInstanceOf(HTMLDivElement) + }) + + it('should pass rest props to the inner container div', () => { + render( + <PreviewContainer header="Header" data-testid="inner-container" id="container-1"> + Content + </PreviewContainer>, + ) + + const inner = screen.getByTestId('inner-container') + expect(inner).toHaveAttribute('id', 'container-1') + }) + + it('should render ReactNode as header', () => { + render( + <PreviewContainer header={<div data-testid="complex-header"><span>Complex</span></div>}> + Content + </PreviewContainer>, + ) + + expect(screen.getByTestId('complex-header')).toBeInTheDocument() + expect(screen.getByText('Complex')).toBeInTheDocument() + }) + }) + + // Layout structure tests + describe('Layout Structure', () => { + it('should have header with border-b styling', () => { + render(<PreviewContainer header="Header">Content</PreviewContainer>) + + const headerEl = screen.getByText('Header').closest('header') + expect(headerEl).toHaveClass('border-b', 'border-divider-subtle') + }) + + it('should have inner div with flex column layout', () => { + render( + <PreviewContainer header="Header" data-testid="inner">Content</PreviewContainer>, + ) + + const inner = screen.getByTestId('inner') + expect(inner).toHaveClass('flex', 'h-full', 'w-full', 'flex-col') + }) + + it('should have main with overflow-y-auto for scrolling', () => { + render(<PreviewContainer header="Header">Content</PreviewContainer>) + + expect(screen.getByRole('main')).toHaveClass('overflow-y-auto') + }) + }) + + // DisplayName test + describe('DisplayName', () => { + it('should have correct displayName', () => { + expect(PreviewContainer.displayName).toBe('PreviewContainer') + }) + }) + + describe('Edge Cases', () => { + it('should render with empty string header', () => { + render(<PreviewContainer header="">Content</PreviewContainer>) + + const headerEl = screen.getByRole('banner') + expect(headerEl).toBeInTheDocument() + }) + + it('should render with null children', () => { + render(<PreviewContainer header="Header">{null}</PreviewContainer>) + + expect(screen.getByRole('main')).toBeInTheDocument() + }) + + it('should render with multiple children', () => { + render( + <PreviewContainer header="Header"> + <div>Child 1</div> + <div>Child 2</div> + <div>Child 3</div> + </PreviewContainer>, + ) + + expect(screen.getByText('Child 1')).toBeInTheDocument() + expect(screen.getByText('Child 2')).toBeInTheDocument() + expect(screen.getByText('Child 3')).toBeInTheDocument() + }) + + it('should not crash on re-render with different props', () => { + const { rerender } = render( + <PreviewContainer header="First" className="a">Content A</PreviewContainer>, + ) + + rerender( + <PreviewContainer header="Second" className="b" mainClassName="new-main">Content B</PreviewContainer>, + ) + + expect(screen.getByText('Second')).toBeInTheDocument() + expect(screen.getByText('Content B')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/preview/__tests__/header.spec.tsx b/web/app/components/datasets/preview/__tests__/header.spec.tsx new file mode 100644 index 0000000000..8f7e44e18c --- /dev/null +++ b/web/app/components/datasets/preview/__tests__/header.spec.tsx @@ -0,0 +1,141 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PreviewHeader } from '../header' + +// Tests for PreviewHeader - displays a title and optional children +describe('PreviewHeader', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render the title text', () => { + render(<PreviewHeader title="Preview Title" />) + + expect(screen.getByText('Preview Title')).toBeInTheDocument() + }) + + it('should render children below the title', () => { + render( + <PreviewHeader title="Title"> + <span>Child content</span> + </PreviewHeader>, + ) + + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('Child content')).toBeInTheDocument() + }) + + it('should render without children', () => { + const { container } = render(<PreviewHeader title="Solo Title" />) + + expect(container.firstElementChild).toBeInTheDocument() + expect(screen.getByText('Solo Title')).toBeInTheDocument() + }) + + it('should render title in an inner div with uppercase styling', () => { + render(<PreviewHeader title="Styled Title" />) + + const titleEl = screen.getByText('Styled Title') + expect(titleEl).toHaveClass('uppercase', 'mb-1', 'px-1', 'text-text-accent') + }) + }) + + describe('Props', () => { + it('should apply custom className to outer div', () => { + render(<PreviewHeader title="Title" className="custom-header" data-testid="header" />) + + expect(screen.getByTestId('header')).toHaveClass('custom-header') + }) + + it('should pass rest props to the outer div', () => { + render(<PreviewHeader title="Title" data-testid="header" id="header-1" aria-label="preview header" />) + + const el = screen.getByTestId('header') + expect(el).toHaveAttribute('id', 'header-1') + expect(el).toHaveAttribute('aria-label', 'preview header') + }) + + it('should render with empty string title', () => { + render(<PreviewHeader title="" data-testid="header" />) + + const header = screen.getByTestId('header') + // Title div exists but is empty + const titleDiv = header.querySelector('.uppercase') + expect(titleDiv).toBeInTheDocument() + expect(titleDiv?.textContent).toBe('') + }) + }) + + describe('Structure', () => { + it('should render as a div element', () => { + render(<PreviewHeader title="Title" data-testid="header" />) + + expect(screen.getByTestId('header').tagName).toBe('DIV') + }) + + it('should have title div as the first child', () => { + render(<PreviewHeader title="Title" data-testid="header" />) + + const header = screen.getByTestId('header') + const firstChild = header.firstElementChild + expect(firstChild).toHaveTextContent('Title') + }) + + it('should place children after the title div', () => { + render( + <PreviewHeader title="Title" data-testid="header"> + <button>Action</button> + </PreviewHeader>, + ) + + const header = screen.getByTestId('header') + const children = Array.from(header.children) + expect(children).toHaveLength(2) + expect(children[0]).toHaveTextContent('Title') + expect(children[1]).toHaveTextContent('Action') + }) + }) + + describe('Edge Cases', () => { + it('should handle special characters in title', () => { + render(<PreviewHeader title="Test & <Special> 'Characters'" />) + + expect(screen.getByText('Test & <Special> \'Characters\'')).toBeInTheDocument() + }) + + it('should handle long titles', () => { + const longTitle = 'A'.repeat(500) + render(<PreviewHeader title={longTitle} />) + + expect(screen.getByText(longTitle)).toBeInTheDocument() + }) + + it('should render multiple children', () => { + render( + <PreviewHeader title="Title"> + <span>First</span> + <span>Second</span> + </PreviewHeader>, + ) + + expect(screen.getByText('First')).toBeInTheDocument() + expect(screen.getByText('Second')).toBeInTheDocument() + }) + + it('should render with null children', () => { + render(<PreviewHeader title="Title">{null}</PreviewHeader>) + + expect(screen.getByText('Title')).toBeInTheDocument() + }) + + it('should not crash on re-render with different title', () => { + const { rerender } = render(<PreviewHeader title="First Title" />) + + rerender(<PreviewHeader title="Second Title" />) + + expect(screen.queryByText('First Title')).not.toBeInTheDocument() + expect(screen.getByText('Second Title')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/preview/index.spec.tsx b/web/app/components/datasets/preview/__tests__/index.spec.tsx similarity index 94% rename from web/app/components/datasets/preview/index.spec.tsx rename to web/app/components/datasets/preview/__tests__/index.spec.tsx index 56638fb612..298d589001 100644 --- a/web/app/components/datasets/preview/index.spec.tsx +++ b/web/app/components/datasets/preview/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import DatasetPreview from './index' +import DatasetPreview from '../index' afterEach(() => { cleanup() diff --git a/web/app/components/datasets/rename-modal/index.spec.tsx b/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/datasets/rename-modal/index.spec.tsx rename to web/app/components/datasets/rename-modal/__tests__/index.spec.tsx index 13ab4d25ea..a29fc0a74c 100644 --- a/web/app/components/datasets/rename-modal/index.spec.tsx +++ b/web/app/components/datasets/rename-modal/__tests__/index.spec.tsx @@ -2,31 +2,29 @@ import type { DataSet } from '@/models/datasets' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' -import RenameDatasetModal from './index' +import RenameDatasetModal from '../index' -// Mock service const mockUpdateDatasetSetting = vi.fn() vi.mock('@/service/datasets', () => ({ updateDatasetSetting: (params: unknown) => mockUpdateDatasetSetting(params), })) -// Mock Toast const mockToastNotify = vi.fn() -vi.mock('../../base/toast', () => ({ +vi.mock('../../../base/toast', () => ({ default: { notify: (params: unknown) => mockToastNotify(params), }, })) // Mock AppIcon - simplified mock to enable testing onClick callback -vi.mock('../../base/app-icon', () => ({ +vi.mock('../../../base/app-icon', () => ({ default: ({ onClick }: { onClick?: () => void }) => ( <button data-testid="app-icon" onClick={onClick}>Icon</button> ), })) // Mock AppIconPicker - simplified mock to test onSelect and onClose callbacks -vi.mock('../../base/app-icon-picker', () => ({ +vi.mock('../../../base/app-icon-picker', () => ({ default: ({ onSelect, onClose }: { onSelect?: (icon: { type: string, icon?: string, background?: string, fileId?: string, url?: string }) => void onClose?: () => void @@ -43,7 +41,6 @@ vi.mock('../../base/app-icon-picker', () => ({ ), })) -// Note: react-i18next is globally mocked in vitest.setup.ts // The mock returns 'ns.key' format, e.g., 'common.operation.cancel' describe('RenameDatasetModal', () => { @@ -748,7 +745,6 @@ describe('RenameDatasetModal', () => { // Initially picker should not be visible expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument() - // Click app icon to open picker const appIcon = screen.getByTestId('app-icon') await act(async () => { fireEvent.click(appIcon) diff --git a/web/app/components/datasets/settings/option-card.spec.tsx b/web/app/components/datasets/settings/__tests__/option-card.spec.tsx similarity index 98% rename from web/app/components/datasets/settings/option-card.spec.tsx rename to web/app/components/datasets/settings/__tests__/option-card.spec.tsx index 6fdcd8faa7..ba670dc144 100644 --- a/web/app/components/datasets/settings/option-card.spec.tsx +++ b/web/app/components/datasets/settings/__tests__/option-card.spec.tsx @@ -1,8 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' -import { EffectColor } from './chunk-structure/types' -import OptionCard from './option-card' - -// Note: react-i18next is globally mocked in vitest.setup.ts +import { EffectColor } from '../chunk-structure/types' +import OptionCard from '../option-card' describe('OptionCard', () => { const defaultProps = { diff --git a/web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx b/web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx new file mode 100644 index 0000000000..39b4ffc784 --- /dev/null +++ b/web/app/components/datasets/settings/__tests__/summary-index-setting.spec.tsx @@ -0,0 +1,226 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import SummaryIndexSetting from '../summary-index-setting' + +// Mock useModelList to return a list of text generation models +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: () => ({ + data: [ + { + provider: 'openai', + label: { en_US: 'OpenAI' }, + models: [ + { model: 'gpt-4', label: { en_US: 'GPT-4' }, model_type: 'llm', status: 'active' }, + ], + }, + ], + }), +})) + +// Mock ModelSelector (external component from header module) +vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({ + default: ({ onSelect, readonly, defaultModel }: { onSelect?: (val: Record<string, string>) => void, readonly?: boolean, defaultModel?: { model?: string } }) => ( + <div data-testid="model-selector" data-readonly={readonly}> + <span data-testid="current-model">{defaultModel?.model || 'none'}</span> + <button + data-testid="select-model-btn" + onClick={() => onSelect?.({ provider: 'openai', model: 'gpt-4' })} + > + Select + </button> + </div> + ), +})) + +const ns = 'datasetSettings' + +describe('SummaryIndexSetting', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('knowledge-base entry', () => { + it('should render auto gen label and switch', () => { + render(<SummaryIndexSetting entry="knowledge-base" />) + expect(screen.getByText(`${ns}.form.summaryAutoGen`)).toBeInTheDocument() + }) + + it('should render switch with defaultValue false when no setting', () => { + render(<SummaryIndexSetting entry="knowledge-base" />) + // Switch is rendered; no model selector without enable + expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument() + }) + + it('should show model selector and textarea when enabled', () => { + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ + enable: true, + model_provider_name: 'openai', + model_name: 'gpt-4', + }} + />, + ) + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + expect(screen.getByText(`${ns}.form.summaryModel`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.form.summaryInstructions`)).toBeInTheDocument() + }) + + it('should call onSummaryIndexSettingChange with enable toggle', () => { + const onChange = vi.fn() + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ enable: false }} + onSummaryIndexSettingChange={onChange} + />, + ) + // Find and click the switch + const switchEl = screen.getByRole('switch') + fireEvent.click(switchEl) + expect(onChange).toHaveBeenCalledWith({ enable: true }) + }) + + it('should call onSummaryIndexSettingChange when model selected', () => { + const onChange = vi.fn() + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ enable: true, model_provider_name: 'openai', model_name: 'gpt-4' }} + onSummaryIndexSettingChange={onChange} + />, + ) + fireEvent.click(screen.getByTestId('select-model-btn')) + expect(onChange).toHaveBeenCalledWith({ model_provider_name: 'openai', model_name: 'gpt-4' }) + }) + + it('should call onSummaryIndexSettingChange when prompt changed', () => { + const onChange = vi.fn() + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ enable: true, summary_prompt: '' }} + onSummaryIndexSettingChange={onChange} + />, + ) + const textarea = screen.getByPlaceholderText(`${ns}.form.summaryInstructionsPlaceholder`) + fireEvent.change(textarea, { target: { value: 'Summarize this' } }) + expect(onChange).toHaveBeenCalledWith({ summary_prompt: 'Summarize this' }) + }) + }) + + describe('dataset-settings entry', () => { + it('should render auto gen label with switch', () => { + render(<SummaryIndexSetting entry="dataset-settings" />) + expect(screen.getByText(`${ns}.form.summaryAutoGen`)).toBeInTheDocument() + }) + + it('should show disabled text when not enabled', () => { + render( + <SummaryIndexSetting + entry="dataset-settings" + summaryIndexSetting={{ enable: false }} + />, + ) + expect(screen.getByText(`${ns}.form.summaryAutoGenEnableTip`)).toBeInTheDocument() + }) + + it('should show enabled tip when enabled', () => { + render( + <SummaryIndexSetting + entry="dataset-settings" + summaryIndexSetting={{ enable: true }} + />, + ) + expect(screen.getByText(`${ns}.form.summaryAutoGenTip`)).toBeInTheDocument() + }) + + it('should show model selector and textarea when enabled', () => { + render( + <SummaryIndexSetting + entry="dataset-settings" + summaryIndexSetting={{ enable: true, model_provider_name: 'openai', model_name: 'gpt-4' }} + />, + ) + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + expect(screen.getByText(`${ns}.form.summaryModel`)).toBeInTheDocument() + }) + }) + + describe('create-document entry', () => { + it('should render auto gen label with switch', () => { + render(<SummaryIndexSetting entry="create-document" />) + expect(screen.getByText(`${ns}.form.summaryAutoGen`)).toBeInTheDocument() + }) + + it('should show model selector and textarea when enabled', () => { + render( + <SummaryIndexSetting + entry="create-document" + summaryIndexSetting={{ enable: true, model_provider_name: 'openai', model_name: 'gpt-4' }} + />, + ) + expect(screen.getByTestId('model-selector')).toBeInTheDocument() + expect(screen.getByText(`${ns}.form.summaryModel`)).toBeInTheDocument() + expect(screen.getByText(`${ns}.form.summaryInstructions`)).toBeInTheDocument() + }) + + it('should not show model selector when disabled', () => { + render( + <SummaryIndexSetting + entry="create-document" + summaryIndexSetting={{ enable: false }} + />, + ) + expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument() + }) + }) + + describe('readonly mode', () => { + it('should pass readonly to model selector in knowledge-base entry', () => { + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ enable: true, model_provider_name: 'openai', model_name: 'gpt-4' }} + readonly + />, + ) + expect(screen.getByTestId('model-selector')).toHaveAttribute('data-readonly', 'true') + }) + + it('should disable textarea in readonly mode', () => { + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ enable: true, summary_prompt: 'test' }} + readonly + />, + ) + const textarea = screen.getByPlaceholderText(`${ns}.form.summaryInstructionsPlaceholder`) + expect(textarea).toBeDisabled() + }) + }) + + describe('model config derivation', () => { + it('should pass correct defaultModel when provider and model are set', () => { + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ enable: true, model_provider_name: 'anthropic', model_name: 'claude-3' }} + />, + ) + expect(screen.getByTestId('current-model')).toHaveTextContent('claude-3') + }) + + it('should pass undefined defaultModel when provider is missing', () => { + render( + <SummaryIndexSetting + entry="knowledge-base" + summaryIndexSetting={{ enable: true }} + />, + ) + expect(screen.getByTestId('current-model')).toHaveTextContent('none') + }) + }) +}) diff --git a/web/app/components/datasets/settings/chunk-structure/hooks.spec.tsx b/web/app/components/datasets/settings/chunk-structure/__tests__/hooks.spec.tsx similarity index 98% rename from web/app/components/datasets/settings/chunk-structure/hooks.spec.tsx rename to web/app/components/datasets/settings/chunk-structure/__tests__/hooks.spec.tsx index 668d2f926f..8d44d19d09 100644 --- a/web/app/components/datasets/settings/chunk-structure/hooks.spec.tsx +++ b/web/app/components/datasets/settings/chunk-structure/__tests__/hooks.spec.tsx @@ -1,8 +1,6 @@ import { renderHook } from '@testing-library/react' -import { useChunkStructure } from './hooks' -import { EffectColor } from './types' - -// Note: react-i18next is globally mocked in vitest.setup.ts +import { useChunkStructure } from '../hooks' +import { EffectColor } from '../types' describe('useChunkStructure', () => { describe('Hook Initialization', () => { diff --git a/web/app/components/datasets/settings/chunk-structure/index.spec.tsx b/web/app/components/datasets/settings/chunk-structure/__tests__/index.spec.tsx similarity index 98% rename from web/app/components/datasets/settings/chunk-structure/index.spec.tsx rename to web/app/components/datasets/settings/chunk-structure/__tests__/index.spec.tsx index 0206617c94..1ebc6da6cb 100644 --- a/web/app/components/datasets/settings/chunk-structure/index.spec.tsx +++ b/web/app/components/datasets/settings/chunk-structure/__tests__/index.spec.tsx @@ -1,8 +1,6 @@ import { render, screen } from '@testing-library/react' import { ChunkingMode } from '@/models/datasets' -import ChunkStructure from './index' - -// Note: react-i18next is globally mocked in vitest.setup.ts +import ChunkStructure from '../index' describe('ChunkStructure', () => { describe('Rendering', () => { diff --git a/web/app/components/datasets/settings/form/index.spec.tsx b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx similarity index 99% rename from web/app/components/datasets/settings/form/index.spec.tsx rename to web/app/components/datasets/settings/form/__tests__/index.spec.tsx index 03e98861e2..b2a2e3c9d8 100644 --- a/web/app/components/datasets/settings/form/index.spec.tsx +++ b/web/app/components/datasets/settings/form/__tests__/index.spec.tsx @@ -3,8 +3,8 @@ import type { RetrievalConfig } from '@/types/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import { IndexingType } from '../../create/step-two' -import Form from './index' +import { IndexingType } from '../../../create/step-two' +import Form from '../index' // Mock contexts const mockMutateDatasets = vi.fn() @@ -374,7 +374,6 @@ describe('Form', () => { const nameInput = screen.getByDisplayValue('Test Dataset') fireEvent.change(nameInput, { target: { value: 'New Dataset Name' } }) - // Save const saveButton = screen.getByRole('button', { name: /form\.save/i }) fireEvent.click(saveButton) @@ -397,7 +396,6 @@ describe('Form', () => { const descriptionTextarea = screen.getByDisplayValue('Test description') fireEvent.change(descriptionTextarea, { target: { value: 'New description' } }) - // Save const saveButton = screen.getByRole('button', { name: /form\.save/i }) fireEvent.click(saveButton) diff --git a/web/app/components/datasets/settings/form/components/basic-info-section.spec.tsx b/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx similarity index 98% rename from web/app/components/datasets/settings/form/components/basic-info-section.spec.tsx rename to web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx index 28085e52fa..618a28d498 100644 --- a/web/app/components/datasets/settings/form/components/basic-info-section.spec.tsx +++ b/web/app/components/datasets/settings/form/components/__tests__/basic-info-section.spec.tsx @@ -4,8 +4,8 @@ import type { RetrievalConfig } from '@/types/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import { IndexingType } from '../../../create/step-two' -import BasicInfoSection from './basic-info-section' +import { IndexingType } from '../../../../create/step-two' +import BasicInfoSection from '../basic-info-section' // Mock app-context vi.mock('@/context/app-context', () => ({ @@ -325,12 +325,10 @@ describe('BasicInfoSection', () => { const setPermission = vi.fn() render(<BasicInfoSection {...defaultProps} setPermission={setPermission} />) - // Open dropdown const trigger = screen.getByText(/form\.permissionsOnlyMe/i) fireEvent.click(trigger) await waitFor(() => { - // Click All Team Members option const allMemberOptions = screen.getAllByText(/form\.permissionsAllMember/i) fireEvent.click(allMemberOptions[0]) }) diff --git a/web/app/components/datasets/settings/form/components/external-knowledge-section.spec.tsx b/web/app/components/datasets/settings/form/components/__tests__/external-knowledge-section.spec.tsx similarity index 99% rename from web/app/components/datasets/settings/form/components/external-knowledge-section.spec.tsx rename to web/app/components/datasets/settings/form/components/__tests__/external-knowledge-section.spec.tsx index 96512b5aca..fd2e83892f 100644 --- a/web/app/components/datasets/settings/form/components/external-knowledge-section.spec.tsx +++ b/web/app/components/datasets/settings/form/components/__tests__/external-knowledge-section.spec.tsx @@ -3,8 +3,8 @@ import type { RetrievalConfig } from '@/types/app' import { render, screen } from '@testing-library/react' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import { IndexingType } from '../../../create/step-two' -import ExternalKnowledgeSection from './external-knowledge-section' +import { IndexingType } from '../../../../create/step-two' +import ExternalKnowledgeSection from '../external-knowledge-section' describe('ExternalKnowledgeSection', () => { const mockRetrievalConfig: RetrievalConfig = { diff --git a/web/app/components/datasets/settings/form/components/indexing-section.spec.tsx b/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx similarity index 99% rename from web/app/components/datasets/settings/form/components/indexing-section.spec.tsx rename to web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx index bf1448b933..f49bdbc576 100644 --- a/web/app/components/datasets/settings/form/components/indexing-section.spec.tsx +++ b/web/app/components/datasets/settings/form/components/__tests__/indexing-section.spec.tsx @@ -5,8 +5,8 @@ import { fireEvent, render, screen } from '@testing-library/react' import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import { IndexingType } from '../../../create/step-two' -import IndexingSection from './indexing-section' +import { IndexingType } from '../../../../create/step-two' +import IndexingSection from '../indexing-section' // Mock i18n doc link vi.mock('@/context/i18n', () => ({ diff --git a/web/app/components/datasets/settings/form/hooks/use-form-state.spec.ts b/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts similarity index 99% rename from web/app/components/datasets/settings/form/hooks/use-form-state.spec.ts rename to web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts index f79500544b..f27b542b1e 100644 --- a/web/app/components/datasets/settings/form/hooks/use-form-state.spec.ts +++ b/web/app/components/datasets/settings/form/hooks/__tests__/use-form-state.spec.ts @@ -3,8 +3,8 @@ import type { RetrievalConfig } from '@/types/app' import { act, renderHook, waitFor } from '@testing-library/react' import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets' import { RETRIEVE_METHOD } from '@/types/app' -import { IndexingType } from '../../../create/step-two' -import { useFormState } from './use-form-state' +import { IndexingType } from '../../../../create/step-two' +import { useFormState } from '../use-form-state' // Mock contexts const mockMutateDatasets = vi.fn() diff --git a/web/app/components/datasets/settings/index-method/index.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx similarity index 97% rename from web/app/components/datasets/settings/index-method/index.spec.tsx rename to web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx index dbb886c676..dbdb9cf6f1 100644 --- a/web/app/components/datasets/settings/index-method/index.spec.tsx +++ b/web/app/components/datasets/settings/index-method/__tests__/index.spec.tsx @@ -1,8 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' -import { IndexingType } from '../../create/step-two' -import IndexMethod from './index' - -// Note: react-i18next is globally mocked in vitest.setup.ts +import { IndexingType } from '../../../create/step-two' +import IndexMethod from '../index' describe('IndexMethod', () => { const defaultProps = { @@ -92,7 +90,6 @@ describe('IndexMethod', () => { const handleChange = vi.fn() render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} />) - // Click on already active High Quality const highQualityTitle = screen.getByText(/stepTwo\.qualified/) const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement fireEvent.click(card!) diff --git a/web/app/components/datasets/settings/index-method/keyword-number.spec.tsx b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx similarity index 98% rename from web/app/components/datasets/settings/index-method/keyword-number.spec.tsx rename to web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx index f0f7f69de5..42d3b953f5 100644 --- a/web/app/components/datasets/settings/index-method/keyword-number.spec.tsx +++ b/web/app/components/datasets/settings/index-method/__tests__/keyword-number.spec.tsx @@ -1,7 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' -import KeyWordNumber from './keyword-number' - -// Note: react-i18next is globally mocked in vitest.setup.ts +import KeyWordNumber from '../keyword-number' describe('KeyWordNumber', () => { const defaultProps = { diff --git a/web/app/components/datasets/settings/permission-selector/index.spec.tsx b/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx similarity index 96% rename from web/app/components/datasets/settings/permission-selector/index.spec.tsx rename to web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx index 0e8a82c102..987d524090 100644 --- a/web/app/components/datasets/settings/permission-selector/index.spec.tsx +++ b/web/app/components/datasets/settings/permission-selector/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import type { Member } from '@/models/common' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { DatasetPermission } from '@/models/datasets' -import PermissionSelector from './index' +import PermissionSelector from '../index' // Mock app-context vi.mock('@/context/app-context', () => ({ @@ -14,8 +14,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -// Note: react-i18next is globally mocked in vitest.setup.ts - describe('PermissionSelector', () => { const mockMemberList: Member[] = [ { id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', last_login_at: '', created_at: '', status: 'active' }, @@ -94,12 +92,10 @@ describe('PermissionSelector', () => { const handleChange = vi.fn() render(<PermissionSelector {...defaultProps} onChange={handleChange} permission={DatasetPermission.allTeamMembers} />) - // Open dropdown const trigger = screen.getByText(/form\.permissionsAllMember/) fireEvent.click(trigger) await waitFor(() => { - // Click Only Me option const onlyMeOptions = screen.getAllByText(/form\.permissionsOnlyMe/) fireEvent.click(onlyMeOptions[0]) }) @@ -111,12 +107,10 @@ describe('PermissionSelector', () => { const handleChange = vi.fn() render(<PermissionSelector {...defaultProps} onChange={handleChange} />) - // Open dropdown const trigger = screen.getByText(/form\.permissionsOnlyMe/) fireEvent.click(trigger) await waitFor(() => { - // Click All Team Members option const allMemberOptions = screen.getAllByText(/form\.permissionsAllMember/) fireEvent.click(allMemberOptions[0]) }) @@ -135,12 +129,10 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByText(/form\.permissionsOnlyMe/) fireEvent.click(trigger) await waitFor(() => { - // Click Invited Members option const invitedOptions = screen.getAllByText(/form\.permissionsInvitedMembers/) fireEvent.click(invitedOptions[0]) }) @@ -159,7 +151,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -180,12 +171,10 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) await waitFor(() => { - // Click on John Doe const johnDoe = screen.getByText('John Doe') fireEvent.click(johnDoe) }) @@ -204,12 +193,10 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) await waitFor(() => { - // Click on John Doe to deselect const johnDoe = screen.getByText('John Doe') fireEvent.click(johnDoe) }) @@ -227,7 +214,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -247,7 +233,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -264,7 +249,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -291,7 +275,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -302,7 +285,6 @@ describe('PermissionSelector', () => { fireEvent.change(searchInput, { target: { value: 'test' } }) expect(searchInput).toHaveValue('test') - // Click the clear button using data-testid const clearButton = screen.getByTestId('input-clear') fireEvent.click(clearButton) @@ -320,7 +302,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -347,7 +328,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -374,7 +354,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) @@ -399,7 +378,6 @@ describe('PermissionSelector', () => { />, ) - // Open dropdown const trigger = screen.getByTitle(/Current User/) fireEvent.click(trigger) diff --git a/web/app/components/datasets/settings/permission-selector/member-item.spec.tsx b/web/app/components/datasets/settings/permission-selector/__tests__/member-item.spec.tsx similarity index 98% rename from web/app/components/datasets/settings/permission-selector/member-item.spec.tsx rename to web/app/components/datasets/settings/permission-selector/__tests__/member-item.spec.tsx index 02d453db7c..bd3d830137 100644 --- a/web/app/components/datasets/settings/permission-selector/member-item.spec.tsx +++ b/web/app/components/datasets/settings/permission-selector/__tests__/member-item.spec.tsx @@ -1,7 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' -import MemberItem from './member-item' - -// Note: react-i18next is globally mocked in vitest.setup.ts +import MemberItem from '../member-item' describe('MemberItem', () => { const defaultProps = { diff --git a/web/app/components/datasets/settings/permission-selector/permission-item.spec.tsx b/web/app/components/datasets/settings/permission-selector/__tests__/permission-item.spec.tsx similarity index 98% rename from web/app/components/datasets/settings/permission-selector/permission-item.spec.tsx rename to web/app/components/datasets/settings/permission-selector/__tests__/permission-item.spec.tsx index 5f6b881bd4..5054bb3b9b 100644 --- a/web/app/components/datasets/settings/permission-selector/permission-item.spec.tsx +++ b/web/app/components/datasets/settings/permission-selector/__tests__/permission-item.spec.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' -import PermissionItem from './permission-item' +import PermissionItem from '../permission-item' describe('PermissionItem', () => { const defaultProps = { diff --git a/web/app/components/datasets/settings/utils/index.spec.ts b/web/app/components/datasets/settings/utils/__tests__/index.spec.ts similarity index 98% rename from web/app/components/datasets/settings/utils/index.spec.ts rename to web/app/components/datasets/settings/utils/__tests__/index.spec.ts index 5a9099e51f..9a51873b1f 100644 --- a/web/app/components/datasets/settings/utils/index.spec.ts +++ b/web/app/components/datasets/settings/utils/__tests__/index.spec.ts @@ -1,7 +1,7 @@ import type { DefaultModel, Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations' import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { IndexingType } from '../../create/step-two' -import { checkShowMultiModalTip } from './index' +import { IndexingType } from '../../../create/step-two' +import { checkShowMultiModalTip } from '../index' describe('checkShowMultiModalTip', () => { // Helper to create a model item with specific features diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index a939fd7d2f..e49d1d8d23 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -3091,21 +3091,11 @@ "count": 3 } }, - "app/components/datasets/common/document-picker/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/datasets/common/document-picker/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 } }, - "app/components/datasets/common/document-picker/preview-document-picker.spec.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/datasets/common/document-picker/preview-document-picker.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 2 @@ -3144,11 +3134,6 @@ "count": 4 } }, - "app/components/datasets/common/retrieval-method-config/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/datasets/common/retrieval-method-info/index.tsx": { "react-refresh/only-export-components": { "count": 1 @@ -3247,11 +3232,6 @@ "count": 1 } }, - "app/components/datasets/create/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 16 - } - }, "app/components/datasets/create/notion-page-preview/index.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 @@ -3270,11 +3250,6 @@ "count": 1 } }, - "app/components/datasets/create/step-three/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 7 - } - }, "app/components/datasets/create/step-three/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 7 @@ -3330,11 +3305,6 @@ "count": 2 } }, - "app/components/datasets/create/stop-embedding-modal/index.spec.tsx": { - "test/prefer-hooks-in-order": { - "count": 1 - } - }, "app/components/datasets/create/top-bar/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -3442,11 +3412,6 @@ "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 4 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/item.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -3457,31 +3422,16 @@ "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/base/header.spec.tsx": { - "ts/no-explicit-any": { - "count": 4 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 10 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx": { "ts/no-explicit-any": { "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 5 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 @@ -3492,11 +3442,6 @@ "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 2 @@ -3532,11 +3477,6 @@ "count": 3 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 3 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/empty-folder.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -3547,11 +3487,6 @@ "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 2 @@ -3562,11 +3497,6 @@ "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 11 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 5 @@ -3612,11 +3542,6 @@ "count": 2 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 9 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -3625,11 +3550,6 @@ "count": 1 } }, - "app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.spec.tsx": { - "ts/no-explicit-any": { - "count": 11 - } - }, "app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.tsx": { "ts/no-explicit-any": { "count": 2