From cb51a449d3696c9356a816b026ca68e1c47462cf Mon Sep 17 00:00:00 2001 From: kurokobo Date: Tue, 20 Jan 2026 10:30:50 +0900 Subject: [PATCH 01/63] fix: correct i18n for stepOne.uploader.tip (#31177) --- web/i18n/de-DE/dataset-creation.json | 2 +- web/i18n/es-ES/dataset-creation.json | 2 +- web/i18n/fa-IR/dataset-creation.json | 2 +- web/i18n/fr-FR/dataset-creation.json | 2 +- web/i18n/hi-IN/dataset-creation.json | 2 +- web/i18n/it-IT/dataset-creation.json | 2 +- web/i18n/ja-JP/dataset-creation.json | 2 +- web/i18n/ko-KR/dataset-creation.json | 2 +- web/i18n/pl-PL/dataset-creation.json | 2 +- web/i18n/pt-BR/dataset-creation.json | 2 +- web/i18n/ro-RO/dataset-creation.json | 2 +- web/i18n/ru-RU/dataset-creation.json | 2 +- web/i18n/sl-SI/dataset-creation.json | 2 +- web/i18n/th-TH/dataset-creation.json | 2 +- web/i18n/tr-TR/dataset-creation.json | 2 +- web/i18n/uk-UA/dataset-creation.json | 2 +- web/i18n/vi-VN/dataset-creation.json | 2 +- web/i18n/zh-Hant/dataset-creation.json | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) diff --git a/web/i18n/de-DE/dataset-creation.json b/web/i18n/de-DE/dataset-creation.json index 8de041f592..b6c4ac41f4 100644 --- a/web/i18n/de-DE/dataset-creation.json +++ b/web/i18n/de-DE/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "Abbrechen", "stepOne.uploader.change": "Ändern", "stepOne.uploader.failed": "Hochladen fehlgeschlagen", - "stepOne.uploader.tip": "Unterstützt {{supportTypes}}. Maximal {{size}}MB pro Datei.", + "stepOne.uploader.tip": "Unterstützt {{supportTypes}}. Maximal {{batchCount}} Dateien pro Batch und {{size}} MB pro Datei. Insgesamt maximal {{totalCount}} Dateien.", "stepOne.uploader.title": "Textdatei hochladen", "stepOne.uploader.validation.count": "Mehrere Dateien nicht unterstützt", "stepOne.uploader.validation.filesNumber": "Sie haben das Limit für die Stapelverarbeitung von {{filesNumber}} erreicht.", diff --git a/web/i18n/es-ES/dataset-creation.json b/web/i18n/es-ES/dataset-creation.json index cd03091909..1de3d76e25 100644 --- a/web/i18n/es-ES/dataset-creation.json +++ b/web/i18n/es-ES/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "Cancelar", "stepOne.uploader.change": "Cambiar", "stepOne.uploader.failed": "Error al cargar", - "stepOne.uploader.tip": "Soporta {{supportTypes}}. Máximo {{size}}MB cada uno.", + "stepOne.uploader.tip": "Soporta {{supportTypes}}. Máximo {{batchCount}} archivos por lote y {{size}} MB cada uno. Total máximo de {{totalCount}} archivos.", "stepOne.uploader.title": "Cargar archivo", "stepOne.uploader.validation.count": "No se admiten varios archivos", "stepOne.uploader.validation.filesNumber": "Has alcanzado el límite de carga por lotes de {{filesNumber}}.", diff --git a/web/i18n/fa-IR/dataset-creation.json b/web/i18n/fa-IR/dataset-creation.json index e585736eb8..3ab4cdac51 100644 --- a/web/i18n/fa-IR/dataset-creation.json +++ b/web/i18n/fa-IR/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "لغو", "stepOne.uploader.change": "تغییر", "stepOne.uploader.failed": "بارگذاری ناموفق بود", - "stepOne.uploader.tip": "پشتیبانی از {{supportTypes}}. حداکثر {{size}}MB هر کدام.", + "stepOne.uploader.tip": "پشتیبانی از {{supportTypes}}. حداکثر {{batchCount}} فایل در هر دسته و {{size}} مگابایت برای هر فایل. حداکثر کل {{totalCount}} فایل.", "stepOne.uploader.title": "بارگذاری فایل", "stepOne.uploader.validation.count": "چندین فایل پشتیبانی نمیشود", "stepOne.uploader.validation.filesNumber": "شما به حد مجاز بارگذاری دستهای {{filesNumber}} رسیدهاید.", diff --git a/web/i18n/fr-FR/dataset-creation.json b/web/i18n/fr-FR/dataset-creation.json index b617099b76..3f1e61284c 100644 --- a/web/i18n/fr-FR/dataset-creation.json +++ b/web/i18n/fr-FR/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "Annuler", "stepOne.uploader.change": "Changer", "stepOne.uploader.failed": "Le téléchargement a échoué", - "stepOne.uploader.tip": "Prend en charge {{supportTypes}}. Max {{size}}MB chacun.", + "stepOne.uploader.tip": "Prend en charge {{supportTypes}}. Maximum {{batchCount}} fichiers par lot et {{size}} MB chacun. Maximum total de {{totalCount}} fichiers.", "stepOne.uploader.title": "Télécharger le fichier texte", "stepOne.uploader.validation.count": "Plusieurs fichiers non pris en charge", "stepOne.uploader.validation.filesNumber": "Vous avez atteint la limite de téléchargement par lot de {{filesNumber}}.", diff --git a/web/i18n/hi-IN/dataset-creation.json b/web/i18n/hi-IN/dataset-creation.json index 14d8c37224..62cd7960cb 100644 --- a/web/i18n/hi-IN/dataset-creation.json +++ b/web/i18n/hi-IN/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "रद्द करें", "stepOne.uploader.change": "बदलें", "stepOne.uploader.failed": "अपलोड विफल रहा", - "stepOne.uploader.tip": "समर्थित {{supportTypes}}। प्रत्येक अधिकतम {{size}}MB।", + "stepOne.uploader.tip": "{{supportTypes}} समर्थित है। एक बैच में अधिकतम {{batchCount}} फ़ाइलें और प्रत्येक {{size}} MB। कुल अधिकतम {{totalCount}} फ़ाइलें।", "stepOne.uploader.title": "फ़ाइल अपलोड करें", "stepOne.uploader.validation.count": "एकाधिक फ़ाइलें समर्थित नहीं हैं", "stepOne.uploader.validation.filesNumber": "आपने {{filesNumber}} की बैच अपलोड सीमा तक पहुँच गए हैं।", diff --git a/web/i18n/it-IT/dataset-creation.json b/web/i18n/it-IT/dataset-creation.json index 0f6fbe5ec2..5e420be5b6 100644 --- a/web/i18n/it-IT/dataset-creation.json +++ b/web/i18n/it-IT/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "Annulla", "stepOne.uploader.change": "Cambia", "stepOne.uploader.failed": "Caricamento fallito", - "stepOne.uploader.tip": "Supporta {{supportTypes}}. Max {{size}}MB ciascuno.", + "stepOne.uploader.tip": "Supporta {{supportTypes}}. Massimo {{batchCount}} file per batch e {{size}} MB ciascuno. Totale massimo {{totalCount}} file.", "stepOne.uploader.title": "Carica file", "stepOne.uploader.validation.count": "Più file non supportati", "stepOne.uploader.validation.filesNumber": "Hai raggiunto il limite di caricamento batch di {{filesNumber}}.", diff --git a/web/i18n/ja-JP/dataset-creation.json b/web/i18n/ja-JP/dataset-creation.json index b41412eae7..3115b69070 100644 --- a/web/i18n/ja-JP/dataset-creation.json +++ b/web/i18n/ja-JP/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "キャンセル", "stepOne.uploader.change": "変更", "stepOne.uploader.failed": "アップロードに失敗しました", - "stepOne.uploader.tip": "{{supportTypes}}をサポートしています。1 つあたりの最大サイズは{{size}}MB です。", + "stepOne.uploader.tip": "{{supportTypes}}をサポートしています。1バッチあたり最大{{batchCount}}ファイル、各ファイル{{size}}MB まで。合計最大{{totalCount}}ファイル。", "stepOne.uploader.title": "テキストファイルをアップロード", "stepOne.uploader.validation.count": "複数のファイルはサポートされていません", "stepOne.uploader.validation.filesNumber": "バッチアップロードの制限({{filesNumber}}個)に達しました。", diff --git a/web/i18n/ko-KR/dataset-creation.json b/web/i18n/ko-KR/dataset-creation.json index f61a893ab0..a31b3428ed 100644 --- a/web/i18n/ko-KR/dataset-creation.json +++ b/web/i18n/ko-KR/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "취소", "stepOne.uploader.change": "변경", "stepOne.uploader.failed": "업로드에 실패했습니다", - "stepOne.uploader.tip": "{{supportTypes}}을 (를) 지원합니다. 파일당 최대 크기는 {{size}}MB 입니다.", + "stepOne.uploader.tip": "{{supportTypes}}을(를) 지원합니다. 배치당 최대 {{batchCount}}개 파일, 각 파일당 {{size}}MB까지. 총 최대 {{totalCount}}개 파일.", "stepOne.uploader.title": "텍스트 파일 업로드", "stepOne.uploader.validation.count": "여러 파일은 지원되지 않습니다", "stepOne.uploader.validation.filesNumber": "일괄 업로드 제한 ({{filesNumber}}개) 에 도달했습니다.", diff --git a/web/i18n/pl-PL/dataset-creation.json b/web/i18n/pl-PL/dataset-creation.json index c6bee81db9..c07e163509 100644 --- a/web/i18n/pl-PL/dataset-creation.json +++ b/web/i18n/pl-PL/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "Anuluj", "stepOne.uploader.change": "Zmień", "stepOne.uploader.failed": "Przesyłanie nie powiodło się", - "stepOne.uploader.tip": "Obsługuje {{supportTypes}}. Maksymalnie {{size}}MB każdy.", + "stepOne.uploader.tip": "Obsługuje {{supportTypes}}. Maksymalnie {{batchCount}} plików w partii, każdy do {{size}} MB. Łącznie maksymalnie {{totalCount}} plików.", "stepOne.uploader.title": "Prześlij plik tekstowy", "stepOne.uploader.validation.count": "Nieobsługiwane przesyłanie wielu plików", "stepOne.uploader.validation.filesNumber": "Osiągnąłeś limit przesłania partii {{filesNumber}}.", diff --git a/web/i18n/pt-BR/dataset-creation.json b/web/i18n/pt-BR/dataset-creation.json index 1f2197082b..9ecd18f464 100644 --- a/web/i18n/pt-BR/dataset-creation.json +++ b/web/i18n/pt-BR/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "Cancelar", "stepOne.uploader.change": "Alterar", "stepOne.uploader.failed": "Falha no envio", - "stepOne.uploader.tip": "Suporta {{supportTypes}}. Máximo de {{size}}MB cada.", + "stepOne.uploader.tip": "Suporta {{supportTypes}}. Máximo de {{batchCount}} arquivos por lote e {{size}} MB cada. Total máximo de {{totalCount}} arquivos.", "stepOne.uploader.title": "Enviar arquivo de texto", "stepOne.uploader.validation.count": "Vários arquivos não suportados", "stepOne.uploader.validation.filesNumber": "Limite de upload em massa {{filesNumber}}.", diff --git a/web/i18n/ro-RO/dataset-creation.json b/web/i18n/ro-RO/dataset-creation.json index fc73a06536..7fc3fcca6e 100644 --- a/web/i18n/ro-RO/dataset-creation.json +++ b/web/i18n/ro-RO/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "Anulează", "stepOne.uploader.change": "Schimbă", "stepOne.uploader.failed": "Încărcarea a eșuat", - "stepOne.uploader.tip": "Acceptă {{supportTypes}}. Maxim {{size}}MB fiecare.", + "stepOne.uploader.tip": "Acceptă {{supportTypes}}. Maxim {{batchCount}} fișiere pe lot și {{size}} MB fiecare. Total maxim {{totalCount}} fișiere.", "stepOne.uploader.title": "Încărcați fișier text", "stepOne.uploader.validation.count": "Nu se acceptă mai multe fișiere", "stepOne.uploader.validation.filesNumber": "Ați atins limita de încărcare în lot de {{filesNumber}} fișiere.", diff --git a/web/i18n/ru-RU/dataset-creation.json b/web/i18n/ru-RU/dataset-creation.json index 4a8e7db9c1..b981139de4 100644 --- a/web/i18n/ru-RU/dataset-creation.json +++ b/web/i18n/ru-RU/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "Отмена", "stepOne.uploader.change": "Изменить", "stepOne.uploader.failed": "Ошибка загрузки", - "stepOne.uploader.tip": "Поддерживаются {{supportTypes}}. Максимум {{size}} МБ каждый.", + "stepOne.uploader.tip": "Поддерживаются {{supportTypes}}. Максимум {{batchCount}} файлов за раз, каждый до {{size}} МБ. Всего максимум {{totalCount}} файлов.", "stepOne.uploader.title": "Загрузить файл", "stepOne.uploader.validation.count": "Несколько файлов не поддерживаются", "stepOne.uploader.validation.filesNumber": "Вы достигли лимита пакетной загрузки {{filesNumber}} файлов.", diff --git a/web/i18n/sl-SI/dataset-creation.json b/web/i18n/sl-SI/dataset-creation.json index 809ed55bdb..cf6610b9fb 100644 --- a/web/i18n/sl-SI/dataset-creation.json +++ b/web/i18n/sl-SI/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "Prekliči", "stepOne.uploader.change": "Zamenjaj", "stepOne.uploader.failed": "Nalaganje ni uspelo", - "stepOne.uploader.tip": "Podprti tipi datotek: {{supportTypes}}. Največ {{size}}MB na datoteko.", + "stepOne.uploader.tip": "Podpira {{supportTypes}}. Največje število datotek v seriji: {{batchCount}}, vsaka do {{size}} MB. Skupaj največ {{totalCount}} datotek.", "stepOne.uploader.title": "Naloži datoteko", "stepOne.uploader.validation.count": "Podprta je le ena datoteka", "stepOne.uploader.validation.filesNumber": "Dosegli ste omejitev za pošiljanje {{filesNumber}} datotek.", diff --git a/web/i18n/th-TH/dataset-creation.json b/web/i18n/th-TH/dataset-creation.json index f9123cec28..68e7e3a78c 100644 --- a/web/i18n/th-TH/dataset-creation.json +++ b/web/i18n/th-TH/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "ยกเลิก", "stepOne.uploader.change": "เปลี่ยน", "stepOne.uploader.failed": "อัปโหลดล้มเหลว", - "stepOne.uploader.tip": "รองรับ {{supportTypes}} สูงสุด {{size}}MB แต่ละตัว", + "stepOne.uploader.tip": "รองรับ {{supportTypes}} สูงสุด {{batchCount}} ไฟล์ต่อชุดและ {{size}} MB แต่ละไฟล์ รวมสูงสุด {{totalCount}} ไฟล์", "stepOne.uploader.title": "อัปโหลดไฟล์", "stepOne.uploader.validation.count": "ไม่รองรับหลายไฟล์", "stepOne.uploader.validation.filesNumber": "คุณถึงขีดจํากัดการอัปโหลดเป็นชุดของ {{filesNumber}} แล้ว", diff --git a/web/i18n/tr-TR/dataset-creation.json b/web/i18n/tr-TR/dataset-creation.json index abce8861c3..7aec43e6b4 100644 --- a/web/i18n/tr-TR/dataset-creation.json +++ b/web/i18n/tr-TR/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "İptal", "stepOne.uploader.change": "Değiştir", "stepOne.uploader.failed": "Yükleme başarısız", - "stepOne.uploader.tip": "Destekler {{supportTypes}}. Her biri en fazla {{size}}MB.", + "stepOne.uploader.tip": "{{supportTypes}} destekler. Parti başına en fazla {{batchCount}} dosya ve her biri {{size}} MB. Toplam en fazla {{totalCount}} dosya.", "stepOne.uploader.title": "Dosya yükle", "stepOne.uploader.validation.count": "Birden fazla dosya desteklenmiyor", "stepOne.uploader.validation.filesNumber": "Toplu yükleme sınırına ulaştınız, {{filesNumber}} dosya.", diff --git a/web/i18n/uk-UA/dataset-creation.json b/web/i18n/uk-UA/dataset-creation.json index 87cdd7d84f..781151fcd7 100644 --- a/web/i18n/uk-UA/dataset-creation.json +++ b/web/i18n/uk-UA/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "Скасувати", "stepOne.uploader.change": "Змінити", "stepOne.uploader.failed": "Завантаження не вдалося", - "stepOne.uploader.tip": "Підтримуються {{supportTypes}}. Максимум {{size}} МБ кожен.", + "stepOne.uploader.tip": "Підтримуються {{supportTypes}}. Максимум {{batchCount}} файлів за раз, кожен до {{size}} МБ. Загалом максимум {{totalCount}} файлів.", "stepOne.uploader.title": "Завантажити текстовий файл", "stepOne.uploader.validation.count": "Не підтримується завантаження кількох файлів", "stepOne.uploader.validation.filesNumber": "Ліміт масового завантаження {{filesNumber}}.", diff --git a/web/i18n/vi-VN/dataset-creation.json b/web/i18n/vi-VN/dataset-creation.json index 81f2fb11ca..4111f3f6f3 100644 --- a/web/i18n/vi-VN/dataset-creation.json +++ b/web/i18n/vi-VN/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "Hủy", "stepOne.uploader.change": "Thay đổi", "stepOne.uploader.failed": "Tải lên thất bại", - "stepOne.uploader.tip": "Hỗ trợ {{supportTypes}}. Tối đa {{size}}MB mỗi tệp.", + "stepOne.uploader.tip": "Hỗ trợ {{supportTypes}}. Tối đa {{batchCount}} tệp trong một lô và {{size}} MB mỗi tệp. Tổng tối đa {{totalCount}} tệp.", "stepOne.uploader.title": "Tải lên tệp văn bản", "stepOne.uploader.validation.count": "Không hỗ trợ tải lên nhiều tệp", "stepOne.uploader.validation.filesNumber": "Bạn đã đạt đến giới hạn tải lên lô của {{filesNumber}} tệp.", diff --git a/web/i18n/zh-Hant/dataset-creation.json b/web/i18n/zh-Hant/dataset-creation.json index 49ad034032..b72a92ac50 100644 --- a/web/i18n/zh-Hant/dataset-creation.json +++ b/web/i18n/zh-Hant/dataset-creation.json @@ -35,7 +35,7 @@ "stepOne.uploader.cancel": "取消", "stepOne.uploader.change": "更改檔案", "stepOne.uploader.failed": "上傳失敗", - "stepOne.uploader.tip": "已支援 {{supportTypes}},每個檔案不超過 {{size}}MB。", + "stepOne.uploader.tip": "支援 {{supportTypes}}。每批最多 {{batchCount}} 個檔案,每個檔案不超過 {{size}} MB,總數不超過 {{totalCount}} 個檔案。", "stepOne.uploader.title": "上傳文字檔案", "stepOne.uploader.validation.count": "暫不支援多個檔案", "stepOne.uploader.validation.filesNumber": "批次上傳限制 {{filesNumber}}。", From 45b8d033be5a43c068022766d6353d8124beaa8d Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:08:50 +0800 Subject: [PATCH 02/63] chore: init tsslint (#31209) Co-authored-by: Johnson Chu Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/style.yml | 5 + web/.vscode/extensions.json | 4 +- web/package.json | 4 + web/pnpm-lock.yaml | 246 ++++++++++++++++++++++++++++++++++++ web/tsslint.config.ts | 11 ++ 5 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 web/tsslint.config.ts diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index debf4ba648..5551030f1e 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -117,6 +117,11 @@ jobs: # eslint-report: web/eslint_report.json # github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Web tsslint + if: steps.changed-files.outputs.any_changed == 'true' + working-directory: ./web + run: pnpm run lint:tss + - name: Web type check if: steps.changed-files.outputs.any_changed == 'true' working-directory: ./web diff --git a/web/.vscode/extensions.json b/web/.vscode/extensions.json index 68f5c7bf0e..01235650ab 100644 --- a/web/.vscode/extensions.json +++ b/web/.vscode/extensions.json @@ -1,6 +1,8 @@ { "recommendations": [ "bradlc.vscode-tailwindcss", - "kisstkondoros.vscode-codemetrics" + "kisstkondoros.vscode-codemetrics", + "johnsoncodehk.vscode-tsslint", + "dbaeumer.vscode-eslint" ] } diff --git a/web/package.json b/web/package.json index 5dfacece73..12e7ac3b17 100644 --- a/web/package.json +++ b/web/package.json @@ -33,6 +33,7 @@ "lint:quiet": "pnpm lint --quiet", "lint:complexity": "pnpm lint --rule 'complexity: [error, {max: 15}]' --quiet", "lint:report": "pnpm lint --output-file eslint_report.json --format json", + "lint:tss": "tsslint --project tsconfig.json", "type-check": "tsc --noEmit", "type-check:tsgo": "tsgo --noEmit", "prepare": "cd ../ && node -e \"if (process.env.NODE_ENV !== 'production'){process.exit(1)} \" || husky ./web/.husky", @@ -178,6 +179,9 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@tsslint/cli": "^3.0.1", + "@tsslint/compat-eslint": "^3.0.1", + "@tsslint/config": "^3.0.1", "@types/js-cookie": "^3.0.6", "@types/js-yaml": "^4.0.9", "@types/negotiator": "^0.6.4", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a6c31f2f62..0a549f8a61 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -430,6 +430,15 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@tsslint/cli': + specifier: ^3.0.1 + version: 3.0.1(@tsslint/compat-eslint@3.0.1(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3) + '@tsslint/compat-eslint': + specifier: ^3.0.1 + version: 3.0.1(jiti@1.21.7)(typescript@5.9.3) + '@tsslint/config': + specifier: ^3.0.1 + version: 3.0.1(@tsslint/compat-eslint@3.0.1(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3) '@types/js-cookie': specifier: ^3.0.6 version: 3.0.6 @@ -1644,14 +1653,30 @@ packages: eslint: optional: true + '@eslint/config-array@0.20.1': + resolution: {integrity: sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-array@0.21.1': resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.2.3': + resolution: {integrity: sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/config-helpers@0.4.2': resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@0.14.0': + resolution: {integrity: sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.15.2': + resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@0.17.0': resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1664,6 +1689,10 @@ packages: resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@9.27.0': + resolution: {integrity: sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@9.39.2': resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1676,6 +1705,10 @@ packages: resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.3.5': + resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/plugin-kit@0.4.1': resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1990,6 +2023,14 @@ packages: cpu: [x64] os: [win32] + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -3462,6 +3503,36 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@tsslint/cli@3.0.1': + resolution: {integrity: sha512-y5yzMFl6sKQNsomuGInmFzMiKW37xxDcJauHnPqYoCWL8LldNLnaUOBqx0illfNZ0FDAiSuV/oshC/NG8/F2Tw==} + engines: {node: '>=22.6.0'} + hasBin: true + peerDependencies: + typescript: '*' + + '@tsslint/compat-eslint@3.0.1': + resolution: {integrity: sha512-cojBaB1C9RxWjDfCvLBhbffshyizb+Cf1Os9NXHuzyQOPvU1IwYPW5Sxo1RU19pCOE9/TvQcuxgnGfwbkk/Dig==} + + '@tsslint/config@3.0.1': + resolution: {integrity: sha512-1S8YYLrZE22xfH3GtDXRO7YzkeQj9+FjoxaWhYQsjWDU82HHeSRWq5d2UzPSN/ac6WFmFq8yApXIGylfvrG6MA==} + engines: {node: '>=22.6.0'} + hasBin: true + peerDependencies: + '@tsslint/compat-eslint': 3.0.0-alpha.0 + tsl: ^1.0.28 + peerDependenciesMeta: + '@tsslint/compat-eslint': + optional: true + tsl: + optional: true + + '@tsslint/core@3.0.1': + resolution: {integrity: sha512-8FEczJ20hdpmEH5vm272hS3QAycsk5574yZT6VMS8TUK8kNY4qoRKY/gdOY0nYNYWZrRPs+6dr1TmEVPBZjlvw==} + engines: {node: '>=22.6.0'} + + '@tsslint/types@3.0.1': + resolution: {integrity: sha512-JPK/+tSJ2hPTwgN173fkenPEnAI2CD0r0FDJ23PfftTc0NM449ZiAFHvs1KuPUOjAvBFIo5BsLr7Kxc1Ekdgtw==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -3881,6 +3952,18 @@ packages: '@vitest/utils@4.0.17': resolution: {integrity: sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==} + '@volar/language-core@2.4.27': + resolution: {integrity: sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==} + + '@volar/language-hub@0.0.1': + resolution: {integrity: sha512-2eOUnlMKTyjtlXIVd+6pfAtcuVugxCOgpNgcLWmlPuncQTG5C1E5mTDL/PUMw7aEnLySUOtMTIp8lT3vk/7w6Q==} + + '@volar/source-map@2.4.27': + resolution: {integrity: sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==} + + '@volar/typescript@2.4.27': + resolution: {integrity: sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==} + '@vue/compiler-core@3.5.25': resolution: {integrity: sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==} @@ -5227,6 +5310,16 @@ packages: resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@9.27.0: + resolution: {integrity: sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6459,6 +6552,10 @@ packages: minimalistic-crypto-utils@1.0.1: resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + minimatch@10.1.1: + resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -9760,6 +9857,11 @@ snapshots: eslint: 9.39.2(jiti@1.21.7) ignore: 7.0.5 + '@eslint-community/eslint-utils@4.9.1(eslint@9.27.0(jiti@1.21.7))': + dependencies: + eslint: 9.27.0(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))': dependencies: eslint: 9.39.2(jiti@1.21.7) @@ -9848,6 +9950,14 @@ snapshots: optionalDependencies: eslint: 9.39.2(jiti@1.21.7) + '@eslint/config-array@0.20.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + '@eslint/config-array@0.21.1': dependencies: '@eslint/object-schema': 2.1.7 @@ -9856,10 +9966,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint/config-helpers@0.2.3': {} + '@eslint/config-helpers@0.4.2': dependencies: '@eslint/core': 0.17.0 + '@eslint/core@0.14.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/core@0.15.2': + dependencies: + '@types/json-schema': 7.0.15 + '@eslint/core@0.17.0': dependencies: '@types/json-schema': 7.0.15 @@ -9882,6 +10002,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint/js@9.27.0': {} + '@eslint/js@9.39.2': {} '@eslint/markdown@7.5.1': @@ -9900,6 +10022,11 @@ snapshots: '@eslint/object-schema@2.1.7': {} + '@eslint/plugin-kit@0.3.5': + dependencies: + '@eslint/core': 0.15.2 + levn: 0.4.1 + '@eslint/plugin-kit@0.4.1': dependencies: '@eslint/core': 0.17.0 @@ -10151,6 +10278,12 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + '@isaacs/cliui@8.0.2': dependencies: string-width: 4.2.3 @@ -11757,6 +11890,47 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@tsslint/cli@3.0.1(@tsslint/compat-eslint@3.0.1(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@clack/prompts': 0.8.2 + '@tsslint/config': 3.0.1(@tsslint/compat-eslint@3.0.1(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3) + '@tsslint/core': 3.0.1 + '@volar/language-core': 2.4.27 + '@volar/language-hub': 0.0.1 + '@volar/typescript': 2.4.27 + minimatch: 10.1.1 + typescript: 5.9.3 + transitivePeerDependencies: + - '@tsslint/compat-eslint' + - tsl + + '@tsslint/compat-eslint@3.0.1(jiti@1.21.7)(typescript@5.9.3)': + dependencies: + '@tsslint/types': 3.0.1 + '@typescript-eslint/parser': 8.53.0(eslint@9.27.0(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.27.0(jiti@1.21.7) + transitivePeerDependencies: + - jiti + - supports-color + - typescript + + '@tsslint/config@3.0.1(@tsslint/compat-eslint@3.0.1(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@tsslint/types': 3.0.1 + minimatch: 10.1.1 + ts-api-utils: 2.4.0(typescript@5.9.3) + optionalDependencies: + '@tsslint/compat-eslint': 3.0.1(jiti@1.21.7)(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@tsslint/core@3.0.1': + dependencies: + '@tsslint/types': 3.0.1 + minimatch: 10.1.1 + + '@tsslint/types@3.0.1': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -12036,6 +12210,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.53.0 + '@typescript-eslint/types': 8.53.0 + '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.53.0 + debug: 4.4.3 + eslint: 9.27.0(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.53.0 @@ -12250,6 +12436,20 @@ snapshots: '@vitest/pretty-format': 4.0.17 tinyrainbow: 3.0.3 + '@volar/language-core@2.4.27': + dependencies: + '@volar/source-map': 2.4.27 + + '@volar/language-hub@0.0.1': {} + + '@volar/source-map@2.4.27': {} + + '@volar/typescript@2.4.27': + dependencies: + '@volar/language-core': 2.4.27 + path-browserify: 1.0.1 + vscode-uri: 3.0.8 + '@vue/compiler-core@3.5.25': dependencies: '@babel/parser': 7.28.5 @@ -13823,6 +14023,48 @@ snapshots: eslint-visitor-keys@5.0.0: {} + eslint@9.27.0(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.27.0(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.20.1 + '@eslint/config-helpers': 0.2.3 + '@eslint/core': 0.14.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.27.0 + '@eslint/plugin-kit': 0.3.5 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + eslint@9.39.2(jiti@1.21.7): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) @@ -15454,6 +15696,10 @@ snapshots: minimalistic-crypto-utils@1.0.1: {} + minimatch@10.1.1: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + minimatch@3.1.2: dependencies: brace-expansion: 2.0.2 diff --git a/web/tsslint.config.ts b/web/tsslint.config.ts new file mode 100644 index 0000000000..10c427638b --- /dev/null +++ b/web/tsslint.config.ts @@ -0,0 +1,11 @@ +import { defineConfig, importESLintRules } from '@tsslint/config' + +// Run `npx tsslint-docgen` to generate documentation for the configured rules. + +export default defineConfig({ + rules: { + ...await importESLintRules({ + 'react-x/no-leaked-conditional-rendering': 'warn', + }), + }, +}) From a715c015e70f27ad37e6887712a160f1d430e753 Mon Sep 17 00:00:00 2001 From: cxhello <49056040+cxhello@users.noreply.github.com> Date: Tue, 20 Jan 2026 12:24:16 +0800 Subject: [PATCH 03/63] chore(web): remove redundant optimizePackageImports config (#31257) --- web/next.config.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/web/next.config.js b/web/next.config.js index 7b1f57adec..180ba05197 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -48,11 +48,6 @@ const nextConfig = { search: '', })), }, - experimental: { - optimizePackageImports: [ - '@heroicons/react', - ], - }, // fix all before production. Now it slow the develop speed. eslint: { // Warning: This allows production builds to successfully complete even if From 76b64dda52e5f4a6744651edfcbf873f21383fdd Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Tue, 20 Jan 2026 13:07:00 +0800 Subject: [PATCH 04/63] test: add tests for dataset list (#31231) Co-authored-by: CodingOnStar Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- .../external-api-modal/Form.spec.tsx | 239 ++ .../external-api-modal/index.spec.tsx | 424 +++ .../external-api-panel/index.spec.tsx | 207 ++ .../index.spec.tsx | 382 +++ .../datasets/extra-info/index.spec.tsx | 1169 ++++++++ .../datasets/hit-testing/index.spec.tsx | 2654 +++++++++++++++++ .../components/corner-labels.spec.tsx | 125 + .../components/dataset-card-footer.spec.tsx | 177 ++ .../components/dataset-card-header.spec.tsx | 254 ++ .../components/dataset-card-modals.spec.tsx | 237 ++ .../components/description.spec.tsx | 107 + .../components/operations-popover.spec.tsx | 162 + .../dataset-card/components/tag-area.spec.tsx | 198 ++ .../hooks/use-dataset-card-state.spec.ts | 427 +++ .../datasets/list/dataset-card/index.spec.tsx | 256 ++ .../list/dataset-card/operation-item.spec.tsx | 87 + .../list/dataset-card/operations.spec.tsx | 119 + .../list/dataset-footer/index.spec.tsx | 60 +- .../datasets/list/datasets.spec.tsx | 485 +++ .../components/datasets/list/index.spec.tsx | 368 +++ .../list/new-dataset-card/index.spec.tsx | 101 +- .../list/new-dataset-card/option.spec.tsx | 78 + .../metadata/add-metadata-button.spec.tsx | 92 + .../metadata/base/date-picker.spec.tsx | 287 ++ .../edit-metadata-batch/add-row.spec.tsx | 257 ++ .../edit-metadata-batch/edit-row.spec.tsx | 395 +++ .../edited-beacon.spec.tsx | 179 ++ .../input-combined.spec.tsx | 269 ++ .../input-has-set-multiple-value.spec.tsx | 147 + .../edit-metadata-batch/label.spec.tsx | 113 + .../edit-metadata-batch/modal.spec.tsx | 548 ++++ .../use-batch-edit-document-metadata.spec.ts | 647 ++++ .../hooks/use-check-metadata-name.spec.ts | 166 ++ .../hooks/use-edit-dataset-metadata.spec.ts | 308 ++ .../hooks/use-metadata-document.spec.ts | 587 ++++ .../metadata-dataset/create-content.spec.tsx | 268 ++ .../create-metadata-modal.spec.tsx | 246 ++ .../dataset-metadata-drawer.spec.tsx | 587 ++++ .../metadata/metadata-dataset/field.spec.tsx | 122 + .../select-metadata-modal.spec.tsx | 348 +++ .../metadata-dataset/select-metadata.spec.tsx | 332 +++ .../metadata/metadata-document/field.spec.tsx | 113 + .../metadata/metadata-document/index.spec.tsx | 752 +++++ .../metadata-document/info-group.spec.tsx | 341 +++ .../metadata-document/no-data.spec.tsx | 131 + .../datasets/metadata/utils/get-icon.spec.ts | 45 + .../datasets/rename-modal/index.spec.tsx | 1173 ++++++++ .../settings/chunk-structure/hooks.spec.tsx | 239 ++ .../settings/chunk-structure/index.spec.tsx | 176 +- .../settings/index-method/index.spec.tsx | 208 ++ .../index-method/keyword-number.spec.tsx | 171 ++ .../datasets/settings/option-card.spec.tsx | 317 ++ .../permission-selector/index.spec.tsx | 512 ++++ .../permission-selector/member-item.spec.tsx | 195 ++ .../permission-item.spec.tsx | 130 + .../datasets/settings/utils/index.spec.ts | 297 ++ 56 files changed, 18890 insertions(+), 124 deletions(-) create mode 100644 web/app/components/datasets/external-api/external-api-modal/Form.spec.tsx create mode 100644 web/app/components/datasets/external-api/external-api-modal/index.spec.tsx create mode 100644 web/app/components/datasets/external-api/external-api-panel/index.spec.tsx create mode 100644 web/app/components/datasets/external-api/external-knowledge-api-card/index.spec.tsx create mode 100644 web/app/components/datasets/extra-info/index.spec.tsx create mode 100644 web/app/components/datasets/hit-testing/index.spec.tsx create mode 100644 web/app/components/datasets/list/dataset-card/components/corner-labels.spec.tsx create mode 100644 web/app/components/datasets/list/dataset-card/components/dataset-card-footer.spec.tsx create mode 100644 web/app/components/datasets/list/dataset-card/components/dataset-card-header.spec.tsx create mode 100644 web/app/components/datasets/list/dataset-card/components/dataset-card-modals.spec.tsx create mode 100644 web/app/components/datasets/list/dataset-card/components/description.spec.tsx create mode 100644 web/app/components/datasets/list/dataset-card/components/operations-popover.spec.tsx create mode 100644 web/app/components/datasets/list/dataset-card/components/tag-area.spec.tsx create mode 100644 web/app/components/datasets/list/dataset-card/hooks/use-dataset-card-state.spec.ts create mode 100644 web/app/components/datasets/list/dataset-card/index.spec.tsx create mode 100644 web/app/components/datasets/list/dataset-card/operation-item.spec.tsx create mode 100644 web/app/components/datasets/list/dataset-card/operations.spec.tsx create mode 100644 web/app/components/datasets/list/datasets.spec.tsx create mode 100644 web/app/components/datasets/list/index.spec.tsx create mode 100644 web/app/components/datasets/list/new-dataset-card/option.spec.tsx create mode 100644 web/app/components/datasets/metadata/add-metadata-button.spec.tsx create mode 100644 web/app/components/datasets/metadata/base/date-picker.spec.tsx create mode 100644 web/app/components/datasets/metadata/edit-metadata-batch/add-row.spec.tsx create mode 100644 web/app/components/datasets/metadata/edit-metadata-batch/edit-row.spec.tsx create mode 100644 web/app/components/datasets/metadata/edit-metadata-batch/edited-beacon.spec.tsx create mode 100644 web/app/components/datasets/metadata/edit-metadata-batch/input-combined.spec.tsx create mode 100644 web/app/components/datasets/metadata/edit-metadata-batch/input-has-set-multiple-value.spec.tsx create mode 100644 web/app/components/datasets/metadata/edit-metadata-batch/label.spec.tsx create mode 100644 web/app/components/datasets/metadata/edit-metadata-batch/modal.spec.tsx create mode 100644 web/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata.spec.ts create mode 100644 web/app/components/datasets/metadata/hooks/use-check-metadata-name.spec.ts create mode 100644 web/app/components/datasets/metadata/hooks/use-edit-dataset-metadata.spec.ts create mode 100644 web/app/components/datasets/metadata/hooks/use-metadata-document.spec.ts create mode 100644 web/app/components/datasets/metadata/metadata-dataset/create-content.spec.tsx create mode 100644 web/app/components/datasets/metadata/metadata-dataset/create-metadata-modal.spec.tsx create mode 100644 web/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer.spec.tsx create mode 100644 web/app/components/datasets/metadata/metadata-dataset/field.spec.tsx create mode 100644 web/app/components/datasets/metadata/metadata-dataset/select-metadata-modal.spec.tsx create mode 100644 web/app/components/datasets/metadata/metadata-dataset/select-metadata.spec.tsx create mode 100644 web/app/components/datasets/metadata/metadata-document/field.spec.tsx create mode 100644 web/app/components/datasets/metadata/metadata-document/index.spec.tsx create mode 100644 web/app/components/datasets/metadata/metadata-document/info-group.spec.tsx create mode 100644 web/app/components/datasets/metadata/metadata-document/no-data.spec.tsx create mode 100644 web/app/components/datasets/metadata/utils/get-icon.spec.ts create mode 100644 web/app/components/datasets/rename-modal/index.spec.tsx create mode 100644 web/app/components/datasets/settings/chunk-structure/hooks.spec.tsx create mode 100644 web/app/components/datasets/settings/index-method/index.spec.tsx create mode 100644 web/app/components/datasets/settings/index-method/keyword-number.spec.tsx create mode 100644 web/app/components/datasets/settings/option-card.spec.tsx create mode 100644 web/app/components/datasets/settings/permission-selector/index.spec.tsx create mode 100644 web/app/components/datasets/settings/permission-selector/member-item.spec.tsx create mode 100644 web/app/components/datasets/settings/permission-selector/permission-item.spec.tsx create mode 100644 web/app/components/datasets/settings/utils/index.spec.ts 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/Form.spec.tsx new file mode 100644 index 0000000000..346bcd00b7 --- /dev/null +++ b/web/app/components/datasets/external-api/external-api-modal/Form.spec.tsx @@ -0,0 +1,239 @@ +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' + +// Mock context for i18n doc link +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.com${path}`, +})) + +describe('Form', () => { + const defaultFormSchemas: FormSchema[] = [ + { + variable: 'name', + type: 'text', + label: { en_US: 'Name', zh_CN: '名称' }, + required: true, + }, + { + variable: 'endpoint', + type: 'text', + label: { en_US: 'API Endpoint', zh_CN: 'API 端点' }, + required: true, + }, + { + variable: 'api_key', + type: 'secret', + label: { en_US: 'API Key', zh_CN: 'API 密钥' }, + required: true, + }, + ] + + const defaultValue: CreateExternalAPIReq = { + name: '', + settings: { + endpoint: '', + api_key: '', + }, + } + + const defaultProps = { + value: defaultValue, + onChange: vi.fn(), + formSchemas: defaultFormSchemas, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render(
) + expect(container.querySelector('form')).toBeInTheDocument() + }) + + it('should render all form fields based on formSchemas', () => { + render() + expect(screen.getByLabelText(/name/i)).toBeInTheDocument() + expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument() + expect(screen.getByLabelText(/api key/i)).toBeInTheDocument() + }) + + it('should render required indicator for required fields', () => { + render() + const labels = screen.getAllByText('*') + expect(labels.length).toBe(3) // All 3 fields are required + }) + + it('should render documentation link for endpoint field', () => { + render() + const docLink = screen.getByText('dataset.externalAPIPanelDocumentation') + expect(docLink).toBeInTheDocument() + expect(docLink.closest('a')).toHaveAttribute('href', expect.stringContaining('docs.example.com')) + }) + + it('should render password type input for secret fields', () => { + render() + const apiKeyInput = screen.getByLabelText(/api key/i) + expect(apiKeyInput).toHaveAttribute('type', 'password') + }) + + it('should render text type input for text fields', () => { + render() + const nameInput = screen.getByLabelText(/name/i) + expect(nameInput).toHaveAttribute('type', 'text') + }) + }) + + describe('Props', () => { + it('should apply custom className to form', () => { + const { container } = render() + expect(container.querySelector('form')).toHaveClass('custom-form-class') + }) + + it('should apply itemClassName to form items', () => { + const { container } = render() + const items = container.querySelectorAll('.custom-item-class') + expect(items.length).toBe(3) + }) + + it('should apply fieldLabelClassName to labels', () => { + const { container } = render() + const labels = container.querySelectorAll('label.custom-label-class') + expect(labels.length).toBe(3) + }) + + it('should apply inputClassName to inputs', () => { + render() + const inputs = screen.getAllByRole('textbox') + inputs.forEach((input) => { + expect(input).toHaveClass('custom-input-class') + }) + }) + + it('should display initial values', () => { + const valueWithData: CreateExternalAPIReq = { + name: 'Test API', + settings: { + endpoint: 'https://api.example.com', + api_key: 'secret-key', + }, + } + render() + expect(screen.getByLabelText(/name/i)).toHaveValue('Test API') + expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com') + expect(screen.getByLabelText(/api key/i)).toHaveValue('secret-key') + }) + }) + + describe('User Interactions', () => { + it('should call onChange when name field changes', () => { + const onChange = vi.fn() + render() + + const nameInput = screen.getByLabelText(/name/i) + fireEvent.change(nameInput, { target: { value: 'New API Name' } }) + + expect(onChange).toHaveBeenCalledWith({ + name: 'New API Name', + settings: { endpoint: '', api_key: '' }, + }) + }) + + it('should call onChange when endpoint field changes', () => { + const onChange = vi.fn() + render() + + const endpointInput = screen.getByLabelText(/api endpoint/i) + fireEvent.change(endpointInput, { target: { value: 'https://new-api.example.com' } }) + + expect(onChange).toHaveBeenCalledWith({ + name: '', + settings: { endpoint: 'https://new-api.example.com', api_key: '' }, + }) + }) + + it('should call onChange when api_key field changes', () => { + const onChange = vi.fn() + render() + + const apiKeyInput = screen.getByLabelText(/api key/i) + fireEvent.change(apiKeyInput, { target: { value: 'new-secret-key' } }) + + expect(onChange).toHaveBeenCalledWith({ + name: '', + settings: { endpoint: '', api_key: 'new-secret-key' }, + }) + }) + + it('should update settings without affecting name', () => { + const onChange = vi.fn() + const initialValue: CreateExternalAPIReq = { + name: 'Existing Name', + settings: { endpoint: '', api_key: '' }, + } + render() + + const endpointInput = screen.getByLabelText(/api endpoint/i) + fireEvent.change(endpointInput, { target: { value: 'https://api.example.com' } }) + + expect(onChange).toHaveBeenCalledWith({ + name: 'Existing Name', + settings: { endpoint: 'https://api.example.com', api_key: '' }, + }) + }) + }) + + describe('Edge Cases', () => { + it('should handle empty formSchemas', () => { + const { container } = render() + expect(container.querySelector('form')).toBeInTheDocument() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + + it('should handle optional field (required: false)', () => { + const schemasWithOptional: FormSchema[] = [ + { + variable: 'description', + type: 'text', + label: { en_US: 'Description' }, + required: false, + }, + ] + render() + expect(screen.queryByText('*')).not.toBeInTheDocument() + }) + + it('should fallback to en_US label when current language label is not available', () => { + const schemasWithEnOnly: FormSchema[] = [ + { + variable: 'test', + type: 'text', + label: { en_US: 'Test Field' }, + required: false, + }, + ] + render() + expect(screen.getByLabelText(/test field/i)).toBeInTheDocument() + }) + + it('should preserve existing settings when updating one field', () => { + const onChange = vi.fn() + const initialValue: CreateExternalAPIReq = { + name: '', + settings: { endpoint: 'https://existing.com', api_key: 'existing-key' }, + } + render() + + const endpointInput = screen.getByLabelText(/api endpoint/i) + fireEvent.change(endpointInput, { target: { value: 'https://new.com' } }) + + expect(onChange).toHaveBeenCalledWith({ + name: '', + settings: { endpoint: 'https://new.com', api_key: 'existing-key' }, + }) + }) + }) +}) 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/index.spec.tsx new file mode 100644 index 0000000000..94c4deab04 --- /dev/null +++ b/web/app/components/datasets/external-api/external-api-modal/index.spec.tsx @@ -0,0 +1,424 @@ +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' + +// Mock API service +vi.mock('@/service/datasets', () => ({ + createExternalAPI: vi.fn(), +})) + +// Mock toast context +const mockNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ + notify: mockNotify, + }), +})) + +describe('AddExternalAPIModal', () => { + const defaultProps = { + onSave: vi.fn(), + onCancel: vi.fn(), + isEditMode: false, + } + + const initialData: CreateExternalAPIReq = { + name: 'Test API', + settings: { + endpoint: 'https://api.example.com', + api_key: 'test-key-12345', + }, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument() + }) + + it('should render create title when not in edit mode', () => { + render() + expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument() + }) + + it('should render edit title when in edit mode', () => { + render() + expect(screen.getByText('dataset.editExternalAPIFormTitle')).toBeInTheDocument() + }) + + it('should render form fields', () => { + render() + expect(screen.getByLabelText(/name/i)).toBeInTheDocument() + expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument() + expect(screen.getByLabelText(/api key/i)).toBeInTheDocument() + }) + + it('should render cancel and save buttons', () => { + render() + expect(screen.getByText('dataset.externalAPIForm.cancel')).toBeInTheDocument() + expect(screen.getByText('dataset.externalAPIForm.save')).toBeInTheDocument() + }) + + it('should render encryption notice', () => { + render() + expect(screen.getByText('PKCS1_OAEP')).toBeInTheDocument() + }) + + it('should render close button', () => { + render() + // Close button is rendered in a portal + const closeButton = document.body.querySelector('.action-btn') + expect(closeButton).toBeInTheDocument() + }) + }) + + describe('Edit Mode with Dataset Bindings', () => { + it('should show warning when editing with dataset bindings', () => { + const datasetBindings = [ + { id: 'ds-1', name: 'Dataset 1' }, + { id: 'ds-2', name: 'Dataset 2' }, + ] + render( + , + ) + expect(screen.getByText('dataset.editExternalAPIFormWarning.front')).toBeInTheDocument() + // Verify the count is displayed in the warning section + const warningElement = screen.getByText('dataset.editExternalAPIFormWarning.front').parentElement + expect(warningElement?.textContent).toContain('2') + }) + + it('should not show warning when no dataset bindings', () => { + render( + , + ) + expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument() + }) + }) + + describe('Form Interactions', () => { + it('should update form values when input changes', () => { + render() + + const nameInput = screen.getByLabelText(/name/i) + fireEvent.change(nameInput, { target: { value: 'New API Name' } }) + expect(nameInput).toHaveValue('New API Name') + }) + + it('should initialize form with data in edit mode', () => { + render() + + expect(screen.getByLabelText(/name/i)).toHaveValue('Test API') + expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com') + expect(screen.getByLabelText(/api key/i)).toHaveValue('test-key-12345') + }) + + it('should disable save button when form has empty inputs', () => { + render() + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button') + expect(saveButton).toBeDisabled() + }) + + it('should enable save button when all fields are filled', () => { + render() + + const nameInput = screen.getByLabelText(/name/i) + const endpointInput = screen.getByLabelText(/api endpoint/i) + const apiKeyInput = screen.getByLabelText(/api key/i) + + fireEvent.change(nameInput, { target: { value: 'Test' } }) + fireEvent.change(endpointInput, { target: { value: 'https://test.com' } }) + fireEvent.change(apiKeyInput, { target: { value: 'key12345' } }) + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button') + expect(saveButton).not.toBeDisabled() + }) + }) + + describe('Create Mode - Save', () => { + it('should create API and call onSave on success', async () => { + const mockResponse = { + id: 'new-api-123', + tenant_id: 'tenant-1', + name: 'Test', + description: '', + settings: { endpoint: 'https://test.com', api_key: 'key12345' }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + } + vi.mocked(createExternalAPI).mockResolvedValue(mockResponse) + const onSave = vi.fn() + const onCancel = vi.fn() + + render() + + const nameInput = screen.getByLabelText(/name/i) + const endpointInput = screen.getByLabelText(/api endpoint/i) + const apiKeyInput = screen.getByLabelText(/api key/i) + + fireEvent.change(nameInput, { target: { value: 'Test' } }) + fireEvent.change(endpointInput, { target: { value: 'https://test.com' } }) + fireEvent.change(apiKeyInput, { target: { value: 'key12345' } }) + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')! + fireEvent.click(saveButton) + + await waitFor(() => { + expect(createExternalAPI).toHaveBeenCalledWith({ + body: { + name: 'Test', + settings: { endpoint: 'https://test.com', api_key: 'key12345' }, + }, + }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'External API saved successfully', + }) + expect(onSave).toHaveBeenCalledWith(mockResponse) + expect(onCancel).toHaveBeenCalled() + }) + }) + + it('should show error notification when API key is too short', async () => { + render() + + const nameInput = screen.getByLabelText(/name/i) + const endpointInput = screen.getByLabelText(/api endpoint/i) + const apiKeyInput = screen.getByLabelText(/api key/i) + + fireEvent.change(nameInput, { target: { value: 'Test' } }) + fireEvent.change(endpointInput, { target: { value: 'https://test.com' } }) + fireEvent.change(apiKeyInput, { target: { value: 'key' } }) // Less than 5 characters + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')! + fireEvent.click(saveButton) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'common.apiBasedExtension.modal.apiKey.lengthError', + }) + }) + }) + + it('should handle create API error', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(createExternalAPI).mockRejectedValue(new Error('Create failed')) + + render() + + const nameInput = screen.getByLabelText(/name/i) + const endpointInput = screen.getByLabelText(/api endpoint/i) + const apiKeyInput = screen.getByLabelText(/api key/i) + + fireEvent.change(nameInput, { target: { value: 'Test' } }) + fireEvent.change(endpointInput, { target: { value: 'https://test.com' } }) + fireEvent.change(apiKeyInput, { target: { value: 'key12345' } }) + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')! + fireEvent.click(saveButton) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'Failed to save/update External API', + }) + }) + + consoleSpy.mockRestore() + }) + }) + + describe('Edit Mode - Save', () => { + it('should call onEdit directly when editing without dataset bindings', async () => { + const onEdit = vi.fn().mockResolvedValue(undefined) + const onCancel = vi.fn() + + render( + , + ) + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')! + fireEvent.click(saveButton) + + await waitFor(() => { + // When no datasetBindings, onEdit is called directly with original form data + expect(onEdit).toHaveBeenCalledWith({ + name: 'Test API', + settings: { + endpoint: 'https://api.example.com', + api_key: 'test-key-12345', + }, + }) + }) + }) + + it('should show confirm dialog when editing with dataset bindings', async () => { + const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }] + const onEdit = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')! + fireEvent.click(saveButton) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument() + }) + }) + + it('should proceed with save after confirming in edit mode with bindings', async () => { + vi.mocked(createExternalAPI).mockResolvedValue({ + id: 'api-123', + tenant_id: 'tenant-1', + name: 'Test API', + description: '', + settings: { endpoint: 'https://api.example.com', api_key: 'test-key-12345' }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + }) + const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }] + const onCancel = vi.fn() + + render( + , + ) + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')! + fireEvent.click(saveButton) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument() + }) + + const confirmButton = screen.getByRole('button', { name: /confirm/i }) + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'success' }), + ) + }) + }) + + it('should close confirm dialog when cancel is clicked', async () => { + const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }] + + render( + , + ) + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')! + fireEvent.click(saveButton) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument() + }) + + // There are multiple cancel buttons, find the one in the confirm dialog + const cancelButtons = screen.getAllByRole('button', { name: /cancel/i }) + const confirmDialogCancelButton = cancelButtons[cancelButtons.length - 1] + fireEvent.click(confirmDialogCancelButton) + + await waitFor(() => { + // Confirm button should be gone after canceling + expect(screen.queryAllByRole('button', { name: /confirm/i })).toHaveLength(0) + }) + }) + }) + + describe('Cancel', () => { + it('should call onCancel when cancel button is clicked', () => { + const onCancel = vi.fn() + render() + + const cancelButton = screen.getByText('dataset.externalAPIForm.cancel').closest('button')! + fireEvent.click(cancelButton) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('should call onCancel when close button is clicked', () => { + const onCancel = vi.fn() + render() + + // Close button is rendered in a portal + const closeButton = document.body.querySelector('.action-btn')! + fireEvent.click(closeButton) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + }) + + describe('Edge Cases', () => { + it('should handle undefined data in edit mode', () => { + render() + expect(screen.getByLabelText(/name/i)).toHaveValue('') + }) + + it('should handle null datasetBindings', () => { + render( + , + ) + expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument() + }) + + it('should render documentation link in encryption notice', () => { + render() + const link = screen.getByRole('link', { name: 'PKCS1_OAEP' }) + expect(link).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html') + expect(link).toHaveAttribute('target', '_blank') + }) + }) +}) 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/index.spec.tsx new file mode 100644 index 0000000000..55297132a9 --- /dev/null +++ b/web/app/components/datasets/external-api/external-api-panel/index.spec.tsx @@ -0,0 +1,207 @@ +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' + +// Mock external contexts (only mock context providers, not base components) +const mockSetShowExternalKnowledgeAPIModal = vi.fn() +const mockMutateExternalKnowledgeApis = vi.fn() +let mockIsLoading = false +let mockExternalKnowledgeApiList: ExternalAPIItem[] = [] + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal, + }), +})) + +vi.mock('@/context/external-knowledge-api-context', () => ({ + useExternalKnowledgeApi: () => ({ + externalKnowledgeApiList: mockExternalKnowledgeApiList, + mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis, + isLoading: mockIsLoading, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.com${path}`, +})) + +// Mock the ExternalKnowledgeAPICard to avoid mocking its internal dependencies +vi.mock('../external-knowledge-api-card', () => ({ + default: ({ api }: { api: ExternalAPIItem }) => ( +
{api.name}
+ ), +})) + +// i18n mock returns 'namespace.key' format + +describe('ExternalAPIPanel', () => { + const defaultProps = { + onClose: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockIsLoading = false + mockExternalKnowledgeApiList = [] + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument() + }) + + it('should render panel title and description', () => { + render() + expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument() + expect(screen.getByText('dataset.externalAPIPanelDescription')).toBeInTheDocument() + }) + + it('should render documentation link', () => { + render() + const docLink = screen.getByText('dataset.externalAPIPanelDocumentation') + expect(docLink).toBeInTheDocument() + expect(docLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/guides/knowledge-base/connect-external-knowledge-base') + }) + + it('should render create button', () => { + render() + expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument() + }) + + it('should render close button', () => { + const { container } = render() + const closeButton = container.querySelector('[class*="action-button"]') || screen.getAllByRole('button')[0] + expect(closeButton).toBeInTheDocument() + }) + }) + + describe('Loading State', () => { + it('should render loading indicator when isLoading is true', () => { + mockIsLoading = true + const { container } = render() + // Loading component should be rendered + const loadingElement = container.querySelector('[class*="loading"]') + || container.querySelector('.animate-spin') + || screen.queryByRole('status') + expect(loadingElement || container.textContent).toBeTruthy() + }) + }) + + describe('API List Rendering', () => { + it('should render empty list when no APIs exist', () => { + mockExternalKnowledgeApiList = [] + render() + expect(screen.queryByTestId(/api-card-/)).not.toBeInTheDocument() + }) + + it('should render API cards when APIs exist', () => { + mockExternalKnowledgeApiList = [ + { + id: 'api-1', + tenant_id: 'tenant-1', + name: 'Test API 1', + description: '', + settings: { endpoint: 'https://api1.example.com', api_key: 'key1' }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + }, + { + id: 'api-2', + tenant_id: 'tenant-1', + name: 'Test API 2', + description: '', + settings: { endpoint: 'https://api2.example.com', api_key: 'key2' }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + }, + ] + render() + expect(screen.getByTestId('api-card-api-1')).toBeInTheDocument() + expect(screen.getByTestId('api-card-api-2')).toBeInTheDocument() + expect(screen.getByText('Test API 1')).toBeInTheDocument() + expect(screen.getByText('Test API 2')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onClose when close button is clicked', () => { + const onClose = vi.fn() + render() + // Find the close button (ActionButton with close icon) + const buttons = screen.getAllByRole('button') + const closeButton = buttons.find(btn => btn.querySelector('svg[class*="ri-close"]')) + || buttons[0] + fireEvent.click(closeButton) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should open external API modal when create button is clicked', async () => { + render() + const createButton = screen.getByText('dataset.createExternalAPI').closest('button')! + fireEvent.click(createButton) + + await waitFor(() => { + expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledTimes(1) + expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { name: '', settings: { endpoint: '', api_key: '' } }, + datasetBindings: [], + isEditMode: false, + }), + ) + }) + }) + + it('should call mutateExternalKnowledgeApis in onSaveCallback', async () => { + render() + const createButton = screen.getByText('dataset.createExternalAPI').closest('button')! + fireEvent.click(createButton) + + const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + callArgs.onSaveCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + }) + + it('should call mutateExternalKnowledgeApis in onCancelCallback', async () => { + render() + const createButton = screen.getByText('dataset.createExternalAPI').closest('button')! + fireEvent.click(createButton) + + const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + callArgs.onCancelCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should handle single API in list', () => { + mockExternalKnowledgeApiList = [ + { + id: 'single-api', + tenant_id: 'tenant-1', + name: 'Single API', + description: '', + settings: { endpoint: 'https://single.example.com', api_key: 'key' }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + }, + ] + render() + expect(screen.getByTestId('api-card-single-api')).toBeInTheDocument() + }) + + it('should render documentation link with correct target', () => { + render() + const docLink = screen.getByText('dataset.externalAPIPanelDocumentation').closest('a') + expect(docLink).toHaveAttribute('target', '_blank') + }) + }) +}) 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/index.spec.tsx new file mode 100644 index 0000000000..f8aacde3e1 --- /dev/null +++ b/web/app/components/datasets/external-api/external-knowledge-api-card/index.spec.tsx @@ -0,0 +1,382 @@ +import type { ExternalAPIItem } from '@/models/datasets' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +// Import mocked services +import { checkUsageExternalAPI, deleteExternalAPI, fetchExternalAPI } from '@/service/datasets' + +import ExternalKnowledgeAPICard from './index' + +// Mock API services +vi.mock('@/service/datasets', () => ({ + fetchExternalAPI: vi.fn(), + updateExternalAPI: vi.fn(), + deleteExternalAPI: vi.fn(), + checkUsageExternalAPI: vi.fn(), +})) + +// Mock contexts +const mockSetShowExternalKnowledgeAPIModal = vi.fn() +const mockMutateExternalKnowledgeApis = vi.fn() + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal, + }), +})) + +vi.mock('@/context/external-knowledge-api-context', () => ({ + useExternalKnowledgeApi: () => ({ + mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis, + }), +})) + +describe('ExternalKnowledgeAPICard', () => { + const mockApi: ExternalAPIItem = { + id: 'api-123', + tenant_id: 'tenant-1', + name: 'Test External API', + description: 'Test API description', + settings: { + endpoint: 'https://api.example.com/knowledge', + api_key: 'secret-key-123', + }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + } + + const defaultProps = { + api: mockApi, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText('Test External API')).toBeInTheDocument() + }) + + it('should render API name', () => { + render() + expect(screen.getByText('Test External API')).toBeInTheDocument() + }) + + it('should render API endpoint', () => { + render() + expect(screen.getByText('https://api.example.com/knowledge')).toBeInTheDocument() + }) + + it('should render edit and delete buttons', () => { + const { container } = render() + const buttons = container.querySelectorAll('button') + expect(buttons.length).toBe(2) + }) + + it('should render API connection icon', () => { + const { container } = render() + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + }) + }) + + describe('User Interactions - Edit', () => { + it('should fetch API details and open modal when edit button is clicked', async () => { + const mockResponse: ExternalAPIItem = { + id: 'api-123', + tenant_id: 'tenant-1', + name: 'Test External API', + description: 'Test API description', + settings: { + endpoint: 'https://api.example.com/knowledge', + api_key: 'secret-key-123', + }, + dataset_bindings: [{ id: 'ds-1', name: 'Dataset 1' }], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + } + vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse) + + const { container } = render() + const buttons = container.querySelectorAll('button') + const editButton = buttons[0] + + fireEvent.click(editButton) + + await waitFor(() => { + expect(fetchExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' }) + expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { + name: 'Test External API', + settings: { + endpoint: 'https://api.example.com/knowledge', + api_key: 'secret-key-123', + }, + }, + isEditMode: true, + datasetBindings: [{ id: 'ds-1', name: 'Dataset 1' }], + }), + ) + }) + }) + + it('should handle fetch error gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(fetchExternalAPI).mockRejectedValue(new Error('Fetch failed')) + + const { container } = render() + const buttons = container.querySelectorAll('button') + const editButton = buttons[0] + + fireEvent.click(editButton) + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Error fetching external knowledge API data:', + expect.any(Error), + ) + }) + + consoleSpy.mockRestore() + }) + + it('should call mutate on save callback', async () => { + const mockResponse: ExternalAPIItem = { + id: 'api-123', + tenant_id: 'tenant-1', + name: 'Test External API', + description: 'Test API description', + settings: { + endpoint: 'https://api.example.com/knowledge', + api_key: 'secret-key-123', + }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + } + vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse) + + const { container } = render() + const editButton = container.querySelectorAll('button')[0] + + fireEvent.click(editButton) + + await waitFor(() => { + expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled() + }) + + // Simulate save callback + const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + modalCall.onSaveCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + }) + + it('should call mutate on cancel callback', async () => { + const mockResponse: ExternalAPIItem = { + id: 'api-123', + tenant_id: 'tenant-1', + name: 'Test External API', + description: 'Test API description', + settings: { + endpoint: 'https://api.example.com/knowledge', + api_key: 'secret-key-123', + }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + } + vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse) + + const { container } = render() + const editButton = container.querySelectorAll('button')[0] + + fireEvent.click(editButton) + + await waitFor(() => { + expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled() + }) + + // Simulate cancel callback + const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + modalCall.onCancelCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + }) + }) + + describe('User Interactions - Delete', () => { + it('should check usage and show confirm dialog when delete button is clicked', async () => { + vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 }) + + const { container } = render() + const buttons = container.querySelectorAll('button') + const deleteButton = buttons[1] + + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(checkUsageExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' }) + }) + + // Confirm dialog should be shown + await waitFor(() => { + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + }) + }) + + it('should show usage count in confirm dialog when API is in use', async () => { + vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: true, count: 3 }) + + const { container } = render() + const deleteButton = container.querySelectorAll('button')[1] + + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByText(/3/)).toBeInTheDocument() + }) + }) + + it('should delete API and refresh list when confirmed', async () => { + vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 }) + vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'success' }) + + const { container } = render() + const deleteButton = container.querySelectorAll('button')[1] + + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument() + }) + + const confirmButton = screen.getByRole('button', { name: /confirm/i }) + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(deleteExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' }) + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + }) + }) + + it('should close confirm dialog when cancel is clicked', async () => { + vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 }) + + const { container } = render() + const deleteButton = container.querySelectorAll('button')[1] + + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + }) + + const cancelButton = screen.getByRole('button', { name: /cancel/i }) + fireEvent.click(cancelButton) + + await waitFor(() => { + expect(screen.queryByRole('button', { name: /confirm/i })).not.toBeInTheDocument() + }) + }) + + it('should handle delete error gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 }) + vi.mocked(deleteExternalAPI).mockRejectedValue(new Error('Delete failed')) + + const { container } = render() + const deleteButton = container.querySelectorAll('button')[1] + + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument() + }) + + const confirmButton = screen.getByRole('button', { name: /confirm/i }) + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Error deleting external knowledge API:', + expect.any(Error), + ) + }) + + consoleSpy.mockRestore() + }) + + it('should handle check usage error gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(checkUsageExternalAPI).mockRejectedValue(new Error('Check failed')) + + const { container } = render() + const deleteButton = container.querySelectorAll('button')[1] + + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Error checking external API usage:', + expect.any(Error), + ) + }) + + consoleSpy.mockRestore() + }) + }) + + describe('Hover State', () => { + it('should apply hover styles when delete button is hovered', () => { + const { container } = render() + const deleteButton = container.querySelectorAll('button')[1] + const cardContainer = container.querySelector('[class*="shadows-shadow"]') + + fireEvent.mouseEnter(deleteButton) + expect(cardContainer).toHaveClass('border-state-destructive-border') + expect(cardContainer).toHaveClass('bg-state-destructive-hover') + + fireEvent.mouseLeave(deleteButton) + expect(cardContainer).not.toHaveClass('border-state-destructive-border') + }) + }) + + describe('Edge Cases', () => { + it('should handle API with empty endpoint', () => { + const apiWithEmptyEndpoint: ExternalAPIItem = { + ...mockApi, + settings: { endpoint: '', api_key: 'key' }, + } + render() + expect(screen.getByText('Test External API')).toBeInTheDocument() + }) + + it('should handle delete response with unsuccessful result', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 }) + vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'error' }) + + const { container } = render() + const deleteButton = container.querySelectorAll('button')[1] + + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument() + }) + + const confirmButton = screen.getByRole('button', { name: /confirm/i }) + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Failed to delete external API') + }) + + consoleSpy.mockRestore() + }) + }) +}) diff --git a/web/app/components/datasets/extra-info/index.spec.tsx b/web/app/components/datasets/extra-info/index.spec.tsx new file mode 100644 index 0000000000..ce34ea26e3 --- /dev/null +++ b/web/app/components/datasets/extra-info/index.spec.tsx @@ -0,0 +1,1169 @@ +import type { DataSet, RelatedApp, RelatedAppResponse } from '@/models/datasets' +import { render, screen, waitFor } from '@testing-library/react' +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' + +// ============================================================================ +// Mock Setup +// ============================================================================ + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + }), + usePathname: () => '/test', + useSearchParams: () => new URLSearchParams(), +})) + +// Mock next/link +vi.mock('next/link', () => ({ + default: ({ children, href, ...props }: { children: React.ReactNode, href: string, [key: string]: unknown }) => ( + {children} + ), +})) + +// Dataset context mock data +const mockDataset: Partial = { + id: 'dataset-123', + name: 'Test Dataset', + enable_api: true, +} + +// Mock use-context-selector +vi.mock('use-context-selector', () => ({ + useContext: vi.fn(() => ({ dataset: mockDataset })), + useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })), + createContext: vi.fn(() => ({})), +})) + +// Mock dataset detail context +const mockMutateDatasetRes = vi.fn() +vi.mock('@/context/dataset-detail', () => ({ + default: {}, + useDatasetDetailContext: vi.fn(() => ({ + dataset: mockDataset, + mutateDatasetRes: mockMutateDatasetRes, + })), + useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset, mutateDatasetRes?: () => void }) => unknown) => + selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }), + ), +})) + +// Mock app context for workspace permissions +let mockIsCurrentWorkspaceManager = true +vi.mock('@/context/app-context', () => ({ + useSelector: vi.fn((selector: (state: { isCurrentWorkspaceManager: boolean }) => unknown) => + selector({ isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager }), + ), +})) + +// Mock service hooks +const mockEnableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' })) +const mockDisableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' })) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetApiBaseUrl: vi.fn(() => ({ + data: { api_base_url: 'https://api.example.com' }, + isLoading: false, + })), + useEnableDatasetServiceApi: vi.fn(() => ({ + mutateAsync: mockEnableDatasetServiceApi, + isPending: false, + })), + useDisableDatasetServiceApi: vi.fn(() => ({ + mutateAsync: mockDisableDatasetServiceApi, + isPending: false, + })), +})) + +// Mock API access URL hook +vi.mock('@/hooks/use-api-access-url', () => ({ + useDatasetApiAccessUrl: vi.fn(() => 'https://docs.dify.ai/api-reference/datasets'), +})) + +// Mock docLink hook +vi.mock('@/context/i18n', () => ({ + useDocLink: vi.fn(() => (path: string) => `https://docs.example.com${path}`), +})) + +// Mock SecretKeyModal to avoid complex modal rendering +vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ + default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => ( + isShow + ? ( +
+ +
+ ) + : null + ), +})) + +// ============================================================================ +// Test Data Factory +// ============================================================================ + +const createMockRelatedApp = (overrides: Partial = {}): RelatedApp => ({ + id: 'app-1', + name: 'Test App', + mode: AppModeEnum.COMPLETION, + icon: 'icon-url', + icon_type: 'image', + icon_background: '#fff', + icon_url: '', + ...overrides, +}) + +const createMockRelatedAppsResponse = (count: number = 2): RelatedAppResponse => ({ + data: Array.from({ length: count }, (_, i) => + createMockRelatedApp({ id: `app-${i + 1}`, name: `App ${i + 1}` })), + total: count, +}) + +// ============================================================================ +// Statistics Component Tests +// ============================================================================ + +describe('Statistics', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render( + , + ) + + expect(screen.getByText('10')).toBeInTheDocument() + }) + + it('should render document count correctly', () => { + render( + , + ) + + expect(screen.getByText('42')).toBeInTheDocument() + }) + + it('should render related apps total correctly', () => { + const relatedApps = createMockRelatedAppsResponse(5) + + render( + , + ) + + expect(screen.getByText('5')).toBeInTheDocument() + }) + + it('should display translated document label', () => { + render( + , + ) + + expect(screen.getByText(/documents/i)).toBeInTheDocument() + }) + + it('should display translated related app label', () => { + render( + , + ) + + expect(screen.getByText(/relatedApp/i)).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render placeholder when documentCount is undefined', () => { + render( + , + ) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + + it('should render placeholder when relatedApps is undefined', () => { + render( + , + ) + + expect(screen.getAllByText('--').length).toBeGreaterThanOrEqual(1) + }) + + it('should handle zero document count', () => { + render( + , + ) + + expect(screen.getByText('0')).toBeInTheDocument() + }) + + it('should handle empty related apps array', () => { + const emptyRelatedApps: RelatedAppResponse = { data: [], total: 0 } + + render( + , + ) + + expect(screen.getByText('0')).toBeInTheDocument() + }) + + it('should handle large numbers correctly', () => { + render( + , + ) + + expect(screen.getByText('999999')).toBeInTheDocument() + expect(screen.getByText('100')).toBeInTheDocument() + }) + }) + + describe('Tooltip Interactions', () => { + it('should render tooltip trigger with info icon', () => { + render( + , + ) + + // Find the cursor-pointer element containing the relatedApp text + const tooltipTrigger = screen.getByText(/relatedApp/i).closest('.cursor-pointer') + expect(tooltipTrigger).toBeInTheDocument() + }) + + it('should render LinkedAppsPanel when related apps exist', async () => { + const relatedApps = createMockRelatedAppsResponse(3) + + render( + , + ) + + // The LinkedAppsPanel should be rendered inside the tooltip + // We can't easily test tooltip content in this context without more setup + // But we verify the condition logic works by checking component renders + expect(screen.getByText('3')).toBeInTheDocument() + }) + + it('should render NoLinkedAppsPanel when no related apps', () => { + const emptyRelatedApps: RelatedAppResponse = { data: [], total: 0 } + + render( + , + ) + + // Verify component renders correctly with empty apps + expect(screen.getByText('0')).toBeInTheDocument() + }) + }) + + describe('Props Variations', () => { + it('should handle expand=false', () => { + render( + , + ) + + // Component should still render with expand=false + expect(screen.getByText('10')).toBeInTheDocument() + }) + + it('should pass isMobile based on expand prop', () => { + // When expand is false, isMobile should be true (!expand) + render( + , + ) + + // Component renders - the isMobile logic is internal + expect(screen.getByText('10')).toBeInTheDocument() + }) + }) + + describe('Memoization', () => { + it('should be memoized with React.memo', () => { + const { rerender } = render( + , + ) + + // Rerender with same props + rerender( + , + ) + + // Component should not cause unnecessary re-renders + expect(screen.getByText('10')).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// ApiAccess Component Tests +// ============================================================================ + +describe('ApiAccess', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render( + , + ) + + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should render API title when expanded', () => { + render( + , + ) + + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should not render API title when collapsed', () => { + render( + , + ) + + expect(screen.queryByText(/appMenus\.apiAccess/i)).not.toBeInTheDocument() + }) + + it('should render indicator when API is enabled', () => { + const { container } = render( + , + ) + + // Indicator component should be present + const indicatorElement = container.querySelector('.relative.flex.h-8') + expect(indicatorElement).toBeInTheDocument() + }) + + it('should render indicator when API is disabled', () => { + const { container } = render( + , + ) + + // Indicator component should be present + const indicatorElement = container.querySelector('.relative.flex.h-8') + expect(indicatorElement).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should toggle popup open state on click', async () => { + const user = userEvent.setup() + + render( + , + ) + + const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]') + expect(trigger).toBeInTheDocument() + + if (trigger) { + await user.click(trigger) + // After click, the Card component should be rendered in the portal + } + }) + + it('should apply hover styles on trigger', () => { + render( + , + ) + + const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('div[class*="cursor-pointer"]') + expect(trigger).toHaveClass('cursor-pointer') + }) + }) + + describe('Props Variations', () => { + it('should apply compressed layout when expand is false', () => { + const { container } = render( + , + ) + + // When collapsed, width should be w-8 + const triggerContainer = container.querySelector('[class*="w-8"]') + expect(triggerContainer).toBeInTheDocument() + }) + + it('should pass apiEnabled to Card component', async () => { + const user = userEvent.setup() + + render( + , + ) + + const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]') + if (trigger) { + await user.click(trigger) + // The apiEnabled should be passed to Card + } + }) + }) + + describe('Memoization', () => { + it('should be memoized with React.memo', () => { + const { rerender } = render( + , + ) + + rerender( + , + ) + + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// ApiAccessCard Component Tests +// ============================================================================ + +describe('ApiAccessCard', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceManager = true + mockEnableDatasetServiceApi.mockResolvedValue({ result: 'success' }) + mockDisableDatasetServiceApi.mockResolvedValue({ result: 'success' }) + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render( + , + ) + + expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() + }) + + it('should display enabled status when API is enabled', () => { + render( + , + ) + + expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() + }) + + it('should display disabled status when API is disabled', () => { + render( + , + ) + + expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument() + }) + + it('should render API Reference link', () => { + render( + , + ) + + expect(screen.getByText(/overview\.apiInfo\.doc/i)).toBeInTheDocument() + }) + + it('should render switch component', () => { + render( + , + ) + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call enableDatasetServiceApi when switch is toggled on', async () => { + const user = userEvent.setup() + + render( + , + ) + + const switchButton = screen.getByRole('switch') + await user.click(switchButton) + + await waitFor(() => { + expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('dataset-123') + }) + }) + + it('should call disableDatasetServiceApi when switch is toggled off', async () => { + const user = userEvent.setup() + + render( + , + ) + + const switchButton = screen.getByRole('switch') + await user.click(switchButton) + + await waitFor(() => { + expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('dataset-123') + }) + }) + + it('should call mutateDatasetRes after successful API toggle', async () => { + const user = userEvent.setup() + + render( + , + ) + + const switchButton = screen.getByRole('switch') + await user.click(switchButton) + + await waitFor(() => { + expect(mockMutateDatasetRes).toHaveBeenCalled() + }) + }) + + it('should not call mutateDatasetRes on API toggle failure', async () => { + mockEnableDatasetServiceApi.mockResolvedValueOnce({ result: 'fail' }) + const user = userEvent.setup() + + render( + , + ) + + const switchButton = screen.getByRole('switch') + await user.click(switchButton) + + await waitFor(() => { + expect(mockEnableDatasetServiceApi).toHaveBeenCalled() + }) + + // mutateDatasetRes should not be called on failure + expect(mockMutateDatasetRes).not.toHaveBeenCalled() + }) + + it('should have correct href for API Reference link', () => { + render( + , + ) + + const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a') + expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets') + }) + }) + + describe('Permission Handling', () => { + it('should disable switch when user is not workspace manager', () => { + mockIsCurrentWorkspaceManager = false + + render( + , + ) + + const switchButton = screen.getByRole('switch') + // Headless UI Switch uses CSS classes for disabled state + expect(switchButton).toHaveClass('!cursor-not-allowed') + expect(switchButton).toHaveClass('!opacity-50') + }) + + it('should enable switch when user is workspace manager', () => { + mockIsCurrentWorkspaceManager = true + + render( + , + ) + + const switchButton = screen.getByRole('switch') + expect(switchButton).not.toHaveClass('!cursor-not-allowed') + expect(switchButton).not.toHaveClass('!opacity-50') + }) + }) + + describe('Memoization', () => { + it('should be memoized with React.memo', () => { + const { rerender } = render( + , + ) + + rerender( + , + ) + + expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() + }) + + it('should use useCallback for handlers', () => { + // Verify handlers are stable by rendering multiple times + const { rerender } = render( + , + ) + + rerender( + , + ) + + // Component should render without issues with memoized callbacks + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// ExtraInfo (Main Component) Tests +// ============================================================================ + +describe('ExtraInfo', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render( + , + ) + + // Should render ApiAccess component + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should render Statistics when expand is true', () => { + render( + , + ) + + // Statistics shows document count + expect(screen.getByText('10')).toBeInTheDocument() + }) + + it('should not render Statistics when expand is false', () => { + render( + , + ) + + // Document count should not be visible when collapsed + expect(screen.queryByText('10')).not.toBeInTheDocument() + }) + + it('should always render ApiAccess regardless of expand state', () => { + const { rerender } = render( + , + ) + + // Check expanded state has ApiAccess title + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + + rerender( + , + ) + + // ApiAccess should still be present (but without title text when collapsed) + // The component is still rendered, just with different styling + }) + }) + + describe('Context Integration', () => { + it('should read apiEnabled from dataset detail context', () => { + render( + , + ) + + // Since mockDataset has enable_api: true, the indicator should be green + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should read apiBaseUrl from useDatasetApiBaseUrl hook', () => { + render( + , + ) + + // Component should render with the mocked API base URL + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should handle missing apiBaseInfo with fallback empty string', async () => { + const { useDatasetApiBaseUrl } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetApiBaseUrl).mockReturnValue({ + data: undefined, + isLoading: false, + } as ReturnType) + + render( + , + ) + + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + + // Reset mock + vi.mocked(useDatasetApiBaseUrl).mockReturnValue({ + data: { api_base_url: 'https://api.example.com' }, + isLoading: false, + } as ReturnType) + }) + + it('should handle missing apiEnabled with fallback false', async () => { + const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail') + vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => { + // Simulate dataset without enable_api by using a partial dataset + const partialDataset = { ...mockDataset } as Partial + delete (partialDataset as { enable_api?: boolean }).enable_api + return selector({ + dataset: partialDataset as DataSet, + mutateDatasetRes: vi.fn(), + }) + }) + + render( + , + ) + + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + + // Reset mock + vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector => + selector({ dataset: mockDataset as DataSet, mutateDatasetRes: vi.fn() }), + ) + }) + }) + + describe('Props Variations', () => { + it('should pass expand prop to Statistics component', () => { + render( + , + ) + + expect(screen.getByText('10')).toBeInTheDocument() + }) + + it('should pass expand prop to ApiAccess component', () => { + render( + , + ) + + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should pass documentCount to Statistics component', () => { + render( + , + ) + + expect(screen.getByText('99')).toBeInTheDocument() + }) + + it('should pass relatedApps to Statistics component', () => { + const relatedApps = createMockRelatedAppsResponse(7) + + render( + , + ) + + expect(screen.getByText('7')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle undefined documentCount', () => { + render( + , + ) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + + it('should handle undefined relatedApps', () => { + render( + , + ) + + expect(screen.getByText('10')).toBeInTheDocument() + }) + + it('should handle all undefined optional props', () => { + render( + , + ) + + // Should render without crashing + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should handle zero values correctly', () => { + const emptyRelatedApps: RelatedAppResponse = { data: [], total: 0 } + + render( + , + ) + + expect(screen.getAllByText('0')).toHaveLength(2) + }) + }) + + describe('Memoization', () => { + it('should be memoized with React.memo', () => { + const { rerender } = render( + , + ) + + // Rerender with same props + rerender( + , + ) + + expect(screen.getByText('10')).toBeInTheDocument() + }) + + it('should update when props change', () => { + const { rerender } = render( + , + ) + + expect(screen.getByText('10')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.getByText('20')).toBeInTheDocument() + }) + + it('should hide Statistics when expand changes to false', () => { + const { rerender } = render( + , + ) + + expect(screen.getByText('10')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.queryByText('10')).not.toBeInTheDocument() + }) + }) + + describe('Component Composition', () => { + it('should render Statistics before ApiAccess when expanded', () => { + const { container } = render( + , + ) + + // Statistics should appear before ApiAccess in DOM order + const elements = container.querySelectorAll('div') + expect(elements.length).toBeGreaterThan(0) + }) + + it('should render only ApiAccess when collapsed', () => { + render( + , + ) + + // Only ApiAccess should be rendered (without its title in collapsed state) + expect(screen.queryByText('10')).not.toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// Integration Tests +// ============================================================================ + +describe('ExtraInfo Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render complete expanded view with all child components', () => { + render( + , + ) + + // Statistics content + expect(screen.getByText('25')).toBeInTheDocument() + expect(screen.getByText('5')).toBeInTheDocument() + + // ApiAccess content + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should handle complete user workflow: view stats and toggle API', async () => { + const user = userEvent.setup() + + render( + , + ) + + // Verify statistics are visible + 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) + + // The popup should open with Card content (showing enabled/disabled status) + await waitFor(() => { + expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() + }) + }) + + it('should integrate with context correctly across all components', async () => { + render( + , + ) + + // The component tree should correctly receive context values + // apiEnabled from context affects ApiAccess indicator color + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/hit-testing/index.spec.tsx b/web/app/components/datasets/hit-testing/index.spec.tsx new file mode 100644 index 0000000000..45c68e44b1 --- /dev/null +++ b/web/app/components/datasets/hit-testing/index.spec.tsx @@ -0,0 +1,2654 @@ +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 ( +
+ + + +
+ ) + }, +})) + +// ============================================================================ +// 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 + +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 ( +
+ {textArea} + {actionButton} + +
+ ) + }, +})) + +// 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 ( + + {children} + + ) +} + +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() + expect(screen.getByText('0.85')).toBeInTheDocument() + expect(screen.getByText('score')).toBeInTheDocument() + }) + + it('should render nothing when value is null', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should render nothing when value is NaN', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should render nothing when value is 0', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + }) + + describe('Props', () => { + it('should apply besideChunkName styles when prop is true', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('border-l-0') + }) + + it('should apply rounded styles when besideChunkName is false', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('rounded-md') + }) + }) + + describe('Edge Cases', () => { + it('should display full score correctly', () => { + render() + expect(screen.getByText('1.00')).toBeInTheDocument() + }) + + it('should display very small score correctly', () => { + render() + expect(screen.getByText('0.01')).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// Mask Component Tests +// ============================================================================ + +describe('Mask', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should have gradient background class', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('bg-gradient-to-b') + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('custom-class') + }) + }) +}) + +// ============================================================================ +// EmptyRecords Component Tests +// ============================================================================ + +describe('EmptyRecords', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/noRecentTip/i)).toBeInTheDocument() + }) + + it('should render history icon', () => { + const { container } = render() + 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() + expect(screen.getByText(/100/)).toBeInTheDocument() + }) + + it('should render score component', () => { + render() + expect(screen.getByText('0.85')).toBeInTheDocument() + }) + + it('should render word count', () => { + render() + expect(screen.getByText(/100/)).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('should handle different position IDs', () => { + render() + // 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() + expect(screen.getByText('Test Document.pdf')).toBeInTheDocument() + }) + + it('should render open button', () => { + render() + expect(screen.getByText(/open/i)).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call showDetailModal when open button is clicked', async () => { + render() + + 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() + expect(screen.getByText(/Child chunk content/)).toBeInTheDocument() + }) + + it('should render position identifier', () => { + render() + // The C- and position number are in the same element + expect(screen.getByText(/C-/)).toBeInTheDocument() + }) + + it('should render score', () => { + render() + expect(screen.getByText('0.90')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply line-clamp when isShowAll is false', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('line-clamp-2') + }) + + it('should not apply line-clamp when isShowAll is true', () => { + const { container } = render() + expect(container.firstChild).not.toHaveClass('line-clamp-2') + }) + }) +}) + +// ============================================================================ +// ResultItem Component Tests +// ============================================================================ + +describe('ResultItem', () => { + const mockHitTesting = createMockHitTesting() + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + // Document name should be visible + expect(screen.getByText('test-document.pdf')).toBeInTheDocument() + }) + + it('should render score', () => { + render() + expect(screen.getByText('0.85')).toBeInTheDocument() + }) + + it('should render document name in footer', () => { + render() + expect(screen.getByText('test-document.pdf')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should open detail modal when clicked', async () => { + render() + + 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() + 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() + + // 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() + 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() + 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() + expect(screen.getByText('External content')).toBeInTheDocument() + }) + + it('should render title in footer', () => { + render() + expect(screen.getByText('External Title')).toBeInTheDocument() + }) + + it('should render score', () => { + render() + expect(screen.getByText('0.75')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should open detail modal when clicked', async () => { + render() + + 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(