mirror of
https://github.com/langgenius/dify.git
synced 2026-04-24 12:55:49 +08:00
Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox
This commit is contained in:
3
.github/labeler.yml
vendored
Normal file
3
.github/labeler.yml
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
web:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: 'web/**'
|
||||
14
.github/workflows/labeler.yml
vendored
Normal file
14
.github/workflows/labeler.yml
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
name: "Pull Request Labeler"
|
||||
on:
|
||||
pull_request_target:
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v6
|
||||
with:
|
||||
sync-labels: true
|
||||
5
.github/workflows/style.yml
vendored
5
.github/workflows/style.yml
vendored
@ -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
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
## Purpose
|
||||
|
||||
`api/controllers/console/datasets/datasets_document.py` contains the console (authenticated) APIs for managing dataset documents (list/create/update/delete, processing controls, estimates, etc.).
|
||||
|
||||
## Storage model (uploaded files)
|
||||
|
||||
- For local file uploads into a knowledge base, the binary is stored via `extensions.ext_storage.storage` under the key:
|
||||
- `upload_files/<tenant_id>/<uuid>.<ext>`
|
||||
- File metadata is stored in the `upload_files` table (`UploadFile` model), keyed by `UploadFile.id`.
|
||||
- Dataset `Document` records reference the uploaded file via:
|
||||
- `Document.data_source_info.upload_file_id`
|
||||
|
||||
## Download endpoint
|
||||
|
||||
- `GET /datasets/<dataset_id>/documents/<document_id>/download`
|
||||
|
||||
- Only supported when `Document.data_source_type == "upload_file"`.
|
||||
- Performs dataset permission + tenant checks via `DocumentResource.get_document(...)`.
|
||||
- Delegates `Document -> UploadFile` validation and signed URL generation to `DocumentService.get_document_download_url(...)`.
|
||||
- Applies `cloud_edition_billing_rate_limit_check("knowledge")` to match other KB operations.
|
||||
- Response body is **only**: `{ "url": "<signed-url>" }`.
|
||||
|
||||
- `POST /datasets/<dataset_id>/documents/download-zip`
|
||||
|
||||
- Accepts `{ "document_ids": ["..."] }` (upload-file only).
|
||||
- Returns `application/zip` as a single attachment download.
|
||||
- Rationale: browsers often block multiple automatic downloads; a ZIP avoids that limitation.
|
||||
- Applies `cloud_edition_billing_rate_limit_check("knowledge")`.
|
||||
- Delegates dataset permission checks, document/upload-file validation, and download-name generation to
|
||||
`DocumentService.prepare_document_batch_download_zip(...)` before streaming the ZIP.
|
||||
|
||||
## Verification plan
|
||||
|
||||
- Upload a document from a local file into a dataset.
|
||||
- Call the download endpoint and confirm it returns a signed URL.
|
||||
- Open the URL and confirm:
|
||||
- Response headers force download (`Content-Disposition`), and
|
||||
- Downloaded bytes match the uploaded file.
|
||||
- Select multiple uploaded-file documents and download as ZIP; confirm all selected files exist in the archive.
|
||||
|
||||
## Shared helper
|
||||
|
||||
- `DocumentService.get_document_download_url(document)` resolves the `UploadFile` and signs a download URL.
|
||||
- `DocumentService.prepare_document_batch_download_zip(...)` performs dataset permission checks, batches
|
||||
document + upload file lookups, preserves request order, and generates the client-visible ZIP filename.
|
||||
- Internal helpers now live in `DocumentService` (`_get_upload_file_id_for_upload_file_document(...)`,
|
||||
`_get_upload_file_for_upload_file_document(...)`, `_get_upload_files_by_document_id_for_zip_download(...)`).
|
||||
- ZIP packing is handled by `FileService.build_upload_files_zip_tempfile(...)`, which also:
|
||||
- sanitizes entry names to avoid path traversal, and
|
||||
- deduplicates names while preserving extensions (e.g., `doc.txt` → `doc (1).txt`).
|
||||
Streaming the response and deferring cleanup is handled by the route via `send_file(path, ...)` + `ExitStack` +
|
||||
`response.call_on_close(...)` (the file is deleted when the response is closed).
|
||||
18
api/agent-notes/services/dataset_service.py.md
Normal file
18
api/agent-notes/services/dataset_service.py.md
Normal file
@ -0,0 +1,18 @@
|
||||
## Purpose
|
||||
|
||||
`api/services/dataset_service.py` hosts dataset/document service logic used by console and API controllers.
|
||||
|
||||
## Batch document operations
|
||||
|
||||
- Batch document workflows should avoid N+1 database queries by using set-based lookups.
|
||||
- Tenant checks must be enforced consistently across dataset/document operations.
|
||||
- `DocumentService.get_documents_by_ids(...)` fetches documents for a dataset using `id.in_(...)`.
|
||||
- `FileService.get_upload_files_by_ids(...)` performs tenant-scoped batch lookup for `UploadFile` (dedupes ids with `set(...)`).
|
||||
- `DocumentService.get_document_download_url(...)` and `prepare_document_batch_download_zip(...)` handle
|
||||
dataset/document permission checks plus `Document -> UploadFile` validation for download endpoints.
|
||||
|
||||
## Verification plan
|
||||
|
||||
- Exercise document list and download endpoints that use the service helpers.
|
||||
- Confirm batch download uses constant query count for documents + upload files.
|
||||
- Request a ZIP with a missing document id and confirm a 404 is returned.
|
||||
35
api/agent-notes/services/file_service.py.md
Normal file
35
api/agent-notes/services/file_service.py.md
Normal file
@ -0,0 +1,35 @@
|
||||
## Purpose
|
||||
|
||||
`api/services/file_service.py` owns business logic around `UploadFile` objects: upload validation, storage persistence,
|
||||
previews/generators, and deletion.
|
||||
|
||||
## Key invariants
|
||||
|
||||
- All storage I/O goes through `extensions.ext_storage.storage`.
|
||||
- Uploaded file keys follow: `upload_files/<tenant_id>/<uuid>.<ext>`.
|
||||
- Upload validation is enforced in `FileService.upload_file(...)` (blocked extensions, size limits, dataset-only types).
|
||||
|
||||
## Batch lookup helpers
|
||||
|
||||
- `FileService.get_upload_files_by_ids(tenant_id, upload_file_ids)` is the canonical tenant-scoped batch loader for
|
||||
`UploadFile`.
|
||||
|
||||
## Dataset document download helpers
|
||||
|
||||
The dataset document download/ZIP endpoints now delegate “Document → UploadFile” validation and permission checks to
|
||||
`DocumentService` (`api/services/dataset_service.py`). `FileService` stays focused on generic `UploadFile` operations
|
||||
(uploading, previews, deletion), plus generic ZIP serving.
|
||||
|
||||
### ZIP serving
|
||||
|
||||
- `FileService.build_upload_files_zip_tempfile(...)` builds a ZIP from `UploadFile` objects and yields a seeked
|
||||
tempfile **path** so callers can stream it (e.g., `send_file(path, ...)`) without hitting "read of closed file"
|
||||
issues from file-handle lifecycle during streamed responses.
|
||||
- Flask `send_file(...)` and the `ExitStack`/`call_on_close(...)` cleanup pattern are handled in the route layer.
|
||||
|
||||
## Verification plan
|
||||
|
||||
- Unit: `api/tests/unit_tests/controllers/console/datasets/test_datasets_document_download.py`
|
||||
- Verify signed URL generation for upload-file documents and ZIP download behavior for multiple documents.
|
||||
- Unit: `api/tests/unit_tests/services/test_file_service_zip_and_lookup.py`
|
||||
- Verify ZIP packing produces a valid, openable archive and preserves file content.
|
||||
@ -0,0 +1,28 @@
|
||||
## Purpose
|
||||
|
||||
Unit tests for the console dataset document download endpoint:
|
||||
|
||||
- `GET /datasets/<dataset_id>/documents/<document_id>/download`
|
||||
|
||||
## Testing approach
|
||||
|
||||
- Uses `Flask.test_request_context()` and calls the `Resource.get(...)` method directly.
|
||||
- Monkeypatches console decorators (`login_required`, `setup_required`, rate limit) to no-ops to keep the test focused.
|
||||
- Mocks:
|
||||
- `DatasetService.get_dataset` / `check_dataset_permission`
|
||||
- `DocumentService.get_document` for single-file download tests
|
||||
- `DocumentService.get_documents_by_ids` + `FileService.get_upload_files_by_ids` for ZIP download tests
|
||||
- `FileService.get_upload_files_by_ids` for `UploadFile` lookups in single-file tests
|
||||
- `services.dataset_service.file_helpers.get_signed_file_url` to return a deterministic URL
|
||||
- Document mocks include `id` fields so batch lookups can map documents by id.
|
||||
|
||||
## Covered cases
|
||||
|
||||
- Success returns `{ "url": "<signed>" }` for upload-file documents.
|
||||
- 404 when document is not `upload_file`.
|
||||
- 404 when `upload_file_id` is missing.
|
||||
- 404 when referenced `UploadFile` row does not exist.
|
||||
- 403 when document tenant does not match current tenant.
|
||||
- Batch ZIP download returns `application/zip` for upload-file documents.
|
||||
- Batch ZIP download rejects non-upload-file documents.
|
||||
- Batch ZIP download uses a random `.zip` attachment name (`download_name`), so tests only assert the suffix.
|
||||
@ -0,0 +1,18 @@
|
||||
## Purpose
|
||||
|
||||
Unit tests for `api/services/file_service.py` helper methods that are not covered by higher-level controller tests.
|
||||
|
||||
## What’s covered
|
||||
|
||||
- `FileService.build_upload_files_zip_tempfile(...)`
|
||||
- ZIP entry name sanitization (no directory components / traversal)
|
||||
- name deduplication while preserving extensions
|
||||
- writing streamed bytes from `storage.load(...)` into ZIP entries
|
||||
- yields a tempfile path so callers can open/stream the ZIP without holding a live file handle
|
||||
- `FileService.get_upload_files_by_ids(...)`
|
||||
- returns `{}` for empty id lists
|
||||
- returns an id-keyed mapping for non-empty lists
|
||||
|
||||
## Notes
|
||||
|
||||
- These tests intentionally stub `storage.load` and `db.session.scalars(...).all()` to avoid needing a real DB/storage.
|
||||
@ -2,10 +2,12 @@ import json
|
||||
import logging
|
||||
from argparse import ArgumentTypeError
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal, cast
|
||||
from contextlib import ExitStack
|
||||
from typing import Any, Literal, cast
|
||||
from uuid import UUID
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
from flask import request, send_file
|
||||
from flask_restx import Resource, fields, marshal, marshal_with
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import asc, desc, select
|
||||
@ -42,6 +44,7 @@ from models import DatasetProcessRule, Document, DocumentSegment, UploadFile
|
||||
from models.dataset import DocumentPipelineExecutionLog
|
||||
from services.dataset_service import DatasetService, DocumentService
|
||||
from services.entities.knowledge_entities.knowledge_entities import KnowledgeConfig, ProcessRule, RetrievalModel
|
||||
from services.file_service import FileService
|
||||
|
||||
from ..app.error import (
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
@ -65,6 +68,9 @@ from ..wraps import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# NOTE: Keep constants near the top of the module for discoverability.
|
||||
DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100
|
||||
|
||||
|
||||
def _get_or_create_model(model_name: str, field_def):
|
||||
existing = console_ns.models.get(model_name)
|
||||
@ -104,6 +110,12 @@ class DocumentRenamePayload(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class DocumentBatchDownloadZipPayload(BaseModel):
|
||||
"""Request payload for bulk downloading documents as a zip archive."""
|
||||
|
||||
document_ids: list[UUID] = Field(..., min_length=1, max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS)
|
||||
|
||||
|
||||
class DocumentDatasetListParam(BaseModel):
|
||||
page: int = Field(1, title="Page", description="Page number.")
|
||||
limit: int = Field(20, title="Limit", description="Page size.")
|
||||
@ -120,6 +132,7 @@ register_schema_models(
|
||||
RetrievalModel,
|
||||
DocumentRetryPayload,
|
||||
DocumentRenamePayload,
|
||||
DocumentBatchDownloadZipPayload,
|
||||
)
|
||||
|
||||
|
||||
@ -853,6 +866,62 @@ class DocumentApi(DocumentResource):
|
||||
return {"result": "success"}, 204
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/download")
|
||||
class DocumentDownloadApi(DocumentResource):
|
||||
"""Return a signed download URL for a dataset document's original uploaded file."""
|
||||
|
||||
@console_ns.doc("get_dataset_document_download_url")
|
||||
@console_ns.doc(description="Get a signed download URL for a dataset document's original uploaded file")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def get(self, dataset_id: str, document_id: str) -> dict[str, Any]:
|
||||
# Reuse the shared permission/tenant checks implemented in DocumentResource.
|
||||
document = self.get_document(str(dataset_id), str(document_id))
|
||||
return {"url": DocumentService.get_document_download_url(document)}
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/documents/download-zip")
|
||||
class DocumentBatchDownloadZipApi(DocumentResource):
|
||||
"""Download multiple uploaded-file documents as a single ZIP (avoids browser multi-download limits)."""
|
||||
|
||||
@console_ns.doc("download_dataset_documents_as_zip")
|
||||
@console_ns.doc(description="Download selected dataset documents as a single ZIP archive (upload-file only)")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
@console_ns.expect(console_ns.models[DocumentBatchDownloadZipPayload.__name__])
|
||||
def post(self, dataset_id: str):
|
||||
"""Stream a ZIP archive containing the requested uploaded documents."""
|
||||
# Parse and validate request payload.
|
||||
payload = DocumentBatchDownloadZipPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
dataset_id = str(dataset_id)
|
||||
document_ids: list[str] = [str(document_id) for document_id in payload.document_ids]
|
||||
upload_files, download_name = DocumentService.prepare_document_batch_download_zip(
|
||||
dataset_id=dataset_id,
|
||||
document_ids=document_ids,
|
||||
tenant_id=current_tenant_id,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
# Delegate ZIP packing to FileService, but keep Flask response+cleanup in the route.
|
||||
with ExitStack() as stack:
|
||||
zip_path = stack.enter_context(FileService.build_upload_files_zip_tempfile(upload_files=upload_files))
|
||||
response = send_file(
|
||||
zip_path,
|
||||
mimetype="application/zip",
|
||||
as_attachment=True,
|
||||
download_name=download_name,
|
||||
)
|
||||
cleanup = stack.pop_all()
|
||||
response.call_on_close(cleanup.close)
|
||||
return response
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/processing/<string:action>")
|
||||
class DocumentProcessingApi(DocumentResource):
|
||||
@console_ns.doc("update_document_processing")
|
||||
|
||||
@ -13,10 +13,11 @@ import sqlalchemy as sa
|
||||
from redis.exceptions import LockNotOwnedError
|
||||
from sqlalchemy import exists, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import NotFound
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
|
||||
from core.file import helpers as file_helpers
|
||||
from core.helper.name_generator import generate_incremental_name
|
||||
from core.model_manager import ModelManager
|
||||
from core.model_runtime.entities.model_entities import ModelFeature, ModelType
|
||||
@ -73,6 +74,7 @@ from services.errors.document import DocumentIndexingError
|
||||
from services.errors.file import FileNotExistsError
|
||||
from services.external_knowledge_service import ExternalDatasetService
|
||||
from services.feature_service import FeatureModel, FeatureService
|
||||
from services.file_service import FileService
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
from services.tag_service import TagService
|
||||
from services.vector_service import VectorService
|
||||
@ -1162,6 +1164,7 @@ class DocumentService:
|
||||
Document.archived.is_(True),
|
||||
),
|
||||
}
|
||||
DOCUMENT_BATCH_DOWNLOAD_ZIP_FILENAME_EXTENSION = ".zip"
|
||||
|
||||
@classmethod
|
||||
def normalize_display_status(cls, status: str | None) -> str | None:
|
||||
@ -1288,6 +1291,143 @@ class DocumentService:
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_documents_by_ids(dataset_id: str, document_ids: Sequence[str]) -> Sequence[Document]:
|
||||
"""Fetch documents for a dataset in a single batch query."""
|
||||
if not document_ids:
|
||||
return []
|
||||
document_id_list: list[str] = [str(document_id) for document_id in document_ids]
|
||||
# Fetch all requested documents in one query to avoid N+1 lookups.
|
||||
documents: Sequence[Document] = db.session.scalars(
|
||||
select(Document).where(
|
||||
Document.dataset_id == dataset_id,
|
||||
Document.id.in_(document_id_list),
|
||||
)
|
||||
).all()
|
||||
return documents
|
||||
|
||||
@staticmethod
|
||||
def get_document_download_url(document: Document) -> str:
|
||||
"""
|
||||
Return a signed download URL for an upload-file document.
|
||||
"""
|
||||
upload_file = DocumentService._get_upload_file_for_upload_file_document(document)
|
||||
return file_helpers.get_signed_file_url(upload_file_id=upload_file.id, as_attachment=True)
|
||||
|
||||
@staticmethod
|
||||
def prepare_document_batch_download_zip(
|
||||
*,
|
||||
dataset_id: str,
|
||||
document_ids: Sequence[str],
|
||||
tenant_id: str,
|
||||
current_user: Account,
|
||||
) -> tuple[list[UploadFile], str]:
|
||||
"""
|
||||
Resolve upload files for batch ZIP downloads and generate a client-visible filename.
|
||||
"""
|
||||
dataset = DatasetService.get_dataset(dataset_id)
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
try:
|
||||
DatasetService.check_dataset_permission(dataset, current_user)
|
||||
except NoPermissionError as e:
|
||||
raise Forbidden(str(e))
|
||||
|
||||
upload_files_by_document_id = DocumentService._get_upload_files_by_document_id_for_zip_download(
|
||||
dataset_id=dataset_id,
|
||||
document_ids=document_ids,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
upload_files = [upload_files_by_document_id[document_id] for document_id in document_ids]
|
||||
download_name = DocumentService._generate_document_batch_download_zip_filename()
|
||||
return upload_files, download_name
|
||||
|
||||
@staticmethod
|
||||
def _generate_document_batch_download_zip_filename() -> str:
|
||||
"""
|
||||
Generate a random attachment filename for the batch download ZIP.
|
||||
"""
|
||||
return f"{uuid.uuid4().hex}{DocumentService.DOCUMENT_BATCH_DOWNLOAD_ZIP_FILENAME_EXTENSION}"
|
||||
|
||||
@staticmethod
|
||||
def _get_upload_file_id_for_upload_file_document(
|
||||
document: Document,
|
||||
*,
|
||||
invalid_source_message: str,
|
||||
missing_file_message: str,
|
||||
) -> str:
|
||||
"""
|
||||
Normalize and validate `Document -> UploadFile` linkage for download flows.
|
||||
"""
|
||||
if document.data_source_type != "upload_file":
|
||||
raise NotFound(invalid_source_message)
|
||||
|
||||
data_source_info: dict[str, Any] = document.data_source_info_dict or {}
|
||||
upload_file_id: str | None = data_source_info.get("upload_file_id")
|
||||
if not upload_file_id:
|
||||
raise NotFound(missing_file_message)
|
||||
|
||||
return str(upload_file_id)
|
||||
|
||||
@staticmethod
|
||||
def _get_upload_file_for_upload_file_document(document: Document) -> UploadFile:
|
||||
"""
|
||||
Load the `UploadFile` row for an upload-file document.
|
||||
"""
|
||||
upload_file_id = DocumentService._get_upload_file_id_for_upload_file_document(
|
||||
document,
|
||||
invalid_source_message="Document does not have an uploaded file to download.",
|
||||
missing_file_message="Uploaded file not found.",
|
||||
)
|
||||
upload_files_by_id = FileService.get_upload_files_by_ids(document.tenant_id, [upload_file_id])
|
||||
upload_file = upload_files_by_id.get(upload_file_id)
|
||||
if not upload_file:
|
||||
raise NotFound("Uploaded file not found.")
|
||||
return upload_file
|
||||
|
||||
@staticmethod
|
||||
def _get_upload_files_by_document_id_for_zip_download(
|
||||
*,
|
||||
dataset_id: str,
|
||||
document_ids: Sequence[str],
|
||||
tenant_id: str,
|
||||
) -> dict[str, UploadFile]:
|
||||
"""
|
||||
Batch load upload files keyed by document id for ZIP downloads.
|
||||
"""
|
||||
document_id_list: list[str] = [str(document_id) for document_id in document_ids]
|
||||
|
||||
documents = DocumentService.get_documents_by_ids(dataset_id, document_id_list)
|
||||
documents_by_id: dict[str, Document] = {str(document.id): document for document in documents}
|
||||
|
||||
missing_document_ids: set[str] = set(document_id_list) - set(documents_by_id.keys())
|
||||
if missing_document_ids:
|
||||
raise NotFound("Document not found.")
|
||||
|
||||
upload_file_ids: list[str] = []
|
||||
upload_file_ids_by_document_id: dict[str, str] = {}
|
||||
for document_id, document in documents_by_id.items():
|
||||
if document.tenant_id != tenant_id:
|
||||
raise Forbidden("No permission.")
|
||||
|
||||
upload_file_id = DocumentService._get_upload_file_id_for_upload_file_document(
|
||||
document,
|
||||
invalid_source_message="Only uploaded-file documents can be downloaded as ZIP.",
|
||||
missing_file_message="Only uploaded-file documents can be downloaded as ZIP.",
|
||||
)
|
||||
upload_file_ids.append(upload_file_id)
|
||||
upload_file_ids_by_document_id[document_id] = upload_file_id
|
||||
|
||||
upload_files_by_id = FileService.get_upload_files_by_ids(tenant_id, upload_file_ids)
|
||||
missing_upload_file_ids: set[str] = set(upload_file_ids) - set(upload_files_by_id.keys())
|
||||
if missing_upload_file_ids:
|
||||
raise NotFound("Only uploaded-file documents can be downloaded as ZIP.")
|
||||
|
||||
return {
|
||||
document_id: upload_files_by_id[upload_file_id]
|
||||
for document_id, upload_file_id in upload_file_ids_by_document_id.items()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_document_by_id(document_id: str) -> Document | None:
|
||||
document = db.session.query(Document).where(Document.id == document_id).first()
|
||||
|
||||
@ -2,7 +2,11 @@ import base64
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Iterator, Sequence
|
||||
from contextlib import contextmanager, suppress
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Literal, Union
|
||||
from zipfile import ZIP_DEFLATED, ZipFile
|
||||
|
||||
from sqlalchemy import Engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
@ -17,6 +21,7 @@ from constants import (
|
||||
)
|
||||
from core.file import helpers as file_helpers
|
||||
from core.rag.extractor.extract_processor import ExtractProcessor
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_storage import storage
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import extract_tenant_id
|
||||
@ -167,6 +172,9 @@ class FileService:
|
||||
return upload_file
|
||||
|
||||
def get_file_preview(self, file_id: str):
|
||||
"""
|
||||
Return a short text preview extracted from a document file.
|
||||
"""
|
||||
with self._session_maker(expire_on_commit=False) as session:
|
||||
upload_file = session.query(UploadFile).where(UploadFile.id == file_id).first()
|
||||
|
||||
@ -253,3 +261,101 @@ class FileService:
|
||||
return
|
||||
storage.delete(upload_file.key)
|
||||
session.delete(upload_file)
|
||||
|
||||
@staticmethod
|
||||
def get_upload_files_by_ids(tenant_id: str, upload_file_ids: Sequence[str]) -> dict[str, UploadFile]:
|
||||
"""
|
||||
Fetch `UploadFile` rows for a tenant in a single batch query.
|
||||
|
||||
This is a generic `UploadFile` lookup helper (not dataset/document specific), so it lives in `FileService`.
|
||||
"""
|
||||
if not upload_file_ids:
|
||||
return {}
|
||||
|
||||
# Normalize and deduplicate ids before using them in the IN clause.
|
||||
upload_file_id_list: list[str] = [str(upload_file_id) for upload_file_id in upload_file_ids]
|
||||
unique_upload_file_ids: list[str] = list(set(upload_file_id_list))
|
||||
|
||||
# Fetch upload files in one query for efficient batch access.
|
||||
upload_files: Sequence[UploadFile] = db.session.scalars(
|
||||
select(UploadFile).where(
|
||||
UploadFile.tenant_id == tenant_id,
|
||||
UploadFile.id.in_(unique_upload_file_ids),
|
||||
)
|
||||
).all()
|
||||
return {str(upload_file.id): upload_file for upload_file in upload_files}
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_zip_entry_name(name: str) -> str:
|
||||
"""
|
||||
Sanitize a ZIP entry name to avoid path traversal and weird separators.
|
||||
|
||||
We keep this conservative: the upload flow already rejects `/` and `\\`, but older rows (or imported data)
|
||||
could still contain unsafe names.
|
||||
"""
|
||||
# Drop any directory components and prevent empty names.
|
||||
base = os.path.basename(name).strip() or "file"
|
||||
|
||||
# ZIP uses forward slashes as separators; remove any residual separator characters.
|
||||
return base.replace("/", "_").replace("\\", "_")
|
||||
|
||||
@staticmethod
|
||||
def _dedupe_zip_entry_name(original_name: str, used_names: set[str]) -> str:
|
||||
"""
|
||||
Return a unique ZIP entry name, inserting suffixes before the extension.
|
||||
"""
|
||||
# Keep the original name when it's not already used.
|
||||
if original_name not in used_names:
|
||||
return original_name
|
||||
|
||||
# Insert suffixes before the extension (e.g., "doc.txt" -> "doc (1).txt").
|
||||
stem, extension = os.path.splitext(original_name)
|
||||
suffix = 1
|
||||
while True:
|
||||
candidate = f"{stem} ({suffix}){extension}"
|
||||
if candidate not in used_names:
|
||||
return candidate
|
||||
suffix += 1
|
||||
|
||||
@staticmethod
|
||||
@contextmanager
|
||||
def build_upload_files_zip_tempfile(
|
||||
*,
|
||||
upload_files: Sequence[UploadFile],
|
||||
) -> Iterator[str]:
|
||||
"""
|
||||
Build a ZIP from `UploadFile`s and yield a tempfile path.
|
||||
|
||||
We yield a path (rather than an open file handle) to avoid "read of closed file" issues when Flask/Werkzeug
|
||||
streams responses. The caller is expected to keep this context open until the response is fully sent, then
|
||||
close it (e.g., via `response.call_on_close(...)`) to delete the tempfile.
|
||||
"""
|
||||
used_names: set[str] = set()
|
||||
|
||||
# Build a ZIP in a temp file and keep it on disk until the caller finishes streaming it.
|
||||
tmp_path: str | None = None
|
||||
try:
|
||||
with NamedTemporaryFile(mode="w+b", suffix=".zip", delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
with ZipFile(tmp, mode="w", compression=ZIP_DEFLATED) as zf:
|
||||
for upload_file in upload_files:
|
||||
# Ensure the entry name is safe and unique.
|
||||
safe_name = FileService._sanitize_zip_entry_name(upload_file.name)
|
||||
arcname = FileService._dedupe_zip_entry_name(safe_name, used_names)
|
||||
used_names.add(arcname)
|
||||
|
||||
# Stream file bytes from storage into the ZIP entry.
|
||||
with zf.open(arcname, "w") as entry:
|
||||
for chunk in storage.load(upload_file.key, stream=True):
|
||||
entry.write(chunk)
|
||||
|
||||
# Flush so `send_file(path, ...)` can re-open it safely on all platforms.
|
||||
tmp.flush()
|
||||
|
||||
assert tmp_path is not None
|
||||
yield tmp_path
|
||||
finally:
|
||||
# Remove the temp file when the context is closed (typically after the response finishes streaming).
|
||||
if tmp_path is not None:
|
||||
with suppress(FileNotFoundError):
|
||||
os.remove(tmp_path)
|
||||
|
||||
@ -0,0 +1,430 @@
|
||||
"""
|
||||
Unit tests for the dataset document download endpoint.
|
||||
|
||||
These tests validate that the controller returns a signed download URL for
|
||||
upload-file documents, and rejects unsupported or missing file cases.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from collections import UserDict
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> Flask:
|
||||
"""Create a minimal Flask app for request-context based controller tests."""
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def datasets_document_module(monkeypatch: pytest.MonkeyPatch):
|
||||
"""
|
||||
Reload `controllers.console.datasets.datasets_document` with lightweight decorators.
|
||||
|
||||
We patch auth / setup / rate-limit decorators to no-ops so we can unit test the
|
||||
controller logic without requiring the full console stack.
|
||||
"""
|
||||
|
||||
from controllers.console import console_ns, wraps
|
||||
from libs import login
|
||||
|
||||
def _noop(func): # type: ignore[no-untyped-def]
|
||||
return func
|
||||
|
||||
# Bypass login/setup/account checks in unit tests.
|
||||
monkeypatch.setattr(login, "login_required", _noop)
|
||||
monkeypatch.setattr(wraps, "setup_required", _noop)
|
||||
monkeypatch.setattr(wraps, "account_initialization_required", _noop)
|
||||
|
||||
# Bypass billing-related decorators used by other endpoints in this module.
|
||||
monkeypatch.setattr(wraps, "cloud_edition_billing_resource_check", lambda *_args, **_kwargs: (lambda f: f))
|
||||
monkeypatch.setattr(wraps, "cloud_edition_billing_rate_limit_check", lambda *_args, **_kwargs: (lambda f: f))
|
||||
|
||||
# Avoid Flask-RESTX route registration side effects during import.
|
||||
def _noop_route(*_args, **_kwargs): # type: ignore[override]
|
||||
def _decorator(cls):
|
||||
return cls
|
||||
|
||||
return _decorator
|
||||
|
||||
monkeypatch.setattr(console_ns, "route", _noop_route)
|
||||
|
||||
module_name = "controllers.console.datasets.datasets_document"
|
||||
sys.modules.pop(module_name, None)
|
||||
return importlib.import_module(module_name)
|
||||
|
||||
|
||||
def _mock_user(*, is_dataset_editor: bool = True) -> SimpleNamespace:
|
||||
"""Build a minimal user object compatible with dataset permission checks."""
|
||||
return SimpleNamespace(is_dataset_editor=is_dataset_editor, id="user-123")
|
||||
|
||||
|
||||
def _mock_document(
|
||||
*,
|
||||
document_id: str,
|
||||
tenant_id: str,
|
||||
data_source_type: str,
|
||||
upload_file_id: str | None,
|
||||
) -> SimpleNamespace:
|
||||
"""Build a minimal document object used by the controller."""
|
||||
data_source_info_dict: dict[str, Any] | None = None
|
||||
if upload_file_id is not None:
|
||||
data_source_info_dict = {"upload_file_id": upload_file_id}
|
||||
else:
|
||||
data_source_info_dict = {}
|
||||
|
||||
return SimpleNamespace(
|
||||
id=document_id,
|
||||
tenant_id=tenant_id,
|
||||
data_source_type=data_source_type,
|
||||
data_source_info_dict=data_source_info_dict,
|
||||
)
|
||||
|
||||
|
||||
def _wire_common_success_mocks(
|
||||
*,
|
||||
module,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
current_tenant_id: str,
|
||||
document_tenant_id: str,
|
||||
data_source_type: str,
|
||||
upload_file_id: str | None,
|
||||
upload_file_exists: bool,
|
||||
signed_url: str,
|
||||
) -> None:
|
||||
"""Patch controller dependencies to create a deterministic test environment."""
|
||||
import services.dataset_service as dataset_service_module
|
||||
|
||||
# Make `current_account_with_tenant()` return a known user + tenant id.
|
||||
monkeypatch.setattr(module, "current_account_with_tenant", lambda: (_mock_user(), current_tenant_id))
|
||||
|
||||
# Return a dataset object and allow permission checks to pass.
|
||||
monkeypatch.setattr(module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1"))
|
||||
monkeypatch.setattr(module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None)
|
||||
|
||||
# Return a document that will be validated inside DocumentResource.get_document.
|
||||
document = _mock_document(
|
||||
document_id="doc-1",
|
||||
tenant_id=document_tenant_id,
|
||||
data_source_type=data_source_type,
|
||||
upload_file_id=upload_file_id,
|
||||
)
|
||||
monkeypatch.setattr(module.DocumentService, "get_document", lambda *_args, **_kwargs: document)
|
||||
|
||||
# Mock UploadFile lookup via FileService batch helper.
|
||||
upload_files_by_id: dict[str, Any] = {}
|
||||
if upload_file_exists and upload_file_id is not None:
|
||||
upload_files_by_id[str(upload_file_id)] = SimpleNamespace(id=str(upload_file_id))
|
||||
monkeypatch.setattr(module.FileService, "get_upload_files_by_ids", lambda *_args, **_kwargs: upload_files_by_id)
|
||||
|
||||
# Mock signing helper so the returned URL is deterministic.
|
||||
monkeypatch.setattr(dataset_service_module.file_helpers, "get_signed_file_url", lambda **_kwargs: signed_url)
|
||||
|
||||
|
||||
def _mock_send_file(obj, **kwargs): # type: ignore[no-untyped-def]
|
||||
"""Return a lightweight representation of `send_file(...)` for unit tests."""
|
||||
|
||||
class _ResponseMock(UserDict):
|
||||
def __init__(self, sent_file: object, send_file_kwargs: dict[str, object]) -> None:
|
||||
super().__init__({"_sent_file": sent_file, "_send_file_kwargs": send_file_kwargs})
|
||||
self._on_close: object | None = None
|
||||
|
||||
def call_on_close(self, func): # type: ignore[no-untyped-def]
|
||||
self._on_close = func
|
||||
return func
|
||||
|
||||
return _ResponseMock(obj, kwargs)
|
||||
|
||||
|
||||
def test_batch_download_zip_returns_send_file(
|
||||
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Ensure batch ZIP download returns a zip attachment via `send_file`."""
|
||||
|
||||
# Arrange common permission mocks.
|
||||
monkeypatch.setattr(datasets_document_module, "current_account_with_tenant", lambda: (_mock_user(), "tenant-123"))
|
||||
monkeypatch.setattr(
|
||||
datasets_document_module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None
|
||||
)
|
||||
|
||||
# Two upload-file documents, each referencing an UploadFile.
|
||||
doc1 = _mock_document(
|
||||
document_id="11111111-1111-1111-1111-111111111111",
|
||||
tenant_id="tenant-123",
|
||||
data_source_type="upload_file",
|
||||
upload_file_id="file-1",
|
||||
)
|
||||
doc2 = _mock_document(
|
||||
document_id="22222222-2222-2222-2222-222222222222",
|
||||
tenant_id="tenant-123",
|
||||
data_source_type="upload_file",
|
||||
upload_file_id="file-2",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
datasets_document_module.DocumentService,
|
||||
"get_documents_by_ids",
|
||||
lambda *_args, **_kwargs: [doc1, doc2],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
datasets_document_module.FileService,
|
||||
"get_upload_files_by_ids",
|
||||
lambda *_args, **_kwargs: {
|
||||
"file-1": SimpleNamespace(id="file-1", name="a.txt", key="k1"),
|
||||
"file-2": SimpleNamespace(id="file-2", name="b.txt", key="k2"),
|
||||
},
|
||||
)
|
||||
|
||||
# Mock storage streaming content.
|
||||
import services.file_service as file_service_module
|
||||
|
||||
monkeypatch.setattr(file_service_module.storage, "load", lambda _key, stream=True: [b"hello"])
|
||||
|
||||
# Replace send_file used by the controller to avoid a real Flask response object.
|
||||
monkeypatch.setattr(datasets_document_module, "send_file", _mock_send_file)
|
||||
|
||||
# Act
|
||||
with app.test_request_context(
|
||||
"/datasets/ds-1/documents/download-zip",
|
||||
method="POST",
|
||||
json={"document_ids": ["11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"]},
|
||||
):
|
||||
api = datasets_document_module.DocumentBatchDownloadZipApi()
|
||||
result = api.post(dataset_id="ds-1")
|
||||
|
||||
# Assert: we returned via send_file with correct mime type and attachment.
|
||||
assert result["_send_file_kwargs"]["mimetype"] == "application/zip"
|
||||
assert result["_send_file_kwargs"]["as_attachment"] is True
|
||||
assert isinstance(result["_send_file_kwargs"]["download_name"], str)
|
||||
assert result["_send_file_kwargs"]["download_name"].endswith(".zip")
|
||||
# Ensure our cleanup hook is registered and execute it to avoid temp file leaks in unit tests.
|
||||
assert getattr(result, "_on_close", None) is not None
|
||||
result._on_close() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_batch_download_zip_response_is_openable_zip(
|
||||
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Ensure the real Flask `send_file` response body is a valid ZIP that can be opened."""
|
||||
|
||||
# Arrange: same controller mocks as the lightweight send_file test, but we keep the real `send_file`.
|
||||
monkeypatch.setattr(datasets_document_module, "current_account_with_tenant", lambda: (_mock_user(), "tenant-123"))
|
||||
monkeypatch.setattr(
|
||||
datasets_document_module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None
|
||||
)
|
||||
|
||||
doc1 = _mock_document(
|
||||
document_id="33333333-3333-3333-3333-333333333333",
|
||||
tenant_id="tenant-123",
|
||||
data_source_type="upload_file",
|
||||
upload_file_id="file-1",
|
||||
)
|
||||
doc2 = _mock_document(
|
||||
document_id="44444444-4444-4444-4444-444444444444",
|
||||
tenant_id="tenant-123",
|
||||
data_source_type="upload_file",
|
||||
upload_file_id="file-2",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
datasets_document_module.DocumentService,
|
||||
"get_documents_by_ids",
|
||||
lambda *_args, **_kwargs: [doc1, doc2],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
datasets_document_module.FileService,
|
||||
"get_upload_files_by_ids",
|
||||
lambda *_args, **_kwargs: {
|
||||
"file-1": SimpleNamespace(id="file-1", name="a.txt", key="k1"),
|
||||
"file-2": SimpleNamespace(id="file-2", name="b.txt", key="k2"),
|
||||
},
|
||||
)
|
||||
|
||||
# Stream distinct bytes per key so we can verify both ZIP entries.
|
||||
import services.file_service as file_service_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
file_service_module.storage, "load", lambda key, stream=True: [b"one"] if key == "k1" else [b"two"]
|
||||
)
|
||||
|
||||
# Act
|
||||
with app.test_request_context(
|
||||
"/datasets/ds-1/documents/download-zip",
|
||||
method="POST",
|
||||
json={"document_ids": ["33333333-3333-3333-3333-333333333333", "44444444-4444-4444-4444-444444444444"]},
|
||||
):
|
||||
api = datasets_document_module.DocumentBatchDownloadZipApi()
|
||||
response = api.post(dataset_id="ds-1")
|
||||
|
||||
# Assert: response body is a valid ZIP and contains the expected entries.
|
||||
response.direct_passthrough = False
|
||||
data = response.get_data()
|
||||
response.close()
|
||||
|
||||
with ZipFile(BytesIO(data), mode="r") as zf:
|
||||
assert zf.namelist() == ["a.txt", "b.txt"]
|
||||
assert zf.read("a.txt") == b"one"
|
||||
assert zf.read("b.txt") == b"two"
|
||||
|
||||
|
||||
def test_batch_download_zip_rejects_non_upload_file_document(
|
||||
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Ensure batch ZIP download rejects non upload-file documents."""
|
||||
|
||||
monkeypatch.setattr(datasets_document_module, "current_account_with_tenant", lambda: (_mock_user(), "tenant-123"))
|
||||
monkeypatch.setattr(
|
||||
datasets_document_module.DatasetService, "get_dataset", lambda _dataset_id: SimpleNamespace(id="ds-1")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
datasets_document_module.DatasetService, "check_dataset_permission", lambda *_args, **_kwargs: None
|
||||
)
|
||||
|
||||
doc = _mock_document(
|
||||
document_id="55555555-5555-5555-5555-555555555555",
|
||||
tenant_id="tenant-123",
|
||||
data_source_type="website_crawl",
|
||||
upload_file_id="file-1",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
datasets_document_module.DocumentService,
|
||||
"get_documents_by_ids",
|
||||
lambda *_args, **_kwargs: [doc],
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/datasets/ds-1/documents/download-zip",
|
||||
method="POST",
|
||||
json={"document_ids": ["55555555-5555-5555-5555-555555555555"]},
|
||||
):
|
||||
api = datasets_document_module.DocumentBatchDownloadZipApi()
|
||||
with pytest.raises(NotFound):
|
||||
api.post(dataset_id="ds-1")
|
||||
|
||||
|
||||
def test_document_download_returns_url_for_upload_file_document(
|
||||
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Ensure upload-file documents return a `{url}` JSON payload."""
|
||||
|
||||
_wire_common_success_mocks(
|
||||
module=datasets_document_module,
|
||||
monkeypatch=monkeypatch,
|
||||
current_tenant_id="tenant-123",
|
||||
document_tenant_id="tenant-123",
|
||||
data_source_type="upload_file",
|
||||
upload_file_id="file-123",
|
||||
upload_file_exists=True,
|
||||
signed_url="https://example.com/signed",
|
||||
)
|
||||
|
||||
# Build a request context then call the resource method directly.
|
||||
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
|
||||
api = datasets_document_module.DocumentDownloadApi()
|
||||
result = api.get(dataset_id="ds-1", document_id="doc-1")
|
||||
|
||||
assert result == {"url": "https://example.com/signed"}
|
||||
|
||||
|
||||
def test_document_download_rejects_non_upload_file_document(
|
||||
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Ensure non-upload documents raise 404 (no file to download)."""
|
||||
|
||||
_wire_common_success_mocks(
|
||||
module=datasets_document_module,
|
||||
monkeypatch=monkeypatch,
|
||||
current_tenant_id="tenant-123",
|
||||
document_tenant_id="tenant-123",
|
||||
data_source_type="website_crawl",
|
||||
upload_file_id="file-123",
|
||||
upload_file_exists=True,
|
||||
signed_url="https://example.com/signed",
|
||||
)
|
||||
|
||||
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
|
||||
api = datasets_document_module.DocumentDownloadApi()
|
||||
with pytest.raises(NotFound):
|
||||
api.get(dataset_id="ds-1", document_id="doc-1")
|
||||
|
||||
|
||||
def test_document_download_rejects_missing_upload_file_id(
|
||||
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Ensure missing `upload_file_id` raises 404."""
|
||||
|
||||
_wire_common_success_mocks(
|
||||
module=datasets_document_module,
|
||||
monkeypatch=monkeypatch,
|
||||
current_tenant_id="tenant-123",
|
||||
document_tenant_id="tenant-123",
|
||||
data_source_type="upload_file",
|
||||
upload_file_id=None,
|
||||
upload_file_exists=False,
|
||||
signed_url="https://example.com/signed",
|
||||
)
|
||||
|
||||
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
|
||||
api = datasets_document_module.DocumentDownloadApi()
|
||||
with pytest.raises(NotFound):
|
||||
api.get(dataset_id="ds-1", document_id="doc-1")
|
||||
|
||||
|
||||
def test_document_download_rejects_when_upload_file_record_missing(
|
||||
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Ensure missing UploadFile row raises 404."""
|
||||
|
||||
_wire_common_success_mocks(
|
||||
module=datasets_document_module,
|
||||
monkeypatch=monkeypatch,
|
||||
current_tenant_id="tenant-123",
|
||||
document_tenant_id="tenant-123",
|
||||
data_source_type="upload_file",
|
||||
upload_file_id="file-123",
|
||||
upload_file_exists=False,
|
||||
signed_url="https://example.com/signed",
|
||||
)
|
||||
|
||||
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
|
||||
api = datasets_document_module.DocumentDownloadApi()
|
||||
with pytest.raises(NotFound):
|
||||
api.get(dataset_id="ds-1", document_id="doc-1")
|
||||
|
||||
|
||||
def test_document_download_rejects_tenant_mismatch(
|
||||
app: Flask, datasets_document_module, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Ensure tenant mismatch is rejected by the shared `get_document()` permission check."""
|
||||
|
||||
_wire_common_success_mocks(
|
||||
module=datasets_document_module,
|
||||
monkeypatch=monkeypatch,
|
||||
current_tenant_id="tenant-123",
|
||||
document_tenant_id="tenant-999",
|
||||
data_source_type="upload_file",
|
||||
upload_file_id="file-123",
|
||||
upload_file_exists=True,
|
||||
signed_url="https://example.com/signed",
|
||||
)
|
||||
|
||||
with app.test_request_context("/datasets/ds-1/documents/doc-1/download", method="GET"):
|
||||
api = datasets_document_module.DocumentDownloadApi()
|
||||
with pytest.raises(Forbidden):
|
||||
api.get(dataset_id="ds-1", document_id="doc-1")
|
||||
@ -0,0 +1,99 @@
|
||||
"""
|
||||
Unit tests for `services.file_service.FileService` helpers.
|
||||
|
||||
We keep these tests focused on:
|
||||
- ZIP tempfile building (sanitization + deduplication + content writes)
|
||||
- tenant-scoped batch lookup behavior (`get_upload_files_by_ids`)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
|
||||
import services.file_service as file_service_module
|
||||
from services.file_service import FileService
|
||||
|
||||
|
||||
def test_build_upload_files_zip_tempfile_sanitizes_and_dedupes_names(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ensure ZIP entry names are safe and unique while preserving extensions."""
|
||||
|
||||
# Arrange: three upload files that all sanitize down to the same basename ("b.txt").
|
||||
upload_files: list[Any] = [
|
||||
SimpleNamespace(name="a/b.txt", key="k1"),
|
||||
SimpleNamespace(name="c/b.txt", key="k2"),
|
||||
SimpleNamespace(name="../b.txt", key="k3"),
|
||||
]
|
||||
|
||||
# Stream distinct bytes per key so we can verify content is written to the right entry.
|
||||
data_by_key: dict[str, list[bytes]] = {"k1": [b"one"], "k2": [b"two"], "k3": [b"three"]}
|
||||
|
||||
def _load(key: str, stream: bool = True) -> list[bytes]:
|
||||
# Return the corresponding chunks for this key (the production code iterates chunks).
|
||||
assert stream is True
|
||||
return data_by_key[key]
|
||||
|
||||
monkeypatch.setattr(file_service_module.storage, "load", _load)
|
||||
|
||||
# Act: build zip in a tempfile.
|
||||
with FileService.build_upload_files_zip_tempfile(upload_files=upload_files) as tmp:
|
||||
with ZipFile(tmp, mode="r") as zf:
|
||||
# Assert: names are sanitized (no directory components) and deduped with suffixes.
|
||||
assert zf.namelist() == ["b.txt", "b (1).txt", "b (2).txt"]
|
||||
|
||||
# Assert: each entry contains the correct bytes from storage.
|
||||
assert zf.read("b.txt") == b"one"
|
||||
assert zf.read("b (1).txt") == b"two"
|
||||
assert zf.read("b (2).txt") == b"three"
|
||||
|
||||
|
||||
def test_get_upload_files_by_ids_returns_empty_when_no_ids(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ensure empty input returns an empty mapping without hitting the database."""
|
||||
|
||||
class _Session:
|
||||
def scalars(self, _stmt): # type: ignore[no-untyped-def]
|
||||
raise AssertionError("db.session.scalars should not be called for empty id lists")
|
||||
|
||||
monkeypatch.setattr(file_service_module, "db", SimpleNamespace(session=_Session()))
|
||||
|
||||
assert FileService.get_upload_files_by_ids("tenant-1", []) == {}
|
||||
|
||||
|
||||
def test_get_upload_files_by_ids_returns_id_keyed_mapping(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ensure batch lookup returns a dict keyed by stringified UploadFile ids."""
|
||||
|
||||
upload_files: list[Any] = [
|
||||
SimpleNamespace(id="file-1", tenant_id="tenant-1"),
|
||||
SimpleNamespace(id="file-2", tenant_id="tenant-1"),
|
||||
]
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, items: list[Any]) -> None:
|
||||
self._items = items
|
||||
|
||||
def all(self) -> list[Any]:
|
||||
return self._items
|
||||
|
||||
class _Session:
|
||||
def __init__(self, items: list[Any]) -> None:
|
||||
self._items = items
|
||||
self.calls: list[object] = []
|
||||
|
||||
def scalars(self, stmt): # type: ignore[no-untyped-def]
|
||||
# Capture the statement so we can at least assert the query path is taken.
|
||||
self.calls.append(stmt)
|
||||
return _ScalarResult(self._items)
|
||||
|
||||
session = _Session(upload_files)
|
||||
monkeypatch.setattr(file_service_module, "db", SimpleNamespace(session=session))
|
||||
|
||||
# Provide duplicates to ensure callers can safely pass repeated ids.
|
||||
result = FileService.get_upload_files_by_ids("tenant-1", ["file-1", "file-1", "file-2"])
|
||||
|
||||
assert set(result.keys()) == {"file-1", "file-2"}
|
||||
assert result["file-1"].id == "file-1"
|
||||
assert result["file-2"].id == "file-2"
|
||||
assert len(session.calls) == 1
|
||||
4
web/.vscode/extensions.json
vendored
4
web/.vscode/extensions.json
vendored
@ -1,6 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"kisstkondoros.vscode-codemetrics"
|
||||
"kisstkondoros.vscode-codemetrics",
|
||||
"johnsoncodehk.vscode-tsslint",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { FC } from 'react'
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
import type { Resources } from './index'
|
||||
import Link from 'next/link'
|
||||
import { Fragment, useState } from 'react'
|
||||
@ -18,6 +18,8 @@ import {
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useDocumentDownload } from '@/service/knowledge/use-document'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import ProgressTooltip from './progress-tooltip'
|
||||
import Tooltip from './tooltip'
|
||||
|
||||
@ -36,6 +38,30 @@ const Popup: FC<PopupProps> = ({
|
||||
? (/\.([^.]*)$/.exec(data.documentName)?.[1] || '')
|
||||
: 'notion'
|
||||
|
||||
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
|
||||
|
||||
/**
|
||||
* Download the original uploaded file for citations whose data source is upload-file.
|
||||
* We request a signed URL from the dataset document download endpoint, then trigger browser download.
|
||||
*/
|
||||
const handleDownloadUploadFile = async (e: MouseEvent<HTMLElement>) => {
|
||||
// Prevent toggling the citation popup when user clicks the download link.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Only upload-file citations can be downloaded this way (needs dataset/document ids).
|
||||
const isUploadFile = data.dataSourceType === 'upload_file' || data.dataSourceType === 'file'
|
||||
const datasetId = data.sources?.[0]?.dataset_id
|
||||
const documentId = data.documentId || data.sources?.[0]?.document_id
|
||||
if (!isUploadFile || !datasetId || !documentId || isDownloading)
|
||||
return
|
||||
|
||||
// Fetch signed URL (usually points to `/files/<id>/file-preview?...&as_attachment=true`).
|
||||
const res = await downloadDocument({ datasetId, documentId })
|
||||
if (res?.url)
|
||||
downloadUrl({ url: res.url, fileName: data.documentName })
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
@ -49,6 +75,7 @@ const Popup: FC<PopupProps> = ({
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
|
||||
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
||||
{/* Keep the trigger purely for opening the popup (no download link here). */}
|
||||
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
@ -57,7 +84,21 @@ const Popup: FC<PopupProps> = ({
|
||||
<div className="px-4 pb-2 pt-3">
|
||||
<div className="flex h-[18px] items-center">
|
||||
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
||||
<div className="system-xs-medium truncate text-text-tertiary">{data.documentName}</div>
|
||||
<div className="system-xs-medium truncate text-text-tertiary">
|
||||
{/* If it's an upload-file reference, the title becomes a download link. */}
|
||||
{(data.dataSourceType === 'upload_file' || data.dataSourceType === 'file') && !!data.sources?.[0]?.dataset_id
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer truncate text-text-tertiary hover:underline"
|
||||
onClick={handleDownloadUploadFile}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{data.documentName}
|
||||
</button>
|
||||
)
|
||||
: data.documentName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[450px] overflow-y-auto rounded-lg bg-components-panel-bg px-4 py-0.5">
|
||||
|
||||
@ -30,9 +30,10 @@ import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { ChunkingMode, DataSourceType, DocumentActionType } from '@/models/datasets'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentEnable } from '@/service/knowledge/use-document'
|
||||
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentDownloadZip, useDocumentEnable } from '@/service/knowledge/use-document'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import BatchAction from '../detail/completed/common/batch-action'
|
||||
import StatusItem from '../status-item'
|
||||
@ -222,6 +223,7 @@ const DocumentList: FC<IDocumentListProps> = ({
|
||||
const { mutateAsync: disableDocument } = useDocumentDisable()
|
||||
const { mutateAsync: deleteDocument } = useDocumentDelete()
|
||||
const { mutateAsync: retryIndexDocument } = useDocumentBatchRetryIndex()
|
||||
const { mutateAsync: requestDocumentsZip, isPending: isDownloadingZip } = useDocumentDownloadZip()
|
||||
|
||||
const handleAction = (actionName: DocumentActionType) => {
|
||||
return async () => {
|
||||
@ -300,6 +302,39 @@ const DocumentList: FC<IDocumentListProps> = ({
|
||||
return dataSourceType === DatasourceType.onlineDrive
|
||||
}, [])
|
||||
|
||||
const downloadableSelectedIds = useMemo(() => {
|
||||
const selectedSet = new Set(selectedIds)
|
||||
return localDocs
|
||||
.filter(doc => selectedSet.has(doc.id) && doc.data_source_type === DataSourceType.FILE)
|
||||
.map(doc => doc.id)
|
||||
}, [localDocs, selectedIds])
|
||||
|
||||
/**
|
||||
* Generate a random ZIP filename for bulk document downloads.
|
||||
* We intentionally avoid leaking dataset info in the exported archive name.
|
||||
*/
|
||||
const generateDocsZipFileName = useCallback((): string => {
|
||||
// Prefer UUID for uniqueness; fall back to time+random when unavailable.
|
||||
const randomPart = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
||||
return `${randomPart}-docs.zip`
|
||||
}, [])
|
||||
|
||||
const handleBatchDownload = useCallback(async () => {
|
||||
if (isDownloadingZip)
|
||||
return
|
||||
|
||||
// Download as a single ZIP to avoid browser caps on multiple automatic downloads.
|
||||
const [e, blob] = await asyncRunSafe(requestDocumentsZip({ datasetId, documentIds: downloadableSelectedIds }))
|
||||
if (e || !blob) {
|
||||
Toast.notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
|
||||
return
|
||||
}
|
||||
|
||||
downloadBlob({ data: blob, fileName: generateDocsZipFileName() })
|
||||
}, [datasetId, downloadableSelectedIds, generateDocsZipFileName, isDownloadingZip, requestDocumentsZip, t])
|
||||
|
||||
return (
|
||||
<div className="relative mt-3 flex h-full w-full flex-col">
|
||||
<div className="relative h-0 grow overflow-x-auto">
|
||||
@ -463,6 +498,7 @@ const DocumentList: FC<IDocumentListProps> = ({
|
||||
onArchive={handleAction(DocumentActionType.archive)}
|
||||
onBatchEnable={handleAction(DocumentActionType.enable)}
|
||||
onBatchDisable={handleAction(DocumentActionType.disable)}
|
||||
onBatchDownload={downloadableSelectedIds.length > 0 ? handleBatchDownload : undefined}
|
||||
onBatchDelete={handleAction(DocumentActionType.delete)}
|
||||
onEditMetadata={showEditModal}
|
||||
onBatchReIndex={hasErrorDocumentsSelected ? handleBatchReIndex : undefined}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import type { OperationName } from '../types'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import type { DocumentDownloadResponse } from '@/service/datasets'
|
||||
import {
|
||||
RiArchive2Line,
|
||||
RiDeleteBinLine,
|
||||
RiDownload2Line,
|
||||
RiEditLine,
|
||||
RiEqualizer2Line,
|
||||
RiLoopLeftLine,
|
||||
@ -28,6 +30,7 @@ import {
|
||||
useDocumentArchive,
|
||||
useDocumentDelete,
|
||||
useDocumentDisable,
|
||||
useDocumentDownload,
|
||||
useDocumentEnable,
|
||||
useDocumentPause,
|
||||
useDocumentResume,
|
||||
@ -37,6 +40,7 @@ import {
|
||||
} from '@/service/knowledge/use-document'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import s from '../style.module.css'
|
||||
import RenameModal from './rename-modal'
|
||||
|
||||
@ -69,7 +73,7 @@ const Operations = ({
|
||||
scene = 'list',
|
||||
className = '',
|
||||
}: OperationsProps) => {
|
||||
const { id, enabled = false, archived = false, data_source_type, display_status } = detail || {}
|
||||
const { id, name, enabled = false, archived = false, data_source_type, display_status } = detail || {}
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const { notify } = useContext(ToastContext)
|
||||
@ -80,6 +84,7 @@ const Operations = ({
|
||||
const { mutateAsync: enableDocument } = useDocumentEnable()
|
||||
const { mutateAsync: disableDocument } = useDocumentDisable()
|
||||
const { mutateAsync: deleteDocument } = useDocumentDelete()
|
||||
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
|
||||
const { mutateAsync: syncDocument } = useSyncDocument()
|
||||
const { mutateAsync: syncWebsite } = useSyncWebsite()
|
||||
const { mutateAsync: pauseDocument } = useDocumentPause()
|
||||
@ -158,6 +163,24 @@ const Operations = ({
|
||||
onUpdate()
|
||||
}, [onUpdate])
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
// Avoid repeated clicks while the signed URL request is in-flight.
|
||||
if (isDownloading)
|
||||
return
|
||||
|
||||
// Request a signed URL first (it points to `/files/<id>/file-preview?...&as_attachment=true`).
|
||||
const [e, res] = await asyncRunSafe<DocumentDownloadResponse>(
|
||||
downloadDocument({ datasetId, documentId: id }) as Promise<DocumentDownloadResponse>,
|
||||
)
|
||||
if (e || !res?.url) {
|
||||
notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger download without navigating away (helps avoid duplicate downloads in some browsers).
|
||||
downloadUrl({ url: res.url, fileName: name })
|
||||
}, [datasetId, downloadDocument, id, isDownloading, name, notify, t])
|
||||
|
||||
return (
|
||||
<div className="flex items-center" onClick={e => e.stopPropagation()}>
|
||||
{isListScene && !embeddingAvailable && (
|
||||
@ -214,6 +237,20 @@ const Operations = ({
|
||||
<RiEditLine className="h-4 w-4 text-text-tertiary" />
|
||||
<span className={s.actionName}>{t('list.table.rename', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
{data_source_type === DataSourceType.FILE && (
|
||||
<div
|
||||
className={s.actionItem}
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
evt.nativeEvent.stopImmediatePropagation?.()
|
||||
handleDownload()
|
||||
}}
|
||||
>
|
||||
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
|
||||
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{['notion_import', DataSourceType.WEB].includes(data_source_type) && (
|
||||
<div className={s.actionItem} onClick={() => onOperate('sync')}>
|
||||
<RiLoopLeftLine className="h-4 w-4 text-text-tertiary" />
|
||||
@ -223,6 +260,23 @@ const Operations = ({
|
||||
<Divider className="my-1" />
|
||||
</>
|
||||
)}
|
||||
{archived && data_source_type === DataSourceType.FILE && (
|
||||
<>
|
||||
<div
|
||||
className={s.actionItem}
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
evt.nativeEvent.stopImmediatePropagation?.()
|
||||
handleDownload()
|
||||
}}
|
||||
>
|
||||
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
|
||||
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
|
||||
</div>
|
||||
<Divider className="my-1" />
|
||||
</>
|
||||
)}
|
||||
{!archived && display_status?.toLowerCase() === 'indexing' && (
|
||||
<div className={s.actionItem} onClick={() => onOperate('pause')}>
|
||||
<RiPauseCircleLine className="h-4 w-4 text-text-tertiary" />
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDraftLine, RiRefreshLine } from '@remixicon/react'
|
||||
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDownload2Line, RiDraftLine, RiRefreshLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -14,6 +14,7 @@ type IBatchActionProps = {
|
||||
selectedIds: string[]
|
||||
onBatchEnable: () => void
|
||||
onBatchDisable: () => void
|
||||
onBatchDownload?: () => void
|
||||
onBatchDelete: () => Promise<void>
|
||||
onArchive?: () => void
|
||||
onEditMetadata?: () => void
|
||||
@ -26,6 +27,7 @@ const BatchAction: FC<IBatchActionProps> = ({
|
||||
selectedIds,
|
||||
onBatchEnable,
|
||||
onBatchDisable,
|
||||
onBatchDownload,
|
||||
onArchive,
|
||||
onBatchDelete,
|
||||
onEditMetadata,
|
||||
@ -103,6 +105,16 @@ const BatchAction: FC<IBatchActionProps> = ({
|
||||
<span className="px-0.5">{t(`${i18nPrefix}.reIndex`, { ns: 'dataset' })}</span>
|
||||
</Button>
|
||||
)}
|
||||
{onBatchDownload && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-x-0.5 px-3"
|
||||
onClick={onBatchDownload}
|
||||
>
|
||||
<RiDownload2Line className="size-4" />
|
||||
<span className="px-0.5">{t(`${i18nPrefix}.download`, { ns: 'dataset' })}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
destructive
|
||||
|
||||
@ -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(<Form {...defaultProps} />)
|
||||
expect(container.querySelector('form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all form fields based on formSchemas', () => {
|
||||
render(<Form {...defaultProps} />)
|
||||
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(<Form {...defaultProps} />)
|
||||
const labels = screen.getAllByText('*')
|
||||
expect(labels.length).toBe(3) // All 3 fields are required
|
||||
})
|
||||
|
||||
it('should render documentation link for endpoint field', () => {
|
||||
render(<Form {...defaultProps} />)
|
||||
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(<Form {...defaultProps} />)
|
||||
const apiKeyInput = screen.getByLabelText(/api key/i)
|
||||
expect(apiKeyInput).toHaveAttribute('type', 'password')
|
||||
})
|
||||
|
||||
it('should render text type input for text fields', () => {
|
||||
render(<Form {...defaultProps} />)
|
||||
const nameInput = screen.getByLabelText(/name/i)
|
||||
expect(nameInput).toHaveAttribute('type', 'text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className to form', () => {
|
||||
const { container } = render(<Form {...defaultProps} className="custom-form-class" />)
|
||||
expect(container.querySelector('form')).toHaveClass('custom-form-class')
|
||||
})
|
||||
|
||||
it('should apply itemClassName to form items', () => {
|
||||
const { container } = render(<Form {...defaultProps} itemClassName="custom-item-class" />)
|
||||
const items = container.querySelectorAll('.custom-item-class')
|
||||
expect(items.length).toBe(3)
|
||||
})
|
||||
|
||||
it('should apply fieldLabelClassName to labels', () => {
|
||||
const { container } = render(<Form {...defaultProps} fieldLabelClassName="custom-label-class" />)
|
||||
const labels = container.querySelectorAll('label.custom-label-class')
|
||||
expect(labels.length).toBe(3)
|
||||
})
|
||||
|
||||
it('should apply inputClassName to inputs', () => {
|
||||
render(<Form {...defaultProps} inputClassName="custom-input-class" />)
|
||||
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(<Form {...defaultProps} value={valueWithData} />)
|
||||
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(<Form {...defaultProps} onChange={onChange} />)
|
||||
|
||||
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(<Form {...defaultProps} onChange={onChange} />)
|
||||
|
||||
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(<Form {...defaultProps} onChange={onChange} />)
|
||||
|
||||
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(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
|
||||
|
||||
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(<Form {...defaultProps} formSchemas={[]} />)
|
||||
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(<Form {...defaultProps} formSchemas={schemasWithOptional} />)
|
||||
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(<Form {...defaultProps} formSchemas={schemasWithEnOnly} />)
|
||||
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(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
|
||||
|
||||
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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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(<AddExternalAPIModal {...defaultProps} />)
|
||||
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render create title when not in edit mode', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} isEditMode={false} />)
|
||||
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit title when in edit mode', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
|
||||
expect(screen.getByText('dataset.editExternalAPIFormTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render form fields', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
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(<AddExternalAPIModal {...defaultProps} />)
|
||||
expect(screen.getByText('dataset.externalAPIForm.cancel')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.externalAPIForm.save')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render encryption notice', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
expect(screen.getByText('PKCS1_OAEP')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
// 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(
|
||||
<AddExternalAPIModal
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
data={initialData}
|
||||
datasetBindings={datasetBindings}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<AddExternalAPIModal
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
data={initialData}
|
||||
datasetBindings={[]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Interactions', () => {
|
||||
it('should update form values when input changes', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
|
||||
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(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
|
||||
|
||||
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(<AddExternalAPIModal {...defaultProps} />)
|
||||
|
||||
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
|
||||
expect(saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable save button when all fields are filled', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
|
||||
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(<AddExternalAPIModal {...defaultProps} onSave={onSave} onCancel={onCancel} />)
|
||||
|
||||
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(<AddExternalAPIModal {...defaultProps} />)
|
||||
|
||||
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(<AddExternalAPIModal {...defaultProps} />)
|
||||
|
||||
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(
|
||||
<AddExternalAPIModal
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
data={initialData}
|
||||
datasetBindings={[]}
|
||||
onEdit={onEdit}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<AddExternalAPIModal
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
data={initialData}
|
||||
datasetBindings={datasetBindings}
|
||||
onEdit={onEdit}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<AddExternalAPIModal
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
data={initialData}
|
||||
datasetBindings={datasetBindings}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<AddExternalAPIModal
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
data={initialData}
|
||||
datasetBindings={datasetBindings}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
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(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
|
||||
|
||||
// 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(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={undefined} />)
|
||||
expect(screen.getByLabelText(/name/i)).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle null datasetBindings', () => {
|
||||
render(
|
||||
<AddExternalAPIModal
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
data={initialData}
|
||||
datasetBindings={undefined}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render documentation link in encryption notice', () => {
|
||||
render(<AddExternalAPIModal {...defaultProps} />)
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 }) => (
|
||||
<div data-testid={`api-card-${api.id}`}>{api.name}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// 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(<ExternalAPIPanel {...defaultProps} />)
|
||||
expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render panel title and description', () => {
|
||||
render(<ExternalAPIPanel {...defaultProps} />)
|
||||
expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.externalAPIPanelDescription')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render documentation link', () => {
|
||||
render(<ExternalAPIPanel {...defaultProps} />)
|
||||
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(<ExternalAPIPanel {...defaultProps} />)
|
||||
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
const { container } = render(<ExternalAPIPanel {...defaultProps} />)
|
||||
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(<ExternalAPIPanel {...defaultProps} />)
|
||||
// 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(<ExternalAPIPanel {...defaultProps} />)
|
||||
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(<ExternalAPIPanel {...defaultProps} />)
|
||||
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(<ExternalAPIPanel onClose={onClose} />)
|
||||
// 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(<ExternalAPIPanel {...defaultProps} />)
|
||||
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(<ExternalAPIPanel {...defaultProps} />)
|
||||
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(<ExternalAPIPanel {...defaultProps} />)
|
||||
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(<ExternalAPIPanel {...defaultProps} />)
|
||||
expect(screen.getByTestId('api-card-single-api')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render documentation link with correct target', () => {
|
||||
render(<ExternalAPIPanel {...defaultProps} />)
|
||||
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation').closest('a')
|
||||
expect(docLink).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
expect(screen.getByText('Test External API')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API name', () => {
|
||||
render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
expect(screen.getByText('Test External API')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render API endpoint', () => {
|
||||
render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
expect(screen.getByText('https://api.example.com/knowledge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit and delete buttons', () => {
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
const buttons = container.querySelectorAll('button')
|
||||
expect(buttons.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should render API connection icon', () => {
|
||||
const { container } = render(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
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(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
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(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
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(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
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(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
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(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
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(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
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(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
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(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
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(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
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(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
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(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
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(<ExternalKnowledgeAPICard api={apiWithEmptyEndpoint} />)
|
||||
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(<ExternalKnowledgeAPICard {...defaultProps} />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
1169
web/app/components/datasets/extra-info/index.spec.tsx
Normal file
1169
web/app/components/datasets/extra-info/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
2654
web/app/components/datasets/hit-testing/index.spec.tsx
Normal file
2654
web/app/components/datasets/hit-testing/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,125 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import CornerLabels from './corner-labels'
|
||||
|
||||
describe('CornerLabels', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'general',
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when embedding is available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
const { container } = render(<CornerLabels dataset={dataset} />)
|
||||
// Should render null when embedding is available and not pipeline
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render unavailable label when embedding is not available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
render(<CornerLabels dataset={dataset} />)
|
||||
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render pipeline label when runtime_mode is rag_pipeline', () => {
|
||||
const dataset = createMockDataset({
|
||||
embedding_available: true,
|
||||
runtime_mode: 'rag_pipeline',
|
||||
})
|
||||
render(<CornerLabels dataset={dataset} />)
|
||||
expect(screen.getByText(/pipeline/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should not render when embedding is available and not pipeline', () => {
|
||||
const dataset = createMockDataset({
|
||||
embedding_available: true,
|
||||
runtime_mode: 'general',
|
||||
})
|
||||
const { container } = render(<CornerLabels dataset={dataset} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should prioritize unavailable label over pipeline label', () => {
|
||||
const dataset = createMockDataset({
|
||||
embedding_available: false,
|
||||
runtime_mode: 'rag_pipeline',
|
||||
})
|
||||
render(<CornerLabels dataset={dataset} />)
|
||||
// Should show unavailable since embedding_available is checked first
|
||||
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/pipeline/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct positioning for unavailable label', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
const { container } = render(<CornerLabels dataset={dataset} />)
|
||||
const labelContainer = container.firstChild as HTMLElement
|
||||
expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10')
|
||||
})
|
||||
|
||||
it('should have correct positioning for pipeline label', () => {
|
||||
const dataset = createMockDataset({
|
||||
embedding_available: true,
|
||||
runtime_mode: 'rag_pipeline',
|
||||
})
|
||||
const { container } = render(<CornerLabels dataset={dataset} />)
|
||||
const labelContainer = container.firstChild as HTMLElement
|
||||
expect(labelContainer).toHaveClass('absolute', 'right-0', 'top-0', 'z-10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined runtime_mode', () => {
|
||||
const dataset = createMockDataset({
|
||||
embedding_available: true,
|
||||
runtime_mode: undefined,
|
||||
})
|
||||
const { container } = render(<CornerLabels dataset={dataset} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle empty string runtime_mode', () => {
|
||||
const dataset = createMockDataset({
|
||||
embedding_available: true,
|
||||
runtime_mode: '' as DataSet['runtime_mode'],
|
||||
})
|
||||
const { container } = render(<CornerLabels dataset={dataset} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle all false conditions', () => {
|
||||
const dataset = createMockDataset({
|
||||
embedding_available: true,
|
||||
runtime_mode: 'general',
|
||||
})
|
||||
const { container } = render(<CornerLabels dataset={dataset} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,177 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import DatasetCardFooter from './dataset-card-footer'
|
||||
|
||||
// Mock the useFormatTimeFromNow hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: vi.fn((timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return `${date.toLocaleDateString()}`
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DatasetCardFooter', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
total_available_documents: 10,
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const dataset = createMockDataset()
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render document count', () => {
|
||||
const dataset = createMockDataset({ document_count: 25, total_available_documents: 25 })
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('25')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app count for non-external provider', () => {
|
||||
const dataset = createMockDataset({ app_count: 8, provider: 'vendor' })
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('8')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render app count for external provider', () => {
|
||||
const dataset = createMockDataset({ app_count: 8, provider: 'external' })
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
// App count should not be rendered
|
||||
const appCounts = screen.queryAllByText('8')
|
||||
expect(appCounts.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should render update time', () => {
|
||||
const dataset = createMockDataset()
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
// Check for "updated" text with i18n key
|
||||
expect(screen.getByText(/updated/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should show partial document count when total_available_documents < document_count', () => {
|
||||
const dataset = createMockDataset({
|
||||
document_count: 20,
|
||||
total_available_documents: 15,
|
||||
})
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('15 / 20')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show full document count when all documents are available', () => {
|
||||
const dataset = createMockDataset({
|
||||
document_count: 20,
|
||||
total_available_documents: 20,
|
||||
})
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('20')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zero documents', () => {
|
||||
const dataset = createMockDataset({
|
||||
document_count: 0,
|
||||
total_available_documents: 0,
|
||||
})
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct base styling when embedding is available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
const { container } = render(<DatasetCardFooter dataset={dataset} />)
|
||||
const footer = container.firstChild as HTMLElement
|
||||
expect(footer).toHaveClass('flex', 'items-center', 'gap-x-3', 'px-4')
|
||||
})
|
||||
|
||||
it('should have opacity class when embedding is not available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
const { container } = render(<DatasetCardFooter dataset={dataset} />)
|
||||
const footer = container.firstChild as HTMLElement
|
||||
expect(footer).toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should not have opacity class when embedding is available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
const { container } = render(<DatasetCardFooter dataset={dataset} />)
|
||||
const footer = container.firstChild as HTMLElement
|
||||
expect(footer).not.toHaveClass('opacity-30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icons', () => {
|
||||
it('should render document icon', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { container } = render(<DatasetCardFooter dataset={dataset} />)
|
||||
// RiFileTextFill icon
|
||||
const icons = container.querySelectorAll('svg')
|
||||
expect(icons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should render robot icon for non-external provider', () => {
|
||||
const dataset = createMockDataset({ provider: 'vendor' })
|
||||
const { container } = render(<DatasetCardFooter dataset={dataset} />)
|
||||
// Should have both file and robot icons
|
||||
const icons = container.querySelectorAll('svg')
|
||||
expect(icons.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined total_available_documents', () => {
|
||||
const dataset = createMockDataset({
|
||||
document_count: 10,
|
||||
total_available_documents: undefined,
|
||||
})
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
// Should show 0 / 10 since total_available_documents defaults to 0
|
||||
expect(screen.getByText('0 / 10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very large numbers', () => {
|
||||
const dataset = createMockDataset({
|
||||
document_count: 999999,
|
||||
total_available_documents: 999999,
|
||||
app_count: 888888,
|
||||
})
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
expect(screen.getByText('999999')).toBeInTheDocument()
|
||||
expect(screen.getByText('888888')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zero app count', () => {
|
||||
const dataset = createMockDataset({ app_count: 0, document_count: 5, total_available_documents: 5 })
|
||||
render(<DatasetCardFooter dataset={dataset} />)
|
||||
// Both document count and app count are shown
|
||||
const zeros = screen.getAllByText('0')
|
||||
expect(zeros.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,254 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import DatasetCardHeader from './dataset-card-header'
|
||||
|
||||
// Mock useFormatTimeFromNow hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleDateString()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useKnowledge hook
|
||||
vi.mock('@/hooks/use-knowledge', () => ({
|
||||
useKnowledge: () => ({
|
||||
formatIndexingTechniqueAndMethod: (technique: string, _method: string) => {
|
||||
if (technique === 'high_quality')
|
||||
return 'High Quality'
|
||||
if (technique === 'economy')
|
||||
return 'Economy'
|
||||
return ''
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DatasetCardHeader', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
indexing_status: 'completed',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
total_document_count: 10,
|
||||
word_count: 1000,
|
||||
updated_at: 1609545600,
|
||||
updated_by: 'user-1',
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'general',
|
||||
is_published: true,
|
||||
enable_api: true,
|
||||
is_multimodal: false,
|
||||
built_in_field_enabled: false,
|
||||
icon_info: {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji' as const,
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
} as DataSet['retrieval_model_dict'],
|
||||
retrieval_model: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
} as DataSet['retrieval_model'],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: '',
|
||||
external_knowledge_api_id: '',
|
||||
external_knowledge_api_name: '',
|
||||
external_knowledge_api_endpoint: '',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 3,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
author_name: 'Test User',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const dataset = createMockDataset()
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset name', () => {
|
||||
const dataset = createMockDataset({ name: 'Custom Dataset' })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText('Custom Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render author name', () => {
|
||||
const dataset = createMockDataset({ author_name: 'John Doe' })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit time', () => {
|
||||
const dataset = createMockDataset()
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
// Should contain the formatted time
|
||||
expect(screen.getByText(/segment\.editedAt/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should show external knowledge base text for external provider', () => {
|
||||
const dataset = createMockDataset({ provider: 'external' })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText(/externalKnowledgeBase/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show chunking mode for text_model doc_form', () => {
|
||||
const dataset = createMockDataset({ doc_form: ChunkingMode.text })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
// text_model maps to 'general' in DOC_FORM_TEXT
|
||||
expect(screen.getByText(/chunkingMode\.general/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show multimodal text when is_multimodal is true', () => {
|
||||
const dataset = createMockDataset({ is_multimodal: true })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText(/multimodal/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show multimodal when is_multimodal is false', () => {
|
||||
const dataset = createMockDataset({ is_multimodal: false })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.queryByText(/^multimodal$/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icon', () => {
|
||||
it('should render AppIcon component', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { container } = render(<DatasetCardHeader dataset={dataset} />)
|
||||
// AppIcon should be rendered
|
||||
const iconContainer = container.querySelector('.relative.shrink-0')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use default icon when icon_info is missing', () => {
|
||||
const dataset = createMockDataset({ icon_info: undefined })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
// Should still render without crashing
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render chunking mode icon for published pipeline', () => {
|
||||
const dataset = createMockDataset({
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'rag_pipeline',
|
||||
is_published: true,
|
||||
})
|
||||
const { container } = render(<DatasetCardHeader dataset={dataset} />)
|
||||
// Should have the icon badge
|
||||
const iconBadge = container.querySelector('.absolute.-bottom-1.-right-1')
|
||||
expect(iconBadge).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have opacity class when embedding is not available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
const { container } = render(<DatasetCardHeader dataset={dataset} />)
|
||||
const header = container.firstChild as HTMLElement
|
||||
expect(header).toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should not have opacity class when embedding is available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
const { container } = render(<DatasetCardHeader dataset={dataset} />)
|
||||
const header = container.firstChild as HTMLElement
|
||||
expect(header).not.toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should have correct base styling', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { container } = render(<DatasetCardHeader dataset={dataset} />)
|
||||
const header = container.firstChild as HTMLElement
|
||||
expect(header).toHaveClass('flex', 'items-center', 'gap-x-3', 'px-4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DocModeInfo', () => {
|
||||
it('should show doc mode info when all conditions are met', () => {
|
||||
const dataset = createMockDataset({
|
||||
doc_form: ChunkingMode.text,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'],
|
||||
runtime_mode: 'general',
|
||||
})
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText(/chunkingMode/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show doc mode info for unpublished pipeline', () => {
|
||||
const dataset = createMockDataset({
|
||||
runtime_mode: 'rag_pipeline',
|
||||
is_published: false,
|
||||
})
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
// DocModeInfo should not be rendered since isShowDocModeInfo is false
|
||||
expect(screen.queryByText(/High Quality/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show doc mode info for published pipeline', () => {
|
||||
const dataset = createMockDataset({
|
||||
doc_form: ChunkingMode.text,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
retrieval_model_dict: { search_method: RETRIEVE_METHOD.semantic } as DataSet['retrieval_model_dict'],
|
||||
runtime_mode: 'rag_pipeline',
|
||||
is_published: true,
|
||||
})
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText(/chunkingMode/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle missing author_name', () => {
|
||||
const dataset = createMockDataset({ author_name: undefined })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty name', () => {
|
||||
const dataset = createMockDataset({ name: '' })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
// Should render without crashing
|
||||
const { container } = render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle missing retrieval_model_dict', () => {
|
||||
const dataset = createMockDataset({ retrieval_model_dict: undefined })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined doc_form', () => {
|
||||
const dataset = createMockDataset({ doc_form: undefined })
|
||||
render(<DatasetCardHeader dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,237 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import DatasetCardModals from './dataset-card-modals'
|
||||
|
||||
// Mock RenameDatasetModal since it's from a different feature folder
|
||||
vi.mock('../../../rename-modal', () => ({
|
||||
default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess?: () => void }) => (
|
||||
show
|
||||
? (
|
||||
<div data-testid="rename-modal">
|
||||
<button onClick={onClose}>Close Rename</button>
|
||||
<button onClick={onSuccess}>Success</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
describe('DatasetCardModals', () => {
|
||||
const mockDataset: DataSet = {
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
indexing_status: 'completed',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
total_document_count: 10,
|
||||
word_count: 1000,
|
||||
updated_at: 1609545600,
|
||||
updated_by: 'user-1',
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'general',
|
||||
enable_api: true,
|
||||
is_multimodal: false,
|
||||
built_in_field_enabled: false,
|
||||
icon_info: {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji' as const,
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
retrieval_model_dict: {} as DataSet['retrieval_model_dict'],
|
||||
retrieval_model: {} as DataSet['retrieval_model'],
|
||||
external_knowledge_info: {
|
||||
external_knowledge_id: '',
|
||||
external_knowledge_api_id: '',
|
||||
external_knowledge_api_name: '',
|
||||
external_knowledge_api_endpoint: '',
|
||||
},
|
||||
external_retrieval_model: {
|
||||
top_k: 3,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
dataset: mockDataset,
|
||||
modalState: {
|
||||
showRenameModal: false,
|
||||
showConfirmDelete: false,
|
||||
confirmMessage: '',
|
||||
},
|
||||
onCloseRename: vi.fn(),
|
||||
onCloseConfirm: vi.fn(),
|
||||
onConfirmDelete: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when no modals are shown', () => {
|
||||
const { container } = render(<DatasetCardModals {...defaultProps} />)
|
||||
// Should render empty fragment
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('should render rename modal when showRenameModal is true', () => {
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render confirm modal when showConfirmDelete is true', () => {
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
modalState={{
|
||||
...defaultProps.modalState,
|
||||
showConfirmDelete: true,
|
||||
confirmMessage: 'Are you sure?',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
// Confirm modal should be rendered
|
||||
expect(screen.getByText('Are you sure?')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass dataset to rename modal', () => {
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display confirmMessage in confirm modal', () => {
|
||||
const confirmMessage = 'This is a custom confirm message'
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
modalState={{
|
||||
...defaultProps.modalState,
|
||||
showConfirmDelete: true,
|
||||
confirmMessage,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(confirmMessage)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onCloseRename when closing rename modal', () => {
|
||||
const onCloseRename = vi.fn()
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
onCloseRename={onCloseRename}
|
||||
modalState={{ ...defaultProps.modalState, showRenameModal: true }}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Close Rename'))
|
||||
expect(onCloseRename).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onConfirmDelete when confirming deletion', () => {
|
||||
const onConfirmDelete = vi.fn()
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
modalState={{
|
||||
...defaultProps.modalState,
|
||||
showConfirmDelete: true,
|
||||
confirmMessage: 'Delete?',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find and click the confirm button
|
||||
const confirmButton = screen.getByRole('button', { name: /confirm|ok|delete/i })
|
||||
|| screen.getAllByRole('button').find(btn => btn.textContent?.toLowerCase().includes('confirm'))
|
||||
if (confirmButton)
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onConfirmDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onCloseConfirm when canceling deletion', () => {
|
||||
const onCloseConfirm = vi.fn()
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
onCloseConfirm={onCloseConfirm}
|
||||
modalState={{
|
||||
...defaultProps.modalState,
|
||||
showConfirmDelete: true,
|
||||
confirmMessage: 'Delete?',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find and click the cancel button
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i })
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
expect(onCloseConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle both modals being true (render both)', () => {
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
modalState={{
|
||||
showRenameModal: true,
|
||||
showConfirmDelete: true,
|
||||
confirmMessage: 'Delete this dataset?',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
|
||||
expect(screen.getByText('Delete this dataset?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty confirmMessage', () => {
|
||||
render(
|
||||
<DatasetCardModals
|
||||
{...defaultProps}
|
||||
modalState={{
|
||||
...defaultProps.modalState,
|
||||
showConfirmDelete: true,
|
||||
confirmMessage: '',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
// Should still render confirm modal
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,107 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import Description from './description'
|
||||
|
||||
describe('Description', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'This is a test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const dataset = createMockDataset()
|
||||
render(<Description dataset={dataset} />)
|
||||
expect(screen.getByText('This is a test description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the description text', () => {
|
||||
const dataset = createMockDataset({ description: 'Custom description text' })
|
||||
render(<Description dataset={dataset} />)
|
||||
expect(screen.getByText('Custom description text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set title attribute for tooltip', () => {
|
||||
const dataset = createMockDataset({ description: 'Tooltip description' })
|
||||
render(<Description dataset={dataset} />)
|
||||
const descDiv = screen.getByTitle('Tooltip description')
|
||||
expect(descDiv).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display dataset description', () => {
|
||||
const description = 'A very detailed description of this dataset'
|
||||
const dataset = createMockDataset({ description })
|
||||
render(<Description dataset={dataset} />)
|
||||
expect(screen.getByText(description)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct base styling when embedding is available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
render(<Description dataset={dataset} />)
|
||||
const descDiv = screen.getByTitle(dataset.description)
|
||||
expect(descDiv).toHaveClass('system-xs-regular', 'line-clamp-2', 'h-10', 'px-4', 'py-1', 'text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should have opacity class when embedding is not available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
render(<Description dataset={dataset} />)
|
||||
const descDiv = screen.getByTitle(dataset.description)
|
||||
expect(descDiv).toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should not have opacity class when embedding is available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
render(<Description dataset={dataset} />)
|
||||
const descDiv = screen.getByTitle(dataset.description)
|
||||
expect(descDiv).not.toHaveClass('opacity-30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty description', () => {
|
||||
const dataset = createMockDataset({ description: '' })
|
||||
render(<Description dataset={dataset} />)
|
||||
const descDiv = screen.getByTitle('')
|
||||
expect(descDiv).toBeInTheDocument()
|
||||
expect(descDiv).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should handle very long description', () => {
|
||||
const longDescription = 'A'.repeat(500)
|
||||
const dataset = createMockDataset({ description: longDescription })
|
||||
render(<Description dataset={dataset} />)
|
||||
expect(screen.getByText(longDescription)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle description with special characters', () => {
|
||||
const description = '<script>alert("XSS")</script> & "quotes" \'single\''
|
||||
const dataset = createMockDataset({ description })
|
||||
render(<Description dataset={dataset} />)
|
||||
expect(screen.getByText(description)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,162 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import OperationsPopover from './operations-popover'
|
||||
|
||||
describe('OperationsPopover', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'general',
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
const defaultProps = {
|
||||
dataset: createMockDataset(),
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
openRenameModal: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<OperationsPopover {...defaultProps} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the more icon button', () => {
|
||||
const { container } = render(<OperationsPopover {...defaultProps} />)
|
||||
const moreIcon = container.querySelector('svg')
|
||||
expect(moreIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render in hidden state initially (group-hover)', () => {
|
||||
const { container } = render(<OperationsPopover {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('hidden', 'group-hover:block')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should show delete option when not workspace dataset operator', () => {
|
||||
render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={false} />)
|
||||
|
||||
// Click to open popover
|
||||
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
|
||||
if (triggerButton)
|
||||
fireEvent.click(triggerButton)
|
||||
|
||||
// showDelete should be true (inverse of isCurrentWorkspaceDatasetOperator)
|
||||
// This means delete operation will be visible
|
||||
})
|
||||
|
||||
it('should hide delete option when is workspace dataset operator', () => {
|
||||
render(<OperationsPopover {...defaultProps} isCurrentWorkspaceDatasetOperator={true} />)
|
||||
|
||||
// Click to open popover
|
||||
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
|
||||
if (triggerButton)
|
||||
fireEvent.click(triggerButton)
|
||||
|
||||
// showDelete should be false
|
||||
})
|
||||
|
||||
it('should show export pipeline when runtime_mode is rag_pipeline', () => {
|
||||
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline' })
|
||||
render(<OperationsPopover {...defaultProps} dataset={dataset} />)
|
||||
|
||||
// Click to open popover
|
||||
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
|
||||
if (triggerButton)
|
||||
fireEvent.click(triggerButton)
|
||||
|
||||
// showExportPipeline should be true
|
||||
})
|
||||
|
||||
it('should hide export pipeline when runtime_mode is not rag_pipeline', () => {
|
||||
const dataset = createMockDataset({ runtime_mode: 'general' })
|
||||
render(<OperationsPopover {...defaultProps} dataset={dataset} />)
|
||||
|
||||
// Click to open popover
|
||||
const triggerButton = document.querySelector('[class*="cursor-pointer"]')
|
||||
if (triggerButton)
|
||||
fireEvent.click(triggerButton)
|
||||
|
||||
// showExportPipeline should be false
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct positioning styles', () => {
|
||||
const { container } = render(<OperationsPopover {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('absolute', 'right-2', 'top-2', 'z-[15]')
|
||||
})
|
||||
|
||||
it('should have icon with correct size classes', () => {
|
||||
const { container } = render(<OperationsPopover {...defaultProps} />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('h-5', 'w-5', 'text-text-tertiary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should pass openRenameModal to Operations', () => {
|
||||
const openRenameModal = vi.fn()
|
||||
render(<OperationsPopover {...defaultProps} openRenameModal={openRenameModal} />)
|
||||
|
||||
// The openRenameModal should be passed to Operations component
|
||||
expect(openRenameModal).not.toHaveBeenCalled() // Initially not called
|
||||
})
|
||||
|
||||
it('should pass handleExportPipeline to Operations', () => {
|
||||
const handleExportPipeline = vi.fn()
|
||||
render(<OperationsPopover {...defaultProps} handleExportPipeline={handleExportPipeline} />)
|
||||
|
||||
expect(handleExportPipeline).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pass detectIsUsedByApp to Operations', () => {
|
||||
const detectIsUsedByApp = vi.fn()
|
||||
render(<OperationsPopover {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />)
|
||||
|
||||
expect(detectIsUsedByApp).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle dataset with external provider', () => {
|
||||
const dataset = createMockDataset({ provider: 'external' })
|
||||
const { container } = render(<OperationsPopover {...defaultProps} dataset={dataset} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle dataset with undefined runtime_mode', () => {
|
||||
const dataset = createMockDataset({ runtime_mode: undefined })
|
||||
const { container } = render(<OperationsPopover {...defaultProps} dataset={dataset} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,198 @@
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useRef } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import TagArea from './tag-area'
|
||||
|
||||
// Mock TagSelector as it's a complex component from base
|
||||
vi.mock('@/app/components/base/tag-management/selector', () => ({
|
||||
default: ({ value, selectedTags, onCacheUpdate, onChange }: {
|
||||
value: string[]
|
||||
selectedTags: Tag[]
|
||||
onCacheUpdate: (tags: Tag[]) => void
|
||||
onChange?: () => void
|
||||
}) => (
|
||||
<div data-testid="tag-selector">
|
||||
<div data-testid="tag-values">{value.join(',')}</div>
|
||||
<div data-testid="selected-count">
|
||||
{selectedTags.length}
|
||||
{' '}
|
||||
tags
|
||||
</div>
|
||||
<button onClick={() => onCacheUpdate([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])}>
|
||||
Update Tags
|
||||
</button>
|
||||
<button onClick={onChange}>
|
||||
Trigger Change
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('TagArea', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
const mockTags: Tag[] = [
|
||||
{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 },
|
||||
{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 },
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
dataset: createMockDataset(),
|
||||
tags: mockTags,
|
||||
setTags: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
isHoveringTagSelector: false,
|
||||
onClick: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<TagArea {...defaultProps} />)
|
||||
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TagSelector with correct value', () => {
|
||||
render(<TagArea {...defaultProps} />)
|
||||
expect(screen.getByTestId('tag-values')).toHaveTextContent('tag-1,tag-2')
|
||||
})
|
||||
|
||||
it('should display selected tags count', () => {
|
||||
render(<TagArea {...defaultProps} />)
|
||||
expect(screen.getByTestId('selected-count')).toHaveTextContent('2 tags')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass dataset id to TagSelector', () => {
|
||||
const dataset = createMockDataset({ id: 'custom-dataset-id' })
|
||||
render(<TagArea {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty tags', () => {
|
||||
render(<TagArea {...defaultProps} tags={[]} />)
|
||||
expect(screen.getByTestId('selected-count')).toHaveTextContent('0 tags')
|
||||
})
|
||||
|
||||
it('should forward ref correctly', () => {
|
||||
const TestComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
return <TagArea {...defaultProps} ref={ref} />
|
||||
}
|
||||
render(<TestComponent />)
|
||||
expect(screen.getByTestId('tag-selector')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when container is clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(<TagArea {...defaultProps} onClick={onClick} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.click(wrapper)
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call setTags when tags are updated', () => {
|
||||
const setTags = vi.fn()
|
||||
render(<TagArea {...defaultProps} setTags={setTags} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Update Tags'))
|
||||
|
||||
expect(setTags).toHaveBeenCalledWith([{ id: 'new-tag', name: 'New Tag', type: 'knowledge', binding_count: 0 }])
|
||||
})
|
||||
|
||||
it('should call onSuccess when onChange is triggered', () => {
|
||||
const onSuccess = vi.fn()
|
||||
render(<TagArea {...defaultProps} onSuccess={onSuccess} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Trigger Change'))
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have opacity class when embedding is not available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
const { container } = render(<TagArea {...defaultProps} dataset={dataset} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should not have opacity class when embedding is available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: true })
|
||||
const { container } = render(<TagArea {...defaultProps} dataset={dataset} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).not.toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should show mask when not hovering and has tags', () => {
|
||||
const { container } = render(<TagArea {...defaultProps} isHoveringTagSelector={false} tags={mockTags} />)
|
||||
const maskDiv = container.querySelector('.bg-tag-selector-mask-bg')
|
||||
expect(maskDiv).toBeInTheDocument()
|
||||
expect(maskDiv).not.toHaveClass('hidden')
|
||||
})
|
||||
|
||||
it('should hide mask when hovering', () => {
|
||||
const { container } = render(<TagArea {...defaultProps} isHoveringTagSelector={true} />)
|
||||
// When hovering, the mask div should have 'hidden' class
|
||||
const maskDiv = container.querySelector('.absolute.right-0.top-0')
|
||||
expect(maskDiv).toHaveClass('hidden')
|
||||
})
|
||||
|
||||
it('should make TagSelector visible when tags exist', () => {
|
||||
const { container } = render(<TagArea {...defaultProps} tags={mockTags} />)
|
||||
const tagSelectorWrapper = container.querySelector('.visible')
|
||||
expect(tagSelectorWrapper).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined onSuccess', () => {
|
||||
render(<TagArea {...defaultProps} onSuccess={undefined} />)
|
||||
// Should not throw when clicking Trigger Change
|
||||
expect(() => fireEvent.click(screen.getByText('Trigger Change'))).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle many tags', () => {
|
||||
const manyTags: Tag[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `tag-${i}`,
|
||||
name: `Tag ${i}`,
|
||||
type: 'knowledge' as const,
|
||||
binding_count: 0,
|
||||
}))
|
||||
render(<TagArea {...defaultProps} tags={manyTags} />)
|
||||
expect(screen.getByTestId('selected-count')).toHaveTextContent('20 tags')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,427 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { useDatasetCardState } from './use-dataset-card-state'
|
||||
|
||||
// Mock Toast
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
const mockCheckUsage = vi.fn()
|
||||
const mockDeleteDataset = vi.fn()
|
||||
const mockExportPipeline = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-dataset-card', () => ({
|
||||
useCheckDatasetUsage: () => ({ mutateAsync: mockCheckUsage }),
|
||||
useDeleteDataset: () => ({ mutateAsync: mockDeleteDataset }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useExportPipelineDSL: () => ({ mutateAsync: mockExportPipeline }),
|
||||
}))
|
||||
|
||||
describe('useDatasetCardState', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [{ id: 'tag-1', name: 'Tag 1', type: 'knowledge', binding_count: 0 }],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
pipeline_id: 'pipeline-1',
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckUsage.mockResolvedValue({ is_using: false })
|
||||
mockDeleteDataset.mockResolvedValue({})
|
||||
mockExportPipeline.mockResolvedValue({ data: 'yaml content' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should return tags from dataset', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
expect(result.current.tags).toEqual(dataset.tags)
|
||||
})
|
||||
|
||||
it('should have initial modal state closed', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
expect(result.current.modalState.showRenameModal).toBe(false)
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(false)
|
||||
expect(result.current.modalState.confirmMessage).toBe('')
|
||||
})
|
||||
|
||||
it('should not be exporting initially', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
expect(result.current.exporting).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tags State', () => {
|
||||
it('should update tags when setTags is called', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setTags([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
|
||||
})
|
||||
|
||||
expect(result.current.tags).toEqual([{ id: 'tag-2', name: 'Tag 2', type: 'knowledge', binding_count: 0 }])
|
||||
})
|
||||
|
||||
it('should sync tags when dataset tags change', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result, rerender } = renderHook(
|
||||
({ dataset }) => useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
{ initialProps: { dataset } },
|
||||
)
|
||||
|
||||
const newTags = [{ id: 'tag-3', name: 'Tag 3', type: 'knowledge', binding_count: 0 }]
|
||||
const updatedDataset = createMockDataset({ tags: newTags })
|
||||
|
||||
rerender({ dataset: updatedDataset })
|
||||
|
||||
expect(result.current.tags).toEqual(newTags)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Modal Handlers', () => {
|
||||
it('should open rename modal when openRenameModal is called', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.openRenameModal()
|
||||
})
|
||||
|
||||
expect(result.current.modalState.showRenameModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should close rename modal when closeRenameModal is called', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.openRenameModal()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.closeRenameModal()
|
||||
})
|
||||
|
||||
expect(result.current.modalState.showRenameModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should close confirm delete modal when closeConfirmDelete is called', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
// First trigger show confirm delete
|
||||
act(() => {
|
||||
result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
waitFor(() => {
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(true)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.closeConfirmDelete()
|
||||
})
|
||||
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectIsUsedByApp', () => {
|
||||
it('should check usage and show confirm modal with not-in-use message', async () => {
|
||||
mockCheckUsage.mockResolvedValue({ is_using: false })
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
expect(mockCheckUsage).toHaveBeenCalledWith('dataset-1')
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(true)
|
||||
expect(result.current.modalState.confirmMessage).toContain('deleteDatasetConfirmContent')
|
||||
})
|
||||
|
||||
it('should show in-use message when dataset is used by app', async () => {
|
||||
mockCheckUsage.mockResolvedValue({ is_using: true })
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
expect(result.current.modalState.confirmMessage).toContain('datasetUsedByApp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onConfirmDelete', () => {
|
||||
it('should delete dataset and call onSuccess', async () => {
|
||||
const onSuccess = vi.fn()
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onConfirmDelete()
|
||||
})
|
||||
|
||||
expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1')
|
||||
expect(onSuccess).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close confirm modal after delete', async () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
// First open confirm modal
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.onConfirmDelete()
|
||||
})
|
||||
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleExportPipeline', () => {
|
||||
it('should not export if pipeline_id is missing', async () => {
|
||||
const dataset = createMockDataset({ pipeline_id: undefined })
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline()
|
||||
})
|
||||
|
||||
expect(mockExportPipeline).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should export pipeline with correct parameters', async () => {
|
||||
const dataset = createMockDataset({ pipeline_id: 'pipeline-1', name: 'Test Pipeline' })
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline(true)
|
||||
})
|
||||
|
||||
expect(mockExportPipeline).toHaveBeenCalledWith({
|
||||
pipelineId: 'pipeline-1',
|
||||
include: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty tags array', () => {
|
||||
const dataset = createMockDataset({ tags: [] })
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
expect(result.current.tags).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle undefined onSuccess', async () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset }),
|
||||
)
|
||||
|
||||
// Should not throw when onSuccess is undefined
|
||||
await act(async () => {
|
||||
await result.current.onConfirmDelete()
|
||||
})
|
||||
|
||||
expect(mockDeleteDataset).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should show error toast when export pipeline fails', async () => {
|
||||
const Toast = await import('@/app/components/base/toast')
|
||||
mockExportPipeline.mockRejectedValue(new Error('Export failed'))
|
||||
|
||||
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline()
|
||||
})
|
||||
|
||||
expect(Toast.default.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.any(String),
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle Response error in detectIsUsedByApp', async () => {
|
||||
const Toast = await import('@/app/components/base/toast')
|
||||
const mockResponse = new Response(JSON.stringify({ message: 'API Error' }), {
|
||||
status: 400,
|
||||
})
|
||||
mockCheckUsage.mockRejectedValue(mockResponse)
|
||||
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
expect(Toast.default.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.stringContaining('API Error'),
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle generic Error in detectIsUsedByApp', async () => {
|
||||
const Toast = await import('@/app/components/base/toast')
|
||||
mockCheckUsage.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
expect(Toast.default.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Network error',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle error without message in detectIsUsedByApp', async () => {
|
||||
const Toast = await import('@/app/components/base/toast')
|
||||
mockCheckUsage.mockRejectedValue({})
|
||||
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
expect(Toast.default.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Unknown error',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle exporting state correctly', async () => {
|
||||
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
// Exporting should initially be false
|
||||
expect(result.current.exporting).toBe(false)
|
||||
|
||||
// Export should work when not exporting
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline()
|
||||
})
|
||||
|
||||
expect(mockExportPipeline).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset exporting state after export completes', async () => {
|
||||
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline()
|
||||
})
|
||||
|
||||
expect(result.current.exporting).toBe(false)
|
||||
})
|
||||
|
||||
it('should reset exporting state even when export fails', async () => {
|
||||
mockExportPipeline.mockRejectedValue(new Error('Export failed'))
|
||||
|
||||
const dataset = createMockDataset({ pipeline_id: 'pipeline-1' })
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportPipeline()
|
||||
})
|
||||
|
||||
expect(result.current.exporting).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
256
web/app/components/datasets/list/dataset-card/index.spec.tsx
Normal file
256
web/app/components/datasets/list/dataset-card/index.spec.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import DatasetCard from './index'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
// Mock ahooks useHover
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useHover: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock app context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: () => false,
|
||||
}))
|
||||
|
||||
// Mock the useDatasetCardState hook
|
||||
vi.mock('./hooks/use-dataset-card-state', () => ({
|
||||
useDatasetCardState: () => ({
|
||||
tags: [],
|
||||
setTags: vi.fn(),
|
||||
modalState: {
|
||||
showRenameModal: false,
|
||||
showConfirmDelete: false,
|
||||
confirmMessage: '',
|
||||
},
|
||||
openRenameModal: vi.fn(),
|
||||
closeRenameModal: vi.fn(),
|
||||
closeConfirmDelete: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
onConfirmDelete: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the RenameDatasetModal
|
||||
vi.mock('../../rename-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
// Mock useFormatTimeFromNow hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleDateString()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useKnowledge hook
|
||||
vi.mock('@/hooks/use-knowledge', () => ({
|
||||
useKnowledge: () => ({
|
||||
formatIndexingTechniqueAndMethod: () => 'High Quality',
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DatasetCard', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'general',
|
||||
is_published: true,
|
||||
total_available_documents: 10,
|
||||
icon_info: {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji' as const,
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
},
|
||||
author_name: 'Test User',
|
||||
...overrides,
|
||||
} as DataSet)
|
||||
|
||||
const defaultProps = {
|
||||
dataset: createMockDataset(),
|
||||
onSuccess: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset name', () => {
|
||||
const dataset = createMockDataset({ name: 'Custom Dataset Name' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Custom Dataset Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset description', () => {
|
||||
const dataset = createMockDataset({ description: 'Custom Description' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Custom Description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render document count', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
expect(screen.getByText('10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app count', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should handle external provider', () => {
|
||||
const dataset = createMockDataset({ provider: 'external' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle rag_pipeline runtime mode', () => {
|
||||
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: true })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should navigate to documents page on click for regular dataset', () => {
|
||||
const dataset = createMockDataset({ provider: 'vendor' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents')
|
||||
})
|
||||
|
||||
it('should navigate to hitTesting page on click for external provider', () => {
|
||||
const dataset = createMockDataset({ provider: 'external' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/hitTesting')
|
||||
})
|
||||
|
||||
it('should navigate to pipeline page when pipeline is unpublished', () => {
|
||||
const dataset = createMockDataset({ runtime_mode: 'rag_pipeline', is_published: false })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/pipeline')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct card styling', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
const card = screen.getByText('Test Dataset').closest('.group')
|
||||
expect(card).toHaveClass('h-[190px]', 'cursor-pointer', 'flex-col', 'rounded-xl')
|
||||
})
|
||||
|
||||
it('should have data-disable-nprogress attribute', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
const card = screen.getByText('Test Dataset').closest('[data-disable-nprogress]')
|
||||
expect(card).toHaveAttribute('data-disable-nprogress', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle dataset without description', () => {
|
||||
const dataset = createMockDataset({ description: '' })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle embedding not available', () => {
|
||||
const dataset = createMockDataset({ embedding_available: false })
|
||||
render(<DatasetCard {...defaultProps} dataset={dataset} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined onSuccess', () => {
|
||||
render(<DatasetCard dataset={createMockDataset()} />)
|
||||
expect(screen.getByText('Test Dataset')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tag Area Click', () => {
|
||||
it('should stop propagation and prevent default when tag area is clicked', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
|
||||
// Find tag area element (it's inside the card)
|
||||
const tagAreaWrapper = document.querySelector('[class*="px-3"]')
|
||||
if (tagAreaWrapper) {
|
||||
const stopPropagationSpy = vi.fn()
|
||||
const preventDefaultSpy = vi.fn()
|
||||
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true })
|
||||
Object.defineProperty(clickEvent, 'stopPropagation', { value: stopPropagationSpy })
|
||||
Object.defineProperty(clickEvent, 'preventDefault', { value: preventDefaultSpy })
|
||||
|
||||
tagAreaWrapper.dispatchEvent(clickEvent)
|
||||
|
||||
expect(stopPropagationSpy).toHaveBeenCalled()
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('should not navigate when clicking on tag area', () => {
|
||||
render(<DatasetCard {...defaultProps} />)
|
||||
|
||||
// Click on tag area should not trigger card navigation
|
||||
const tagArea = document.querySelector('[class*="px-3"]')
|
||||
if (tagArea) {
|
||||
fireEvent.click(tagArea)
|
||||
// mockPush should NOT be called when clicking tag area
|
||||
// (stopPropagation prevents it from reaching the card click handler)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,87 @@
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import OperationItem from './operation-item'
|
||||
|
||||
describe('OperationItem', () => {
|
||||
const defaultProps = {
|
||||
Icon: RiEditLine,
|
||||
name: 'Edit',
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<OperationItem {...defaultProps} />)
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the icon', () => {
|
||||
const { container } = render(<OperationItem {...defaultProps} />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
expect(icon).toHaveClass('size-4', 'text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should render the name text', () => {
|
||||
render(<OperationItem {...defaultProps} />)
|
||||
const nameSpan = screen.getByText('Edit')
|
||||
expect(nameSpan).toHaveClass('system-md-regular', 'text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should render different name', () => {
|
||||
render(<OperationItem {...defaultProps} name="Delete" />)
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be callable without handleClick', () => {
|
||||
render(<OperationItem {...defaultProps} />)
|
||||
const item = screen.getByText('Edit').closest('div')
|
||||
expect(() => fireEvent.click(item!)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call handleClick when clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<OperationItem {...defaultProps} handleClick={handleClick} />)
|
||||
|
||||
const item = screen.getByText('Edit').closest('div')
|
||||
fireEvent.click(item!)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should prevent default and stop propagation on click', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<OperationItem {...defaultProps} handleClick={handleClick} />)
|
||||
|
||||
const item = screen.getByText('Edit').closest('div')
|
||||
const clickEvent = new MouseEvent('click', { bubbles: true })
|
||||
const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault')
|
||||
const stopPropagationSpy = vi.spyOn(clickEvent, 'stopPropagation')
|
||||
|
||||
item!.dispatchEvent(clickEvent)
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
expect(stopPropagationSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct container styling', () => {
|
||||
render(<OperationItem {...defaultProps} />)
|
||||
const item = screen.getByText('Edit').closest('div')
|
||||
expect(item).toHaveClass('flex', 'cursor-pointer', 'items-center', 'gap-x-1', 'rounded-lg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty name', () => {
|
||||
render(<OperationItem {...defaultProps} name="" />)
|
||||
const container = document.querySelector('.cursor-pointer')
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,119 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Operations from './operations'
|
||||
|
||||
describe('Operations', () => {
|
||||
const defaultProps = {
|
||||
showDelete: true,
|
||||
showExportPipeline: true,
|
||||
openRenameModal: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
// Edit operation should always be visible
|
||||
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit operation', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render export pipeline operation when showExportPipeline is true', () => {
|
||||
render(<Operations {...defaultProps} showExportPipeline={true} />)
|
||||
expect(screen.getByText(/exportPipeline/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render export pipeline operation when showExportPipeline is false', () => {
|
||||
render(<Operations {...defaultProps} showExportPipeline={false} />)
|
||||
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete operation when showDelete is true', () => {
|
||||
render(<Operations {...defaultProps} showDelete={true} />)
|
||||
expect(screen.getByText(/operation\.delete/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render delete operation when showDelete is false', () => {
|
||||
render(<Operations {...defaultProps} showDelete={false} />)
|
||||
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should render divider when showDelete is true', () => {
|
||||
const { container } = render(<Operations {...defaultProps} showDelete={true} />)
|
||||
const divider = container.querySelector('.bg-divider-subtle')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render divider when showDelete is false', () => {
|
||||
const { container } = render(<Operations {...defaultProps} showDelete={false} />)
|
||||
// Should not have the divider-subtle one (the separator before delete)
|
||||
expect(container.querySelector('.bg-divider-subtle')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call openRenameModal when edit is clicked', () => {
|
||||
const openRenameModal = vi.fn()
|
||||
render(<Operations {...defaultProps} openRenameModal={openRenameModal} />)
|
||||
|
||||
const editItem = screen.getByText(/operation\.edit/).closest('div')
|
||||
fireEvent.click(editItem!)
|
||||
|
||||
expect(openRenameModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleExportPipeline when export is clicked', () => {
|
||||
const handleExportPipeline = vi.fn()
|
||||
render(<Operations {...defaultProps} handleExportPipeline={handleExportPipeline} />)
|
||||
|
||||
const exportItem = screen.getByText(/exportPipeline/).closest('div')
|
||||
fireEvent.click(exportItem!)
|
||||
|
||||
expect(handleExportPipeline).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call detectIsUsedByApp when delete is clicked', () => {
|
||||
const detectIsUsedByApp = vi.fn()
|
||||
render(<Operations {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />)
|
||||
|
||||
const deleteItem = screen.getByText(/operation\.delete/).closest('div')
|
||||
fireEvent.click(deleteItem!)
|
||||
|
||||
expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct container styling', () => {
|
||||
const { container } = render(<Operations {...defaultProps} />)
|
||||
const operationsContainer = container.firstChild
|
||||
expect(operationsContainer).toHaveClass(
|
||||
'relative',
|
||||
'flex',
|
||||
'w-full',
|
||||
'flex-col',
|
||||
'rounded-xl',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render only edit when both showDelete and showExportPipeline are false', () => {
|
||||
render(<Operations {...defaultProps} showDelete={false} showExportPipeline={false} />)
|
||||
expect(screen.getByText(/operation\.edit/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/exportPipeline/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/operation\.delete/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,30 +1,52 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import DatasetFooter from './index'
|
||||
|
||||
describe('DatasetFooter', () => {
|
||||
it('should render correctly', () => {
|
||||
render(<DatasetFooter />)
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<DatasetFooter />)
|
||||
expect(screen.getByRole('contentinfo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Check main title (mocked i18n returns ns:key or key)
|
||||
// The code uses t('didYouKnow', { ns: 'dataset' })
|
||||
// With default mock it likely returns 'dataset.didYouKnow'
|
||||
expect(screen.getByText('dataset.didYouKnow')).toBeInTheDocument()
|
||||
it('should render the main heading', () => {
|
||||
render(<DatasetFooter />)
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Check paragraph content
|
||||
expect(screen.getByText(/dataset.intro1/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/dataset.intro2/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/dataset.intro3/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/dataset.intro4/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/dataset.intro5/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/dataset.intro6/)).toBeInTheDocument()
|
||||
it('should render description paragraph', () => {
|
||||
render(<DatasetFooter />)
|
||||
// The paragraph contains multiple text spans
|
||||
expect(screen.getByText(/intro1/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should have correct styling', () => {
|
||||
const { container } = render(<DatasetFooter />)
|
||||
const footer = container.querySelector('footer')
|
||||
expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
|
||||
describe('Props', () => {
|
||||
it('should be memoized', () => {
|
||||
// DatasetFooter is wrapped with React.memo
|
||||
expect(DatasetFooter).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
const h3 = container.querySelector('h3')
|
||||
expect(h3).toHaveClass('text-gradient')
|
||||
describe('Styles', () => {
|
||||
it('should have correct footer styling', () => {
|
||||
render(<DatasetFooter />)
|
||||
const footer = screen.getByRole('contentinfo')
|
||||
expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
|
||||
})
|
||||
|
||||
it('should have gradient text on heading', () => {
|
||||
render(<DatasetFooter />)
|
||||
const heading = screen.getByRole('heading', { level: 3 })
|
||||
expect(heading).toHaveClass('text-gradient')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Content Structure', () => {
|
||||
it('should render accent spans for highlighted text', () => {
|
||||
render(<DatasetFooter />)
|
||||
const accentSpans = document.querySelectorAll('.text-text-accent')
|
||||
expect(accentSpans.length).toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
485
web/app/components/datasets/list/datasets.spec.tsx
Normal file
485
web/app/components/datasets/list/datasets.spec.tsx
Normal file
@ -0,0 +1,485 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import Datasets from './datasets'
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
// Mock ahooks
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useHover: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock useFormatTimeFromNow hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useKnowledge hook
|
||||
vi.mock('@/hooks/use-knowledge', () => ({
|
||||
useKnowledge: () => ({
|
||||
formatIndexingTechniqueAndMethod: () => 'High Quality',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock service hooks - will be overridden in individual tests
|
||||
const mockFetchNextPage = vi.fn()
|
||||
const mockInvalidDatasetList = vi.fn()
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetList: vi.fn(() => ({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: [
|
||||
createMockDataset({ id: 'dataset-1', name: 'Dataset 1' }),
|
||||
createMockDataset({ id: 'dataset-2', name: 'Dataset 2' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
})),
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
}))
|
||||
|
||||
// Mock app context - will be overridden in tests
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
// Mock useDatasetCardState hook
|
||||
vi.mock('./dataset-card/hooks/use-dataset-card-state', () => ({
|
||||
useDatasetCardState: () => ({
|
||||
tags: [],
|
||||
setTags: vi.fn(),
|
||||
modalState: {
|
||||
showRenameModal: false,
|
||||
showConfirmDelete: false,
|
||||
confirmMessage: '',
|
||||
},
|
||||
openRenameModal: vi.fn(),
|
||||
closeRenameModal: vi.fn(),
|
||||
closeConfirmDelete: vi.fn(),
|
||||
handleExportPipeline: vi.fn(),
|
||||
detectIsUsedByApp: vi.fn(),
|
||||
onConfirmDelete: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock RenameDatasetModal
|
||||
vi.mock('../rename-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
function createMockDataset(overrides: Partial<DataSet> = {}): DataSet {
|
||||
return {
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
description: 'Test description',
|
||||
provider: 'vendor',
|
||||
permission: DatasetPermission.allTeamMembers,
|
||||
data_source_type: DataSourceType.FILE,
|
||||
indexing_technique: IndexingType.QUALIFIED,
|
||||
embedding_available: true,
|
||||
app_count: 5,
|
||||
document_count: 10,
|
||||
word_count: 1000,
|
||||
created_at: 1609459200,
|
||||
updated_at: 1609545600,
|
||||
tags: [],
|
||||
embedding_model: 'text-embedding-ada-002',
|
||||
embedding_model_provider: 'openai',
|
||||
created_by: 'user-1',
|
||||
doc_form: ChunkingMode.text,
|
||||
runtime_mode: 'general',
|
||||
is_published: true,
|
||||
total_available_documents: 10,
|
||||
icon_info: {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji' as const,
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
retrieval_model_dict: {
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
},
|
||||
author_name: 'Test User',
|
||||
...overrides,
|
||||
} as DataSet
|
||||
}
|
||||
|
||||
// Store IntersectionObserver callbacks for testing
|
||||
let intersectionObserverCallback: IntersectionObserverCallback | null = null
|
||||
const mockObserve = vi.fn()
|
||||
const mockDisconnect = vi.fn()
|
||||
const mockUnobserve = vi.fn()
|
||||
|
||||
// Custom IntersectionObserver mock
|
||||
class MockIntersectionObserver {
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionObserverCallback = callback
|
||||
}
|
||||
|
||||
observe = mockObserve
|
||||
disconnect = mockDisconnect
|
||||
unobserve = mockUnobserve
|
||||
root = null
|
||||
rootMargin = ''
|
||||
thresholds = []
|
||||
takeRecords = () => []
|
||||
}
|
||||
|
||||
describe('Datasets', () => {
|
||||
const defaultProps = {
|
||||
tags: [],
|
||||
keywords: '',
|
||||
includeAll: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
intersectionObserverCallback = null
|
||||
document.title = ''
|
||||
|
||||
// Setup IntersectionObserver mock
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render NewDatasetCard when user is editor', async () => {
|
||||
const { useSelector } = await import('@/context/app-context')
|
||||
vi.mocked(useSelector).mockReturnValue(true)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT render NewDatasetCard when user is NOT editor', async () => {
|
||||
const { useSelector } = await import('@/context/app-context')
|
||||
vi.mocked(useSelector).mockReturnValue(false)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.queryByText(/createDataset/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset cards from data', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByText('Dataset 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dataset 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render anchor div for infinite scroll', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
const anchor = document.querySelector('.h-0')
|
||||
expect(anchor).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass tags to useDatasetList', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
render(<Datasets {...defaultProps} tags={['tag-1', 'tag-2']} />)
|
||||
expect(useDatasetList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tag_ids: ['tag-1', 'tag-2'],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass keywords to useDatasetList', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
render(<Datasets {...defaultProps} keywords="search term" />)
|
||||
expect(useDatasetList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
keyword: 'search term',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass includeAll to useDatasetList', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
render(<Datasets {...defaultProps} includeAll={true} />)
|
||||
expect(useDatasetList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
include_all: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Document Title', () => {
|
||||
it('should set document title on mount', async () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(document.title).toContain('dataset.knowledge')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should show Loading component when isFetchingNextPage is true', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: true,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
// Loading component renders a div with loading classes
|
||||
const nav = screen.getByRole('navigation')
|
||||
expect(nav).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT show Loading component when isFetchingNextPage is false', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DatasetList null handling', () => {
|
||||
it('should handle null datasetList gracefully', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: null,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined datasetList gracefully', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: undefined,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty pages array', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('IntersectionObserver', () => {
|
||||
it('should setup IntersectionObserver on mount', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
// Should observe the anchor element
|
||||
expect(mockObserve).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call fetchNextPage when isIntersecting, hasNextPage, and not isFetching', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
// Simulate intersection
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should NOT call fetchNextPage when isIntersecting is false', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: false } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT call fetchNextPage when hasNextPage is false', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false, // No more pages
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should NOT call fetchNextPage when isFetching is true', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: true, // Already fetching
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
|
||||
if (intersectionObserverCallback) {
|
||||
intersectionObserverCallback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disconnect observer on unmount', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
const { unmount } = render(<Datasets {...defaultProps} />)
|
||||
|
||||
// Unmount the component
|
||||
unmount()
|
||||
|
||||
// disconnect should be called during cleanup
|
||||
expect(mockDisconnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct grid styling', () => {
|
||||
render(<Datasets {...defaultProps} />)
|
||||
const nav = screen.getByRole('navigation')
|
||||
expect(nav).toHaveClass('grid', 'grow', 'gap-3', 'px-12')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty tags array', () => {
|
||||
render(<Datasets {...defaultProps} tags={[]} />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty keywords', () => {
|
||||
render(<Datasets {...defaultProps} keywords="" />)
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple pages of data', async () => {
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: {
|
||||
pages: [
|
||||
{ data: [createMockDataset({ id: 'ds-1', name: 'Page 1 Dataset' })] },
|
||||
{ data: [createMockDataset({ id: 'ds-2', name: 'Page 2 Dataset' })] },
|
||||
],
|
||||
},
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<Datasets {...defaultProps} />)
|
||||
expect(screen.getByText('Page 1 Dataset')).toBeInTheDocument()
|
||||
expect(screen.getByText('Page 2 Dataset')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
368
web/app/components/datasets/list/index.spec.tsx
Normal file
368
web/app/components/datasets/list/index.spec.tsx
Normal file
@ -0,0 +1,368 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import List from './index'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
const mockReplace = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock ahooks
|
||||
vi.mock('ahooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ahooks')>()
|
||||
return {
|
||||
...actual,
|
||||
useBoolean: () => [false, { toggle: vi.fn(), setTrue: vi.fn(), setFalse: vi.fn() }],
|
||||
useDebounceFn: (fn: () => void) => ({ run: fn }),
|
||||
useHover: () => false,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock app context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { role: 'admin' },
|
||||
isCurrentWorkspaceOwner: true,
|
||||
}),
|
||||
useSelector: () => true,
|
||||
}))
|
||||
|
||||
// Mock global public context
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
systemFeatures: {
|
||||
branding: { enabled: false },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock external api panel context
|
||||
const mockSetShowExternalApiPanel = vi.fn()
|
||||
vi.mock('@/context/external-api-panel-context', () => ({
|
||||
useExternalApiPanel: () => ({
|
||||
showExternalApiPanel: false,
|
||||
setShowExternalApiPanel: mockSetShowExternalApiPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock tag management store
|
||||
vi.mock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: () => false,
|
||||
}))
|
||||
|
||||
// Mock useDocumentTitle hook
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock useFormatTimeFromNow hook
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: (timestamp: number) => new Date(timestamp).toLocaleDateString(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useKnowledge hook
|
||||
vi.mock('@/hooks/use-knowledge', () => ({
|
||||
useKnowledge: () => ({
|
||||
formatIndexingTechniqueAndMethod: () => 'High Quality',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetList: vi.fn(() => ({
|
||||
data: { pages: [{ data: [] }] },
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
})),
|
||||
useInvalidDatasetList: () => vi.fn(),
|
||||
useDatasetApiBaseUrl: () => ({
|
||||
data: { api_base_url: 'https://api.example.com' },
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Datasets component
|
||||
vi.mock('./datasets', () => ({
|
||||
default: ({ tags, keywords, includeAll }: { tags: string[], keywords: string, includeAll: boolean }) => (
|
||||
<div data-testid="datasets-component">
|
||||
<span data-testid="tags">{tags.join(',')}</span>
|
||||
<span data-testid="keywords">{keywords}</span>
|
||||
<span data-testid="include-all">{includeAll ? 'true' : 'false'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock DatasetFooter component
|
||||
vi.mock('./dataset-footer', () => ({
|
||||
default: () => <footer data-testid="dataset-footer">Footer</footer>,
|
||||
}))
|
||||
|
||||
// Mock ExternalAPIPanel component
|
||||
vi.mock('../external-api/external-api-panel', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<div data-testid="external-api-panel">
|
||||
<button onClick={onClose}>Close Panel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock TagManagementModal
|
||||
vi.mock('@/app/components/base/tag-management', () => ({
|
||||
default: () => <div data-testid="tag-management-modal" />,
|
||||
}))
|
||||
|
||||
// Mock TagFilter
|
||||
vi.mock('@/app/components/base/tag-management/filter', () => ({
|
||||
default: ({ onChange }: { value: string[], onChange: (val: string[]) => void }) => (
|
||||
<div data-testid="tag-filter">
|
||||
<button onClick={() => onChange(['tag-1', 'tag-2'])}>Select Tags</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock CheckboxWithLabel
|
||||
vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () => ({
|
||||
default: ({ isChecked, onChange, label }: { isChecked: boolean, onChange: () => void, label: string }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={onChange}
|
||||
data-testid="include-all-checkbox"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the search input', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tag filter', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render external API panel button', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByText(/externalAPIPanelTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dataset footer when branding is disabled', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('dataset-footer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass includeAll prop to Datasets', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('include-all')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should pass empty keywords initially', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('keywords')).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should pass empty tags initially', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('tags')).toHaveTextContent('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should open external API panel when button is clicked', () => {
|
||||
render(<List />)
|
||||
|
||||
const button = screen.getByText(/externalAPIPanelTitle/)
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should update search input value', () => {
|
||||
render(<List />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
|
||||
expect(input).toHaveValue('test search')
|
||||
})
|
||||
|
||||
it('should trigger tag filter change', () => {
|
||||
render(<List />)
|
||||
// Tag filter is rendered and interactive
|
||||
const selectTagsBtn = screen.getByText('Select Tags')
|
||||
expect(selectTagsBtn).toBeInTheDocument()
|
||||
fireEvent.click(selectTagsBtn)
|
||||
// The onChange callback was triggered (debounced)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Conditional Rendering', () => {
|
||||
it('should show include all checkbox for workspace owner', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('include-all-checkbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct container styling', () => {
|
||||
const { container } = render(<List />)
|
||||
const mainContainer = container.firstChild as HTMLElement
|
||||
expect(mainContainer).toHaveClass('scroll-container', 'relative', 'flex', 'grow', 'flex-col')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty state gracefully', () => {
|
||||
render(<List />)
|
||||
// Should render without errors even with empty data
|
||||
expect(screen.getByTestId('datasets-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Branch Coverage', () => {
|
||||
it('should redirect normal role users to /apps', async () => {
|
||||
// Re-mock useAppContext with normal role
|
||||
vi.doMock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { role: 'normal' },
|
||||
isCurrentWorkspaceOwner: false,
|
||||
}),
|
||||
useSelector: () => true,
|
||||
}))
|
||||
|
||||
// Clear module cache and re-import
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear search input when onClear is called', () => {
|
||||
render(<List />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
// First set a value
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
expect(input).toHaveValue('test search')
|
||||
|
||||
// Find and click the clear button
|
||||
const clearButton = document.querySelector('[class*="clear"], button[aria-label*="clear"]')
|
||||
if (clearButton) {
|
||||
fireEvent.click(clearButton)
|
||||
expect(input).toHaveValue('')
|
||||
}
|
||||
})
|
||||
|
||||
it('should show ExternalAPIPanel when showExternalApiPanel is true', async () => {
|
||||
// Re-mock to show external API panel
|
||||
vi.doMock('@/context/external-api-panel-context', () => ({
|
||||
useExternalApiPanel: () => ({
|
||||
showExternalApiPanel: true,
|
||||
setShowExternalApiPanel: mockSetShowExternalApiPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.getByTestId('external-api-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close ExternalAPIPanel when onClose is called', async () => {
|
||||
vi.doMock('@/context/external-api-panel-context', () => ({
|
||||
useExternalApiPanel: () => ({
|
||||
showExternalApiPanel: true,
|
||||
setShowExternalApiPanel: mockSetShowExternalApiPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
const closeButton = screen.getByText('Close Panel')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
expect(mockSetShowExternalApiPanel).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should show TagManagementModal when showTagManagementModal is true', async () => {
|
||||
vi.doMock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: () => true, // showTagManagementModal is true
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show DatasetFooter when branding is enabled', async () => {
|
||||
vi.doMock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({
|
||||
systemFeatures: {
|
||||
branding: { enabled: true },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.queryByTestId('dataset-footer')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show include all checkbox when not workspace owner', async () => {
|
||||
vi.doMock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { role: 'editor' },
|
||||
isCurrentWorkspaceOwner: false,
|
||||
}),
|
||||
useSelector: () => true,
|
||||
}))
|
||||
|
||||
vi.resetModules()
|
||||
const { default: ListComponent } = await import('./index')
|
||||
|
||||
render(<ListComponent />)
|
||||
|
||||
expect(screen.queryByTestId('include-all-checkbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,49 +1,76 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import NewDatasetCard from './index'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import CreateAppCard from './index'
|
||||
|
||||
type MockOptionProps = {
|
||||
text: string
|
||||
href: string
|
||||
}
|
||||
describe('CreateAppCard', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getAllByRole('link')).toHaveLength(3)
|
||||
})
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./option', () => ({
|
||||
default: ({ text, href }: MockOptionProps) => (
|
||||
<a data-testid="option-link" href={href}>
|
||||
{text}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
it('should render create dataset option', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getByText(/createDataset/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiAddLine: () => <svg data-testid="icon-add" />,
|
||||
RiFunctionAddLine: () => <svg data-testid="icon-function" />,
|
||||
}))
|
||||
it('should render create from pipeline option', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getByText(/createFromPipeline/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/solid/development', () => ({
|
||||
ApiConnectionMod: () => <svg data-testid="icon-api" />,
|
||||
}))
|
||||
it('should render connect dataset option', () => {
|
||||
render(<CreateAppCard />)
|
||||
expect(screen.getByText(/connectDataset/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NewDatasetCard', () => {
|
||||
it('should render all options', () => {
|
||||
render(<NewDatasetCard />)
|
||||
describe('Props', () => {
|
||||
it('should have correct displayName', () => {
|
||||
expect(CreateAppCard.displayName).toBe('CreateAppCard')
|
||||
})
|
||||
})
|
||||
|
||||
const options = screen.getAllByTestId('option-link')
|
||||
expect(options).toHaveLength(3)
|
||||
describe('Links', () => {
|
||||
it('should have correct href for create dataset', () => {
|
||||
render(<CreateAppCard />)
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links[0]).toHaveAttribute('href', '/datasets/create')
|
||||
})
|
||||
|
||||
// Check first option (Create Dataset)
|
||||
const createDataset = options[0]
|
||||
expect(createDataset).toHaveAttribute('href', '/datasets/create')
|
||||
expect(createDataset).toHaveTextContent('dataset.createDataset')
|
||||
it('should have correct href for create from pipeline', () => {
|
||||
render(<CreateAppCard />)
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links[1]).toHaveAttribute('href', '/datasets/create-from-pipeline')
|
||||
})
|
||||
|
||||
// Check second option (Create from Pipeline)
|
||||
const createFromPipeline = options[1]
|
||||
expect(createFromPipeline).toHaveAttribute('href', '/datasets/create-from-pipeline')
|
||||
expect(createFromPipeline).toHaveTextContent('dataset.createFromPipeline')
|
||||
it('should have correct href for connect dataset', () => {
|
||||
render(<CreateAppCard />)
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links[2]).toHaveAttribute('href', '/datasets/connect')
|
||||
})
|
||||
})
|
||||
|
||||
// Check third option (Connect Dataset)
|
||||
const connectDataset = options[2]
|
||||
expect(connectDataset).toHaveAttribute('href', '/datasets/connect')
|
||||
expect(connectDataset).toHaveTextContent('dataset.connectDataset')
|
||||
describe('Styles', () => {
|
||||
it('should have correct card styling', () => {
|
||||
const { container } = render(<CreateAppCard />)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('h-[190px]', 'flex', 'flex-col', 'rounded-xl')
|
||||
})
|
||||
|
||||
it('should have border separator for connect option', () => {
|
||||
const { container } = render(<CreateAppCard />)
|
||||
const borderDiv = container.querySelector('.border-t-\\[0\\.5px\\]')
|
||||
expect(borderDiv).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icons', () => {
|
||||
it('should render three icons for three options', () => {
|
||||
const { container } = render(<CreateAppCard />)
|
||||
// Each option has an icon
|
||||
const icons = container.querySelectorAll('svg')
|
||||
expect(icons.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Option from './option'
|
||||
|
||||
describe('Option', () => {
|
||||
const defaultProps = {
|
||||
Icon: RiAddLine,
|
||||
text: 'Test Option',
|
||||
href: '/test-path',
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
expect(screen.getByRole('link')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the text content', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
expect(screen.getByText('Test Option')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the icon', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
// Icon should be rendered with correct size class
|
||||
const icon = document.querySelector('.h-4.w-4')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should have correct href attribute', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', '/test-path')
|
||||
})
|
||||
|
||||
it('should render different text based on props', () => {
|
||||
render(<Option {...defaultProps} text="Different Text" />)
|
||||
expect(screen.getByText('Different Text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render different href based on props', () => {
|
||||
render(<Option {...defaultProps} href="/different-path" />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', '/different-path')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styles', () => {
|
||||
it('should have correct base styling', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveClass('flex', 'w-full', 'items-center', 'gap-x-2', 'rounded-lg')
|
||||
})
|
||||
|
||||
it('should have text span with correct styling', () => {
|
||||
render(<Option {...defaultProps} />)
|
||||
const textSpan = screen.getByText('Test Option')
|
||||
expect(textSpan).toHaveClass('system-sm-medium', 'grow', 'text-left')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty text', () => {
|
||||
render(<Option {...defaultProps} text="" />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long text', () => {
|
||||
const longText = 'A'.repeat(100)
|
||||
render(<Option {...defaultProps} text={longText} />)
|
||||
expect(screen.getByText(longText)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,92 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import AddedMetadataButton from './add-metadata-button'
|
||||
|
||||
describe('AddedMetadataButton', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<AddedMetadataButton />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with translated text', () => {
|
||||
render(<AddedMetadataButton />)
|
||||
// The button should contain text from i18n
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render add icon', () => {
|
||||
const { container } = render(<AddedMetadataButton />)
|
||||
// Check if there's an SVG element (the RiAddLine icon)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<AddedMetadataButton className="custom-class" />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should apply default classes', () => {
|
||||
render(<AddedMetadataButton />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('flex', 'w-full', 'items-center')
|
||||
})
|
||||
|
||||
it('should merge custom className with default classes', () => {
|
||||
render(<AddedMetadataButton className="my-custom-class" />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('flex', 'w-full', 'items-center', 'my-custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when button is clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<AddedMetadataButton onClick={handleClick} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not throw when onClick is not provided and button is clicked', () => {
|
||||
render(<AddedMetadataButton />)
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should call onClick multiple times on multiple clicks', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<AddedMetadataButton onClick={handleClick} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with undefined className', () => {
|
||||
render(<AddedMetadataButton className={undefined} />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty className', () => {
|
||||
render(<AddedMetadataButton className="" />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with undefined onClick', () => {
|
||||
render(<AddedMetadataButton onClick={undefined} />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
287
web/app/components/datasets/metadata/base/date-picker.spec.tsx
Normal file
287
web/app/components/datasets/metadata/base/date-picker.spec.tsx
Normal file
@ -0,0 +1,287 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import WrappedDatePicker from './date-picker'
|
||||
|
||||
type TriggerArgs = {
|
||||
handleClickTrigger: () => void
|
||||
}
|
||||
|
||||
type DatePickerProps = {
|
||||
onChange: (value: Date | null) => void
|
||||
onClear: () => void
|
||||
renderTrigger: (args: TriggerArgs) => React.ReactNode
|
||||
value?: Date
|
||||
}
|
||||
|
||||
// Mock the base date picker component
|
||||
vi.mock('@/app/components/base/date-and-time-picker/date-picker', () => ({
|
||||
default: ({ onChange, onClear, renderTrigger, value }: DatePickerProps) => {
|
||||
const trigger = renderTrigger({
|
||||
handleClickTrigger: () => {},
|
||||
})
|
||||
return (
|
||||
<div data-testid="date-picker-wrapper">
|
||||
{trigger}
|
||||
<button data-testid="select-date" onClick={() => onChange(value || null)}>
|
||||
Select Date
|
||||
</button>
|
||||
<button data-testid="clear-date" onClick={() => onClear()}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useTimestamp hook
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: (timestamp: number) => {
|
||||
if (!timestamp)
|
||||
return ''
|
||||
return new Date(timestamp * 1000).toLocaleDateString()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('WrappedDatePicker', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder text when no value', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
// When no value, should show placeholder from i18n
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render formatted date when value is provided', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render calendar icon', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render select date button', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
expect(screen.getByTestId('select-date')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render clear date button', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
expect(screen.getByTestId('clear-date')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close icon for clearing', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
// RiCloseCircleFill should be rendered
|
||||
const closeIcon = container.querySelectorAll('svg')
|
||||
expect(closeIcon.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(
|
||||
<WrappedDatePicker className="custom-class" onChange={handleChange} />,
|
||||
)
|
||||
const triggerElement = container.querySelector('.custom-class')
|
||||
expect(triggerElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept undefined value', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker value={undefined} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept number value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = 1609459200 // 2021-01-01
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with timestamp when date is selected', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onChange with null when date is cleared via onClear', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('should call onChange with null when close icon is clicked directly', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
// Find the RiCloseCircleFill icon (it has specific classes)
|
||||
const closeIcon = container.querySelector('.cursor-pointer.hover\\:text-components-input-text-filled')
|
||||
if (closeIcon) {
|
||||
fireEvent.click(closeIcon)
|
||||
expect(handleChange).toHaveBeenCalledWith(null)
|
||||
}
|
||||
})
|
||||
|
||||
it('should show close button on hover when value exists', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
// The close icon should be present but hidden initially
|
||||
const triggerGroup = container.querySelector('.group')
|
||||
expect(triggerGroup).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle clicking on trigger element', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const trigger = container.querySelector('.group.flex')
|
||||
if (trigger)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have tertiary text color when no value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
|
||||
const textElement = container.querySelector('.text-text-tertiary')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have secondary text color when value exists', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
const textElement = container.querySelector('.text-text-secondary')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have input background styling', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(<WrappedDatePicker onChange={handleChange} />)
|
||||
const bgElement = container.querySelector('.bg-components-input-bg-normal')
|
||||
expect(bgElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have quaternary text color for close icon when value exists', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { container } = render(
|
||||
<WrappedDatePicker value={timestamp} onChange={handleChange} />,
|
||||
)
|
||||
const closeIcon = container.querySelector('.text-text-quaternary')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle timestamp of 0', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker value={0} onChange={handleChange} />)
|
||||
// 0 is falsy but is a valid timestamp (epoch)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle very large timestamp', () => {
|
||||
const handleChange = vi.fn()
|
||||
const farFuture = 4102444800 // 2100-01-01
|
||||
render(<WrappedDatePicker value={farFuture} onChange={handleChange} />)
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle switching between no value and value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<WrappedDatePicker onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
rerender(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
expect(screen.getByTestId('date-picker-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle clearing date multiple times', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
fireEvent.click(screen.getByTestId('clear-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should handle rapid date selections', () => {
|
||||
const handleChange = vi.fn()
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
render(<WrappedDatePicker value={timestamp} onChange={handleChange} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should handle onChange with date object that has unix method', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<WrappedDatePicker onChange={handleChange} />)
|
||||
|
||||
// The mock triggers onChange with the value prop
|
||||
fireEvent.click(screen.getByTestId('select-date'))
|
||||
|
||||
// onChange should have been called
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,257 @@
|
||||
import type { MetadataItemWithEdit } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import AddRow from './add-row'
|
||||
|
||||
type InputCombinedProps = {
|
||||
type: DataType
|
||||
value: string | number | null
|
||||
onChange: (value: string | number) => void
|
||||
}
|
||||
|
||||
type LabelProps = {
|
||||
text: string
|
||||
}
|
||||
|
||||
// Mock InputCombined component
|
||||
vi.mock('./input-combined', () => ({
|
||||
default: ({ type, value, onChange }: InputCombinedProps) => (
|
||||
<input
|
||||
data-testid="input-combined"
|
||||
data-type={type}
|
||||
value={value || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Label component
|
||||
vi.mock('./label', () => ({
|
||||
default: ({ text }: LabelProps) => <div data-testid="label">{text}</div>,
|
||||
}))
|
||||
|
||||
describe('AddRow', () => {
|
||||
const mockPayload: MetadataItemWithEdit = {
|
||||
id: 'test-id',
|
||||
name: 'test_field',
|
||||
type: DataType.string,
|
||||
value: 'test value',
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label with payload name', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('label')).toHaveTextContent('test_field')
|
||||
})
|
||||
|
||||
it('should render input combined component', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render remove button icon', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass correct type to input combined', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.string)
|
||||
})
|
||||
|
||||
it('should pass correct value to input combined', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveValue('test value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow
|
||||
payload={mockPayload}
|
||||
onChange={handleChange}
|
||||
onRemove={handleRemove}
|
||||
className="custom-class"
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should have default flex styling', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('flex', 'h-6', 'items-center', 'space-x-0.5')
|
||||
})
|
||||
|
||||
it('should handle different data types', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const numberPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
type: DataType.number,
|
||||
value: 42,
|
||||
}
|
||||
render(
|
||||
<AddRow payload={numberPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.number)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with updated payload when input changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'new value' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith({
|
||||
...mockPayload,
|
||||
value: 'new value',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onRemove when remove button is clicked', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
|
||||
const removeButton = container.querySelector('.cursor-pointer')
|
||||
if (removeButton)
|
||||
fireEvent.click(removeButton)
|
||||
|
||||
expect(handleRemove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should preserve other payload properties on change', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'updated' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'test-id',
|
||||
name: 'test_field',
|
||||
type: DataType.string,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Remove Button Styling', () => {
|
||||
it('should have hover styling on remove button', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
const removeButton = container.querySelector('.cursor-pointer')
|
||||
expect(removeButton).toHaveClass('hover:bg-state-destructive-hover', 'hover:text-text-destructive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle null value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const nullPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
value: null,
|
||||
}
|
||||
render(
|
||||
<AddRow payload={nullPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty string value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const emptyPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
value: '',
|
||||
}
|
||||
render(
|
||||
<AddRow payload={emptyPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle time type payload', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const timePayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
type: DataType.time,
|
||||
value: 1609459200,
|
||||
}
|
||||
render(
|
||||
<AddRow payload={timePayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time)
|
||||
})
|
||||
|
||||
it('should handle multiple onRemove calls', () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<AddRow payload={mockPayload} onChange={handleChange} onRemove={handleRemove} />,
|
||||
)
|
||||
|
||||
const removeButton = container.querySelector('.cursor-pointer')
|
||||
if (removeButton) {
|
||||
fireEvent.click(removeButton)
|
||||
fireEvent.click(removeButton)
|
||||
fireEvent.click(removeButton)
|
||||
}
|
||||
|
||||
expect(handleRemove).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,395 @@
|
||||
import type { MetadataItemWithEdit } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType, UpdateType } from '../types'
|
||||
import EditMetadatabatchItem from './edit-row'
|
||||
|
||||
type InputCombinedProps = {
|
||||
type: DataType
|
||||
value: string | number | null
|
||||
onChange: (value: string | number) => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
type MultipleValueInputProps = {
|
||||
onClear: () => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
type LabelProps = {
|
||||
text: string
|
||||
isDeleted?: boolean
|
||||
}
|
||||
|
||||
type EditedBeaconProps = {
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
// Mock InputCombined component
|
||||
vi.mock('./input-combined', () => ({
|
||||
default: ({ type, value, onChange, readOnly }: InputCombinedProps) => (
|
||||
<input
|
||||
data-testid="input-combined"
|
||||
data-type={type}
|
||||
value={value || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock InputHasSetMultipleValue component
|
||||
vi.mock('./input-has-set-multiple-value', () => ({
|
||||
default: ({ onClear, readOnly }: MultipleValueInputProps) => (
|
||||
<div data-testid="multiple-value-input" data-readonly={readOnly}>
|
||||
<button data-testid="clear-multiple" onClick={onClear}>Clear Multiple</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Label component
|
||||
vi.mock('./label', () => ({
|
||||
default: ({ text, isDeleted }: LabelProps) => (
|
||||
<div data-testid="label" data-deleted={isDeleted}>{text}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock EditedBeacon component
|
||||
vi.mock('./edited-beacon', () => ({
|
||||
default: ({ onReset }: EditedBeaconProps) => (
|
||||
<button data-testid="edited-beacon" onClick={onReset}>Reset</button>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('EditMetadatabatchItem', () => {
|
||||
const mockPayload: MetadataItemWithEdit = {
|
||||
id: 'test-id',
|
||||
name: 'test_field',
|
||||
type: DataType.string,
|
||||
value: 'test value',
|
||||
isMultipleValue: false,
|
||||
isUpdated: false,
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label with payload name', () => {
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('label')).toHaveTextContent('test_field')
|
||||
})
|
||||
|
||||
it('should render input combined for single value', () => {
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple value input when isMultipleValue is true', () => {
|
||||
const multiplePayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isMultipleValue: true,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={multiplePayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('multiple-value-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete button icon', () => {
|
||||
const { container } = render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Updated State', () => {
|
||||
it('should show edited beacon when isUpdated is true', () => {
|
||||
const updatedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isUpdated: true,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={updatedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('edited-beacon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show edited beacon when isUpdated is false', () => {
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId('edited-beacon')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Deleted State', () => {
|
||||
it('should pass isDeleted to label when updateType is delete', () => {
|
||||
const deletedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
updateType: UpdateType.delete,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={deletedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('label')).toHaveAttribute('data-deleted', 'true')
|
||||
})
|
||||
|
||||
it('should set readOnly on input when deleted', () => {
|
||||
const deletedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
updateType: UpdateType.delete,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={deletedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('readonly')
|
||||
})
|
||||
|
||||
it('should have destructive styling on delete button when deleted', () => {
|
||||
const deletedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
updateType: UpdateType.delete,
|
||||
}
|
||||
const { container } = render(
|
||||
<EditMetadatabatchItem
|
||||
payload={deletedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const deleteButton = container.querySelector('.bg-state-destructive-hover')
|
||||
expect(deleteButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with updated payload when input changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={handleChange}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-combined'), { target: { value: 'new value' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
...mockPayload,
|
||||
value: 'new value',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onRemove with id when delete button is clicked', () => {
|
||||
const handleRemove = vi.fn()
|
||||
const { container } = render(
|
||||
<EditMetadatabatchItem
|
||||
payload={mockPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={handleRemove}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const deleteButton = container.querySelector('.cursor-pointer')
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(handleRemove).toHaveBeenCalledWith('test-id')
|
||||
})
|
||||
|
||||
it('should call onReset with id when reset beacon is clicked', () => {
|
||||
const handleReset = vi.fn()
|
||||
const updatedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isUpdated: true,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={updatedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={handleReset}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('edited-beacon'))
|
||||
|
||||
expect(handleReset).toHaveBeenCalledWith('test-id')
|
||||
})
|
||||
|
||||
it('should call onChange to clear multiple value', () => {
|
||||
const handleChange = vi.fn()
|
||||
const multiplePayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isMultipleValue: true,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={multiplePayload}
|
||||
onChange={handleChange}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-multiple'))
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: null,
|
||||
isMultipleValue: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple Value State', () => {
|
||||
it('should render multiple value input when isMultipleValue is true', () => {
|
||||
const multiplePayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isMultipleValue: true,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={multiplePayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('multiple-value-input')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('input-combined')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass readOnly to multiple value input when deleted', () => {
|
||||
const multipleDeletedPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
isMultipleValue: true,
|
||||
updateType: UpdateType.delete,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={multipleDeletedPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('multiple-value-input')).toHaveAttribute('data-readonly', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle payload with number type', () => {
|
||||
const numberPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
type: DataType.number,
|
||||
value: 42,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={numberPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.number)
|
||||
})
|
||||
|
||||
it('should handle payload with time type', () => {
|
||||
const timePayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
type: DataType.time,
|
||||
value: 1609459200,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={timePayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toHaveAttribute('data-type', DataType.time)
|
||||
})
|
||||
|
||||
it('should handle null value', () => {
|
||||
const nullPayload: MetadataItemWithEdit = {
|
||||
...mockPayload,
|
||||
value: null,
|
||||
}
|
||||
render(
|
||||
<EditMetadatabatchItem
|
||||
payload={nullPayload}
|
||||
onChange={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('input-combined')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,179 @@
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import EditedBeacon from './edited-beacon'
|
||||
|
||||
describe('EditedBeacon', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with correct size', () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
expect(container.firstChild).toHaveClass('size-4', 'cursor-pointer')
|
||||
})
|
||||
|
||||
it('should render beacon dot by default (not hovering)', () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
// When not hovering, should show the small beacon dot
|
||||
const beaconDot = container.querySelector('.size-1')
|
||||
expect(beaconDot).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hover State', () => {
|
||||
it('should show reset icon on hover', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
// On hover, should show the reset icon (RiResetLeftLine)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show beacon dot when not hovering', () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
// By default (not hovering), should show beacon dot
|
||||
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
|
||||
expect(beaconDot).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide beacon dot on hover', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
// On hover, the small beacon dot should be hidden
|
||||
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
|
||||
expect(beaconDot).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show beacon dot again on mouse leave', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
|
||||
// Hover
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Leave
|
||||
fireEvent.mouseLeave(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
const beaconDot = container.querySelector('.size-1.rounded-full.bg-text-accent-secondary')
|
||||
expect(beaconDot).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onReset when reset button is clicked', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
|
||||
// Hover to show reset button
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
const resetButton = container.querySelector('.bg-text-accent-secondary')
|
||||
expect(resetButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click the reset button (the clickable element with onClick)
|
||||
const clickableElement = container.querySelector('.flex.size-4.items-center.justify-center.rounded-full.bg-text-accent-secondary')
|
||||
if (clickableElement) {
|
||||
fireEvent.click(clickableElement)
|
||||
}
|
||||
|
||||
expect(handleReset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onReset when clicking beacon dot (not hovering)', () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
// Click on the wrapper when not hovering
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.click(wrapper)
|
||||
|
||||
// onReset should not be called because we're not hovering
|
||||
expect(handleReset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip', () => {
|
||||
it('should render tooltip on hover', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
// Tooltip should be rendered (it wraps the reset button)
|
||||
await waitFor(() => {
|
||||
const resetIcon = container.querySelector('svg')
|
||||
expect(resetIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple hover/leave cycles', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.mouseLeave(wrapper)
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.size-1.rounded-full')).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle rapid hover/leave', async () => {
|
||||
const handleReset = vi.fn()
|
||||
const { container } = render(<EditedBeacon onReset={handleReset} />)
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
|
||||
// Rapid hover/leave
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
fireEvent.mouseLeave(wrapper)
|
||||
fireEvent.mouseEnter(wrapper)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,269 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import InputCombined from './input-combined'
|
||||
|
||||
type DatePickerProps = {
|
||||
value: number | null
|
||||
onChange: (value: number) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Mock the base date-picker component
|
||||
vi.mock('../base/date-picker', () => ({
|
||||
default: ({ value, onChange, className }: DatePickerProps) => (
|
||||
<div data-testid="date-picker" className={className} onClick={() => onChange(Date.now())}>
|
||||
{value || 'Pick date'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('InputCombined', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(
|
||||
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text input for string type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
|
||||
)
|
||||
const input = screen.getByDisplayValue('test')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input.tagName.toLowerCase()).toBe('input')
|
||||
})
|
||||
|
||||
it('should render number input for number type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
|
||||
)
|
||||
const input = screen.getByDisplayValue('42')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render date picker for time type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.time} value={Date.now()} onChange={handleChange} />,
|
||||
)
|
||||
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('String Input', () => {
|
||||
it('should call onChange with input value for string type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'new value' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('new value')
|
||||
})
|
||||
|
||||
it('should display current value for string type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="existing value" onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('existing value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply readOnly prop to string input', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="test" onChange={handleChange} readOnly />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('readonly')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Number Input', () => {
|
||||
it('should call onChange with number value for number type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '123' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should display current value for number type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={999} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('999')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply readOnly prop to number input', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} readOnly />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveAttribute('readonly')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Time/Date Input', () => {
|
||||
it('should render date picker for time type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.time} value={1234567890} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('date-picker')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when date is selected', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.time} value={null} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('date-picker'))
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { container } = render(
|
||||
<InputCombined
|
||||
type={DataType.string}
|
||||
value=""
|
||||
onChange={handleChange}
|
||||
className="custom-class"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Check that custom class is applied to wrapper
|
||||
const wrapper = container.querySelector('.custom-class')
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null value for string type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value={null} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined value for string type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value={undefined as unknown as string} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null value for number type', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={null} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct base styling for string input', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveClass('h-6', 'grow', 'p-0.5', 'text-xs', 'rounded-md')
|
||||
})
|
||||
|
||||
it('should have correct styling for number input', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveClass('rounded-l-md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty string value', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value="" onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle zero value for number', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={0} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle negative number', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.number} value={-100} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('-100')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in string', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InputCombined type={DataType.string} value={'<script>alert("xss")</script>'} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('<script>alert("xss")</script>')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle switching between types', () => {
|
||||
const handleChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<InputCombined type={DataType.string} value="test" onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<InputCombined type={DataType.number} value={42} onChange={handleChange} />,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,147 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import InputHasSetMultipleValue from './input-has-set-multiple-value'
|
||||
|
||||
describe('InputHasSetMultipleValue', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with correct wrapper styling', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
expect(container.firstChild).toHaveClass('h-6', 'grow', 'rounded-md', 'bg-components-input-bg-normal', 'p-0.5')
|
||||
})
|
||||
|
||||
it('should render multiple value text', () => {
|
||||
const handleClear = vi.fn()
|
||||
render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
// The text should come from i18n
|
||||
expect(screen.getByText(/multipleValue|Multiple/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close icon when not readOnly', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
// Should have close icon (RiCloseLine)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should not show close icon when readOnly is true', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
|
||||
// Should not have close icon
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show close icon when readOnly is false', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly={false} />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show close icon when readOnly is undefined', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly={undefined} />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply pr-1.5 padding when readOnly', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
|
||||
const badge = container.querySelector('.inline-flex')
|
||||
expect(badge).toHaveClass('pr-1.5')
|
||||
})
|
||||
|
||||
it('should apply pr-0.5 padding when not readOnly', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
const badge = container.querySelector('.inline-flex')
|
||||
expect(badge).toHaveClass('pr-0.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClear when close icon is clicked', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
|
||||
const closeIcon = container.querySelector('svg')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
|
||||
if (closeIcon) {
|
||||
fireEvent.click(closeIcon)
|
||||
}
|
||||
|
||||
expect(handleClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onClear when readOnly and clicking on component', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
|
||||
|
||||
// Click on the wrapper
|
||||
fireEvent.click(container.firstChild as HTMLElement)
|
||||
|
||||
expect(handleClear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClear multiple times on multiple clicks', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
|
||||
const closeIcon = container.querySelector('svg')
|
||||
|
||||
if (closeIcon) {
|
||||
fireEvent.click(closeIcon)
|
||||
fireEvent.click(closeIcon)
|
||||
fireEvent.click(closeIcon)
|
||||
}
|
||||
|
||||
expect(handleClear).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have badge styling', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
const badge = container.querySelector('.inline-flex')
|
||||
expect(badge).toHaveClass('h-5', 'items-center', 'rounded-[5px]', 'border-[0.5px]')
|
||||
})
|
||||
|
||||
it('should have hover styles on close button wrapper', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
const closeWrapper = container.querySelector('.cursor-pointer')
|
||||
expect(closeWrapper).toHaveClass('hover:bg-state-base-hover', 'hover:text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render correctly when switching readOnly state', () => {
|
||||
const handleClear = vi.fn()
|
||||
const { container, rerender } = render(<InputHasSetMultipleValue onClear={handleClear} />)
|
||||
|
||||
// Initially not readOnly
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
|
||||
// Switch to readOnly
|
||||
rerender(<InputHasSetMultipleValue onClear={handleClear} readOnly />)
|
||||
expect(container.querySelector('svg')).not.toBeInTheDocument()
|
||||
|
||||
// Switch back to not readOnly
|
||||
rerender(<InputHasSetMultipleValue onClear={handleClear} readOnly={false} />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,113 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Label from './label'
|
||||
|
||||
describe('Label', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Label text="Test Label" />)
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text with correct styling', () => {
|
||||
render(<Label text="My Label" />)
|
||||
const labelElement = screen.getByText('My Label')
|
||||
expect(labelElement).toHaveClass('system-xs-medium', 'w-[136px]', 'shrink-0', 'truncate', 'text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should not have deleted styling by default', () => {
|
||||
render(<Label text="Label" />)
|
||||
const labelElement = screen.getByText('Label')
|
||||
expect(labelElement).not.toHaveClass('text-text-quaternary', 'line-through')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<Label text="Label" className="custom-class" />)
|
||||
const labelElement = screen.getByText('Label')
|
||||
expect(labelElement).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should merge custom className with default classes', () => {
|
||||
render(<Label text="Label" className="my-custom-class" />)
|
||||
const labelElement = screen.getByText('Label')
|
||||
expect(labelElement).toHaveClass('system-xs-medium', 'my-custom-class')
|
||||
})
|
||||
|
||||
it('should apply deleted styling when isDeleted is true', () => {
|
||||
render(<Label text="Label" isDeleted />)
|
||||
const labelElement = screen.getByText('Label')
|
||||
expect(labelElement).toHaveClass('text-text-quaternary', 'line-through')
|
||||
})
|
||||
|
||||
it('should not apply deleted styling when isDeleted is false', () => {
|
||||
render(<Label text="Label" isDeleted={false} />)
|
||||
const labelElement = screen.getByText('Label')
|
||||
expect(labelElement).not.toHaveClass('text-text-quaternary', 'line-through')
|
||||
})
|
||||
|
||||
it('should render different text values', () => {
|
||||
const { rerender } = render(<Label text="First" />)
|
||||
expect(screen.getByText('First')).toBeInTheDocument()
|
||||
|
||||
rerender(<Label text="Second" />)
|
||||
expect(screen.getByText('Second')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Deleted State', () => {
|
||||
it('should have strikethrough when deleted', () => {
|
||||
render(<Label text="Deleted Label" isDeleted />)
|
||||
const labelElement = screen.getByText('Deleted Label')
|
||||
expect(labelElement).toHaveClass('line-through')
|
||||
})
|
||||
|
||||
it('should have quaternary text color when deleted', () => {
|
||||
render(<Label text="Deleted Label" isDeleted />)
|
||||
const labelElement = screen.getByText('Deleted Label')
|
||||
expect(labelElement).toHaveClass('text-text-quaternary')
|
||||
})
|
||||
|
||||
it('should combine deleted styling with custom className', () => {
|
||||
render(<Label text="Label" isDeleted className="custom" />)
|
||||
const labelElement = screen.getByText('Label')
|
||||
expect(labelElement).toHaveClass('line-through', 'custom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with empty text', () => {
|
||||
const { container } = render(<Label text="" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with long text (truncation)', () => {
|
||||
const longText = 'This is a very long label text that should be truncated'
|
||||
render(<Label text={longText} />)
|
||||
const labelElement = screen.getByText(longText)
|
||||
expect(labelElement).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('should render with undefined className', () => {
|
||||
render(<Label text="Label" className={undefined} />)
|
||||
expect(screen.getByText('Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with undefined isDeleted', () => {
|
||||
render(<Label text="Label" isDeleted={undefined} />)
|
||||
const labelElement = screen.getByText('Label')
|
||||
expect(labelElement).not.toHaveClass('line-through')
|
||||
})
|
||||
|
||||
it('should handle special characters in text', () => {
|
||||
render(<Label text={'Label & "chars"'} />)
|
||||
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle numbers in text', () => {
|
||||
render(<Label text="Label 123" />)
|
||||
expect(screen.getByText('Label 123')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,548 @@
|
||||
import type { MetadataItemInBatchEdit, MetadataItemWithEdit } from '../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType, UpdateType } from '../types'
|
||||
import EditMetadataBatchModal from './modal'
|
||||
|
||||
// Mock service/API calls
|
||||
const mockDoAddMetaData = vi.fn().mockResolvedValue({})
|
||||
vi.mock('@/service/knowledge/use-metadata', () => ({
|
||||
useCreateMetaData: () => ({
|
||||
mutate: mockDoAddMetaData,
|
||||
}),
|
||||
useDatasetMetaData: () => ({
|
||||
data: {
|
||||
doc_metadata: [
|
||||
{ id: 'existing-1', name: 'existing_field', type: DataType.string },
|
||||
{ id: 'existing-2', name: 'another_field', type: DataType.number },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock check name hook to control validation
|
||||
let mockCheckNameResult = { errorMsg: '' }
|
||||
vi.mock('../hooks/use-check-metadata-name', () => ({
|
||||
default: () => ({
|
||||
checkName: () => mockCheckNameResult,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Toast to verify notifications
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (args: unknown) => mockToastNotify(args),
|
||||
},
|
||||
}))
|
||||
|
||||
// Type definitions for mock props
|
||||
type EditRowProps = {
|
||||
payload: MetadataItemWithEdit
|
||||
onChange: (item: MetadataItemWithEdit) => void
|
||||
onRemove: (id: string) => void
|
||||
onReset: (id: string) => void
|
||||
}
|
||||
|
||||
type AddRowProps = {
|
||||
payload: MetadataItemWithEdit
|
||||
onChange: (item: MetadataItemWithEdit) => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
type SelectModalProps = {
|
||||
trigger: React.ReactNode
|
||||
onSelect: (item: MetadataItemInBatchEdit) => void
|
||||
onSave: (data: { name: string, type: DataType }) => Promise<void>
|
||||
onManage: () => void
|
||||
}
|
||||
|
||||
// Mock child components with callback exposure
|
||||
vi.mock('./edit-row', () => ({
|
||||
default: ({ payload, onChange, onRemove, onReset }: EditRowProps) => (
|
||||
<div data-testid="edit-row" data-id={payload.id}>
|
||||
<span data-testid="edit-row-name">{payload.name}</span>
|
||||
<button data-testid={`change-${payload.id}`} onClick={() => onChange({ ...payload, value: 'changed', isUpdated: true, updateType: UpdateType.changeValue })}>Change</button>
|
||||
<button data-testid={`remove-${payload.id}`} onClick={() => onRemove(payload.id)}>Remove</button>
|
||||
<button data-testid={`reset-${payload.id}`} onClick={() => onReset(payload.id)}>Reset</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./add-row', () => ({
|
||||
default: ({ payload, onChange, onRemove }: AddRowProps) => (
|
||||
<div data-testid="add-row" data-id={payload.id}>
|
||||
<span data-testid="add-row-name">{payload.name}</span>
|
||||
<button data-testid={`add-change-${payload.id}`} onClick={() => onChange({ ...payload, value: 'new_value' })}>Change</button>
|
||||
<button data-testid="add-remove" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../metadata-dataset/select-metadata-modal', () => ({
|
||||
default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
|
||||
<div data-testid="select-modal">
|
||||
{trigger}
|
||||
<button data-testid="select-metadata" onClick={() => onSelect({ id: 'new-1', name: 'new_field', type: DataType.string, value: null, isMultipleValue: false })}>Select</button>
|
||||
<button data-testid="save-metadata" onClick={() => onSave({ name: 'created_field', type: DataType.string }).catch(() => {})}>Save</button>
|
||||
<button data-testid="manage-metadata" onClick={onManage}>Manage</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('EditMetadataBatchModal', () => {
|
||||
const mockList: MetadataItemInBatchEdit[] = [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1', isMultipleValue: false },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, value: 42, isMultipleValue: false },
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
datasetId: 'ds-1',
|
||||
documentNum: 5,
|
||||
list: mockList,
|
||||
onSave: vi.fn(),
|
||||
onHide: vi.fn(),
|
||||
onShowManage: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCheckNameResult = { errorMsg: '' }
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render document count', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/5/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render all edit rows for existing items', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
const editRows = screen.getAllByTestId('edit-row')
|
||||
expect(editRows).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render field names for existing items', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_two')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render checkbox for apply to all', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
const checkboxes = document.querySelectorAll('[data-testid*="checkbox"]')
|
||||
expect(checkboxes.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render select metadata modal', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('select-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onHide when cancel button is clicked', async () => {
|
||||
const onHide = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onHide={onHide} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const cancelButton = screen.getByText(/cancel/i)
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onSave when save button is clicked', async () => {
|
||||
const onSave = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find the primary save button (not the one in SelectMetadataModal)
|
||||
const saveButtons = screen.getAllByText(/save/i)
|
||||
const modalSaveButton = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
|
||||
if (modalSaveButton)
|
||||
fireEvent.click(modalSaveButton)
|
||||
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toggle apply to all checkbox', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const checkboxContainer = document.querySelector('[data-testid*="checkbox"]')
|
||||
expect(checkboxContainer).toBeInTheDocument()
|
||||
|
||||
if (checkboxContainer) {
|
||||
fireEvent.click(checkboxContainer)
|
||||
await waitFor(() => {
|
||||
const checkIcon = checkboxContainer.querySelector('svg')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onHide when modal close button is clicked', async () => {
|
||||
const onHide = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onHide={onHide} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Row Operations', () => {
|
||||
it('should update item value when change is triggered', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('change-1'))
|
||||
|
||||
// The component should update internally
|
||||
expect(screen.getAllByTestId('edit-row').length).toBe(2)
|
||||
})
|
||||
|
||||
it('should mark item as deleted when remove is clicked', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-1'))
|
||||
|
||||
// The component should update internally - item marked as deleted
|
||||
expect(screen.getAllByTestId('edit-row').length).toBe(2)
|
||||
})
|
||||
|
||||
it('should reset item when reset is clicked', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// First change the item
|
||||
fireEvent.click(screen.getByTestId('change-1'))
|
||||
// Then reset it
|
||||
fireEvent.click(screen.getByTestId('reset-1'))
|
||||
|
||||
expect(screen.getAllByTestId('edit-row').length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add Metadata Operations', () => {
|
||||
it('should add new item when metadata is selected', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-metadata'))
|
||||
|
||||
// Should now have add-row for the new item
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('add-row')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove added item when remove is clicked', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// First add an item
|
||||
fireEvent.click(screen.getByTestId('select-metadata'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('add-row')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Then remove it
|
||||
fireEvent.click(screen.getByTestId('add-remove'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('add-row')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should update added item when change is triggered', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// First add an item
|
||||
fireEvent.click(screen.getByTestId('select-metadata'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('add-row')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Then change it
|
||||
fireEvent.click(screen.getByTestId('add-change-new-1'))
|
||||
|
||||
expect(screen.getByTestId('add-row')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call doAddMetaData when saving new metadata with valid name', async () => {
|
||||
mockCheckNameResult = { errorMsg: '' }
|
||||
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('save-metadata'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDoAddMetaData).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show success toast when saving with valid name', async () => {
|
||||
mockCheckNameResult = { errorMsg: '' }
|
||||
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('save-metadata'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDoAddMetaData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'success',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast when saving with invalid name', async () => {
|
||||
mockCheckNameResult = { errorMsg: 'Name already exists' }
|
||||
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('save-metadata'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
message: 'Name already exists',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onShowManage when manage is clicked', async () => {
|
||||
const onShowManage = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onShowManage={onShowManage} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('manage-metadata'))
|
||||
|
||||
expect(onShowManage).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass correct datasetId', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} datasetId="custom-ds" />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display correct document number', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} documentNum={10} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/10/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty list', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} list={[]} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('edit-row')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle list with multiple value items', async () => {
|
||||
const multipleValueList: MetadataItemInBatchEdit[] = [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: null, isMultipleValue: true },
|
||||
]
|
||||
render(<EditMetadataBatchModal {...defaultProps} list={multipleValueList} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-row')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle rapid save clicks', async () => {
|
||||
const onSave = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find the primary save button
|
||||
const saveButtons = screen.getAllByText(/save/i)
|
||||
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
|
||||
if (saveBtn) {
|
||||
fireEvent.click(saveBtn)
|
||||
fireEvent.click(saveBtn)
|
||||
fireEvent.click(saveBtn)
|
||||
}
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should pass correct arguments to onSave', async () => {
|
||||
const onSave = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const saveButtons = screen.getAllByText(/save/i)
|
||||
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
|
||||
if (saveBtn)
|
||||
fireEvent.click(saveBtn)
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.any(Array),
|
||||
expect.any(Boolean),
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass isApplyToAllSelectDocument as true when checked', async () => {
|
||||
const onSave = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const checkboxContainer = document.querySelector('[data-testid*="checkbox"]')
|
||||
if (checkboxContainer)
|
||||
fireEvent.click(checkboxContainer)
|
||||
|
||||
const saveButtons = screen.getAllByText(/save/i)
|
||||
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
|
||||
if (saveBtn)
|
||||
fireEvent.click(saveBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.any(Array),
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter out deleted items when saving', async () => {
|
||||
const onSave = vi.fn()
|
||||
render(<EditMetadataBatchModal {...defaultProps} onSave={onSave} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Remove an item
|
||||
fireEvent.click(screen.getByTestId('remove-1'))
|
||||
|
||||
// Save
|
||||
const saveButtons = screen.getAllByText(/save/i)
|
||||
const saveBtn = saveButtons.find(btn => btn.closest('button')?.classList.contains('btn-primary'))
|
||||
if (saveBtn)
|
||||
fireEvent.click(saveBtn)
|
||||
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
// The first argument should not contain the deleted item (id '1')
|
||||
const savedList = onSave.mock.calls[0][0] as MetadataItemInBatchEdit[]
|
||||
const hasDeletedItem = savedList.some(item => item.id === '1')
|
||||
expect(hasDeletedItem).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle multiple add and remove operations', async () => {
|
||||
render(<EditMetadataBatchModal {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Add first item
|
||||
fireEvent.click(screen.getByTestId('select-metadata'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('add-row')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Remove it
|
||||
fireEvent.click(screen.getByTestId('add-remove'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('add-row')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Add again
|
||||
fireEvent.click(screen.getByTestId('select-metadata'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('add-row')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,647 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType, UpdateType } from '../types'
|
||||
import useBatchEditDocumentMetadata from './use-batch-edit-document-metadata'
|
||||
|
||||
type DocMetadataItem = {
|
||||
id: string
|
||||
name: string
|
||||
type: DataType
|
||||
value: string | number | null
|
||||
}
|
||||
|
||||
type DocListItem = {
|
||||
id: string
|
||||
name?: string
|
||||
doc_metadata?: DocMetadataItem[] | null
|
||||
}
|
||||
|
||||
type MetadataItemWithEdit = {
|
||||
id: string
|
||||
name: string
|
||||
type: DataType
|
||||
value: string | number | null
|
||||
isMultipleValue?: boolean
|
||||
updateType?: UpdateType
|
||||
}
|
||||
|
||||
// Mock useBatchUpdateDocMetadata
|
||||
const mockMutateAsync = vi.fn().mockResolvedValue({})
|
||||
vi.mock('@/service/knowledge/use-metadata', () => ({
|
||||
useBatchUpdateDocMetadata: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Toast
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useBatchEditDocumentMetadata', () => {
|
||||
const mockDocList: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
name: 'Document 1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
name: 'Document 2',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 2' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
datasetId: 'ds-1',
|
||||
docList: mockDocList as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
onUpdate: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Hook Initialization', () => {
|
||||
it('should initialize with isShowEditModal as false', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
expect(result.current.isShowEditModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should return showEditModal function', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
expect(typeof result.current.showEditModal).toBe('function')
|
||||
})
|
||||
|
||||
it('should return hideEditModal function', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
expect(typeof result.current.hideEditModal).toBe('function')
|
||||
})
|
||||
|
||||
it('should return originalList', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
expect(Array.isArray(result.current.originalList)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return handleSave function', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
expect(typeof result.current.handleSave).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Modal Control', () => {
|
||||
it('should show modal when showEditModal is called', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.showEditModal()
|
||||
})
|
||||
|
||||
expect(result.current.isShowEditModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide modal when hideEditModal is called', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.showEditModal()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.hideEditModal()
|
||||
})
|
||||
|
||||
expect(result.current.isShowEditModal).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Original List Processing', () => {
|
||||
it('should compute originalList from docList metadata', () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
|
||||
expect(result.current.originalList.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should filter out built-in metadata', () => {
|
||||
const docListWithBuiltIn: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: 'built-in', name: 'created_at', type: DataType.time, value: 123 },
|
||||
{ id: '1', name: 'custom', type: DataType.string, value: 'test' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListWithBuiltIn as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
const hasBuiltIn = result.current.originalList.some(item => item.id === 'built-in')
|
||||
expect(hasBuiltIn).toBe(false)
|
||||
})
|
||||
|
||||
it('should mark items with multiple values', () => {
|
||||
const docListWithDifferentValues: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Value A' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Value B' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListWithDifferentValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
const fieldItem = result.current.originalList.find(item => item.id === '1')
|
||||
expect(fieldItem?.isMultipleValue).toBe(true)
|
||||
})
|
||||
|
||||
it('should not mark items with same values as multiple', () => {
|
||||
const docListWithSameValues: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Same Value' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Same Value' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListWithSameValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
const fieldItem = result.current.originalList.find(item => item.id === '1')
|
||||
expect(fieldItem?.isMultipleValue).toBe(false)
|
||||
})
|
||||
|
||||
it('should skip already marked multiple value items', () => {
|
||||
// Three docs with same field but different values
|
||||
const docListThreeDocs: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Value A' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Value B' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-3',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Value C' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListThreeDocs as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Should only have one item for field '1', marked as multiple
|
||||
const fieldItems = result.current.originalList.filter(item => item.id === '1')
|
||||
expect(fieldItems.length).toBe(1)
|
||||
expect(fieldItems[0].isMultipleValue).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSave', () => {
|
||||
it('should call mutateAsync with correct data', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({ ...defaultProps, onUpdate }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave([], [], false)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onUpdate after successful save', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({ ...defaultProps, onUpdate }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave([], [], false)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide modal after successful save', async () => {
|
||||
const { result } = renderHook(() => useBatchEditDocumentMetadata(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.showEditModal()
|
||||
})
|
||||
|
||||
expect(result.current.isShowEditModal).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave([], [], false)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isShowEditModal).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle edited items with changeValue updateType', async () => {
|
||||
const docListSingleDoc: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
const editedList: MetadataItemWithEdit[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'field_one',
|
||||
type: DataType.string,
|
||||
value: 'New Value',
|
||||
updateType: UpdateType.changeValue,
|
||||
},
|
||||
]
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave(editedList, [], false)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
document_id: 'doc-1',
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: '1',
|
||||
value: 'New Value',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle removed items', async () => {
|
||||
const docListSingleDoc: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Only pass field_one in editedList, field_two should be removed
|
||||
const editedList: MetadataItemWithEdit[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'field_one',
|
||||
type: DataType.string,
|
||||
value: 'Value 1',
|
||||
},
|
||||
]
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave(editedList, [], false)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle added items', async () => {
|
||||
const docListSingleDoc: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
const addedList = [
|
||||
{
|
||||
id: 'new-1',
|
||||
name: 'new_field',
|
||||
type: DataType.string,
|
||||
value: 'New Value',
|
||||
isMultipleValue: false,
|
||||
},
|
||||
]
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave([], addedList, false)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'new_field',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should add missing metadata when isApplyToAllSelectDocument is true', async () => {
|
||||
// Doc 1 has field, Doc 2 doesn't have it
|
||||
const docListMissingField: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
doc_metadata: [],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListMissingField as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
const editedList: MetadataItemWithEdit[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'field_one',
|
||||
type: DataType.string,
|
||||
value: 'Updated Value',
|
||||
isMultipleValue: false,
|
||||
updateType: UpdateType.changeValue,
|
||||
},
|
||||
]
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave(editedList, [], true)
|
||||
})
|
||||
|
||||
// Both documents should have the field after applying to all
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
const callArgs = mockMutateAsync.mock.calls[0][0]
|
||||
expect(callArgs.metadata_list.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should not add missing metadata for multiple value items when isApplyToAllSelectDocument is true', async () => {
|
||||
// Two docs with different values for same field
|
||||
const docListDifferentValues: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value A' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value B' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'doc-3',
|
||||
doc_metadata: [],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListDifferentValues as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Mark it as multiple value item - should not be added to doc-3
|
||||
const editedList: MetadataItemWithEdit[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'field_one',
|
||||
type: DataType.string,
|
||||
value: null,
|
||||
isMultipleValue: true,
|
||||
updateType: UpdateType.changeValue,
|
||||
},
|
||||
]
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave(editedList, [], true)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update existing items in the list', async () => {
|
||||
const docListSingleDoc: DocListItem[] = [
|
||||
{
|
||||
id: 'doc-1',
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Old Value' },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, value: 100 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListSingleDoc as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Edit both items
|
||||
const editedList: MetadataItemWithEdit[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'field_one',
|
||||
type: DataType.string,
|
||||
value: 'New Value 1',
|
||||
updateType: UpdateType.changeValue,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'field_two',
|
||||
type: DataType.number,
|
||||
value: 200,
|
||||
updateType: UpdateType.changeValue,
|
||||
},
|
||||
]
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave(editedList, [], false)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({ id: '1', value: 'New Value 1' }),
|
||||
expect.objectContaining({ id: '2', value: 200 }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selected Document IDs', () => {
|
||||
it('should use selectedDocumentIds when provided', async () => {
|
||||
const selectedIds = ['doc-1']
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
selectedDocumentIds: selectedIds,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave([], [], false)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dataset_id: 'ds-1',
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
document_id: 'doc-1',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle selectedDocumentIds not in docList', async () => {
|
||||
// Select a document that's not in docList
|
||||
const selectedIds = ['doc-1', 'doc-not-in-list']
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
selectedDocumentIds: selectedIds,
|
||||
}),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave([], [], false)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata_list: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
document_id: 'doc-not-in-list',
|
||||
partial_update: true,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty docList', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: [] as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.originalList).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle documents without metadata', () => {
|
||||
const docListNoMetadata: DocListItem[] = [
|
||||
{ id: 'doc-1', name: 'Doc 1' },
|
||||
{ id: 'doc-2', name: 'Doc 2', doc_metadata: null },
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchEditDocumentMetadata({
|
||||
...defaultProps,
|
||||
docList: docListNoMetadata as Parameters<typeof useBatchEditDocumentMetadata>[0]['docList'],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.originalList).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,166 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import useCheckMetadataName from './use-check-metadata-name'
|
||||
|
||||
describe('useCheckMetadataName', () => {
|
||||
describe('Hook Initialization', () => {
|
||||
it('should return an object with checkName function', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
expect(result.current).toHaveProperty('checkName')
|
||||
expect(typeof result.current.checkName).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkName - Empty Name Validation', () => {
|
||||
it('should return error for empty string', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return error for whitespace-only string', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
// Whitespace is not valid since it doesn't match the pattern
|
||||
const { errorMsg } = result.current.checkName(' ')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkName - Pattern Validation', () => {
|
||||
it('should return error for name starting with number', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('1name')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return error for name starting with uppercase', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('Name')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return error for name starting with underscore', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('_name')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return error for name with spaces', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('my name')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return error for name with special characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('name-with-dash')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should return error for name with dots', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('name.with.dot')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should accept valid name starting with lowercase letter', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('validname')
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should accept valid name with numbers after first character', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('name123')
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should accept valid name with underscores after first character', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('name_with_underscore')
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should accept single lowercase letter', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('a')
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkName - Length Validation', () => {
|
||||
it('should return error for name longer than 255 characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const longName = 'a'.repeat(256)
|
||||
const { errorMsg } = result.current.checkName(longName)
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should accept name with exactly 255 characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const maxLengthName = 'a'.repeat(255)
|
||||
const { errorMsg } = result.current.checkName(maxLengthName)
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should accept name with less than 255 characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const shortName = 'a'.repeat(100)
|
||||
const { errorMsg } = result.current.checkName(shortName)
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkName - Edge Cases', () => {
|
||||
it('should validate all lowercase letters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('abcdefghijklmnopqrstuvwxyz')
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should validate name with mixed numbers and underscores', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('a1_2_3_test')
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should reject uppercase letters anywhere in name', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('nameWithUppercase')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should reject unicode characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('名字')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should reject emoji characters', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('name😀')
|
||||
expect(errorMsg).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Return Value Structure', () => {
|
||||
it('should return object with errorMsg property', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const returnValue = result.current.checkName('test')
|
||||
expect(returnValue).toHaveProperty('errorMsg')
|
||||
})
|
||||
|
||||
it('should return empty string for valid name', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('valid_name')
|
||||
expect(errorMsg).toBe('')
|
||||
})
|
||||
|
||||
it('should return non-empty string for invalid name', () => {
|
||||
const { result } = renderHook(() => useCheckMetadataName())
|
||||
const { errorMsg } = result.current.checkName('')
|
||||
expect(typeof errorMsg).toBe('string')
|
||||
expect(errorMsg.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,308 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import useEditDatasetMetadata from './use-edit-dataset-metadata'
|
||||
|
||||
// Mock service hooks
|
||||
const mockDoAddMetaData = vi.fn().mockResolvedValue({})
|
||||
const mockDoRenameMetaData = vi.fn().mockResolvedValue({})
|
||||
const mockDoDeleteMetaData = vi.fn().mockResolvedValue({})
|
||||
const mockToggleBuiltInStatus = vi.fn().mockResolvedValue({})
|
||||
|
||||
vi.mock('@/service/knowledge/use-metadata', () => ({
|
||||
useDatasetMetaData: () => ({
|
||||
data: {
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, count: 5 },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, count: 3 },
|
||||
],
|
||||
built_in_field_enabled: false,
|
||||
},
|
||||
}),
|
||||
useCreateMetaData: () => ({
|
||||
mutate: mockDoAddMetaData,
|
||||
}),
|
||||
useRenameMeta: () => ({
|
||||
mutate: mockDoRenameMetaData,
|
||||
}),
|
||||
useDeleteMetaData: () => ({
|
||||
mutateAsync: mockDoDeleteMetaData,
|
||||
}),
|
||||
useUpdateBuiltInStatus: () => ({
|
||||
mutateAsync: mockToggleBuiltInStatus,
|
||||
}),
|
||||
useBuiltInMetaDataFields: () => ({
|
||||
data: {
|
||||
fields: [
|
||||
{ name: 'created_at', type: DataType.time },
|
||||
{ name: 'modified_at', type: DataType.time },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Toast
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useCheckMetadataName
|
||||
vi.mock('./use-check-metadata-name', () => ({
|
||||
default: () => ({
|
||||
checkName: (name: string) => ({
|
||||
errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
|
||||
describe('useEditDatasetMetadata', () => {
|
||||
const defaultProps = {
|
||||
datasetId: 'ds-1',
|
||||
onUpdateDocList: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorageMock.getItem.mockReturnValue(null)
|
||||
})
|
||||
|
||||
describe('Hook Initialization', () => {
|
||||
it('should initialize with isShowEditModal as false', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(result.current.isShowEditModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should return showEditModal function', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(typeof result.current.showEditModal).toBe('function')
|
||||
})
|
||||
|
||||
it('should return hideEditModal function', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(typeof result.current.hideEditModal).toBe('function')
|
||||
})
|
||||
|
||||
it('should return datasetMetaData', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(result.current.datasetMetaData).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return handleAddMetaData function', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(typeof result.current.handleAddMetaData).toBe('function')
|
||||
})
|
||||
|
||||
it('should return handleRename function', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(typeof result.current.handleRename).toBe('function')
|
||||
})
|
||||
|
||||
it('should return handleDeleteMetaData function', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(typeof result.current.handleDeleteMetaData).toBe('function')
|
||||
})
|
||||
|
||||
it('should return builtInMetaData', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(result.current.builtInMetaData).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return builtInEnabled', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(typeof result.current.builtInEnabled).toBe('boolean')
|
||||
})
|
||||
|
||||
it('should return setBuiltInEnabled function', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
expect(typeof result.current.setBuiltInEnabled).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Modal Control', () => {
|
||||
it('should show modal when showEditModal is called', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.showEditModal()
|
||||
})
|
||||
|
||||
expect(result.current.isShowEditModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide modal when hideEditModal is called', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.showEditModal()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.hideEditModal()
|
||||
})
|
||||
|
||||
expect(result.current.isShowEditModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle toggle of modal state', () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
// Initially closed
|
||||
expect(result.current.isShowEditModal).toBe(false)
|
||||
|
||||
// Show, hide, show
|
||||
act(() => result.current.showEditModal())
|
||||
expect(result.current.isShowEditModal).toBe(true)
|
||||
|
||||
act(() => result.current.hideEditModal())
|
||||
expect(result.current.isShowEditModal).toBe(false)
|
||||
|
||||
act(() => result.current.showEditModal())
|
||||
expect(result.current.isShowEditModal).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleAddMetaData', () => {
|
||||
it('should call doAddMetaData with valid name', async () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAddMetaData({
|
||||
name: 'valid_name',
|
||||
type: DataType.string,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockDoAddMetaData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject invalid name', async () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.handleAddMetaData({
|
||||
name: '',
|
||||
type: DataType.string,
|
||||
})
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleRename', () => {
|
||||
it('should call doRenameMetaData with valid name', async () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRename({
|
||||
id: '1',
|
||||
name: 'new_valid_name',
|
||||
type: DataType.string,
|
||||
count: 5,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockDoRenameMetaData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onUpdateDocList after rename', async () => {
|
||||
const onUpdateDocList = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditDatasetMetadata({ ...defaultProps, onUpdateDocList }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRename({
|
||||
id: '1',
|
||||
name: 'renamed',
|
||||
type: DataType.string,
|
||||
count: 5,
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdateDocList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should reject invalid name for rename', async () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.handleRename({
|
||||
id: '1',
|
||||
name: 'Invalid Name',
|
||||
type: DataType.string,
|
||||
count: 5,
|
||||
})
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDeleteMetaData', () => {
|
||||
it('should call doDeleteMetaData', async () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteMetaData('1')
|
||||
})
|
||||
|
||||
expect(mockDoDeleteMetaData).toHaveBeenCalledWith('1')
|
||||
})
|
||||
|
||||
it('should call onUpdateDocList after delete', async () => {
|
||||
const onUpdateDocList = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useEditDatasetMetadata({ ...defaultProps, onUpdateDocList }),
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDeleteMetaData('1')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdateDocList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Built-in Status', () => {
|
||||
it('should toggle built-in status', async () => {
|
||||
const { result } = renderHook(() => useEditDatasetMetadata(defaultProps))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setBuiltInEnabled(true)
|
||||
})
|
||||
|
||||
expect(mockToggleBuiltInStatus).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle different datasetIds', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
props => useEditDatasetMetadata(props),
|
||||
{ initialProps: defaultProps },
|
||||
)
|
||||
|
||||
expect(result.current).toBeDefined()
|
||||
|
||||
rerender({ ...defaultProps, datasetId: 'ds-2' })
|
||||
|
||||
expect(result.current).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,587 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import useMetadataDocument from './use-metadata-document'
|
||||
|
||||
type DocDetail = {
|
||||
id: string
|
||||
name: string
|
||||
data_source_type: string
|
||||
word_count: number
|
||||
language?: string
|
||||
hit_count?: number
|
||||
segment_count?: number
|
||||
}
|
||||
|
||||
// Mock service hooks
|
||||
const mockMutateAsync = vi.fn().mockResolvedValue({})
|
||||
const mockDoAddMetaData = vi.fn().mockResolvedValue({})
|
||||
|
||||
vi.mock('@/service/knowledge/use-metadata', () => ({
|
||||
useBatchUpdateDocMetadata: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
}),
|
||||
useCreateMetaData: () => ({
|
||||
mutateAsync: mockDoAddMetaData,
|
||||
}),
|
||||
useDocumentMetaData: () => ({
|
||||
data: {
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
|
||||
{ id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 },
|
||||
],
|
||||
},
|
||||
}),
|
||||
useDatasetMetaData: () => ({
|
||||
data: {
|
||||
built_in_field_enabled: true,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useDatasetDetailContext
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContext: () => ({
|
||||
dataset: {
|
||||
embedding_available: true,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useMetadataMap and useLanguages with comprehensive field definitions
|
||||
vi.mock('@/hooks/use-metadata', () => ({
|
||||
useMetadataMap: () => ({
|
||||
originInfo: {
|
||||
subFieldsMap: {
|
||||
data_source_type: { label: 'Source Type', inputType: 'text' },
|
||||
language: { label: 'Language', inputType: 'select' },
|
||||
empty_field: { label: 'Empty Field', inputType: 'text' },
|
||||
},
|
||||
},
|
||||
technicalParameters: {
|
||||
subFieldsMap: {
|
||||
word_count: { label: 'Word Count', inputType: 'text' },
|
||||
hit_count: {
|
||||
label: 'Hit Count',
|
||||
inputType: 'text',
|
||||
render: (val: number, segmentCount?: number) => `${val}/${segmentCount || 0}`,
|
||||
},
|
||||
custom_render: {
|
||||
label: 'Custom Render',
|
||||
inputType: 'text',
|
||||
render: (val: string) => `Rendered: ${val}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
useLanguages: () => ({
|
||||
en: 'English',
|
||||
zh: 'Chinese',
|
||||
ja: 'Japanese',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Toast
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock useCheckMetadataName
|
||||
vi.mock('./use-check-metadata-name', () => ({
|
||||
default: () => ({
|
||||
checkName: (name: string) => ({
|
||||
errorMsg: name && /^[a-z][a-z0-9_]*$/.test(name) ? '' : 'Invalid name',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useMetadataDocument', () => {
|
||||
const mockDocDetail: DocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
data_source_type: 'upload_file',
|
||||
word_count: 100,
|
||||
language: 'en',
|
||||
hit_count: 50,
|
||||
segment_count: 10,
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
datasetId: 'ds-1',
|
||||
documentId: 'doc-1',
|
||||
docDetail: mockDocDetail as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Hook Initialization', () => {
|
||||
it('should return embeddingAvailable', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(result.current.embeddingAvailable).toBe(true)
|
||||
})
|
||||
|
||||
it('should return isEdit as false initially', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(result.current.isEdit).toBe(false)
|
||||
})
|
||||
|
||||
it('should return setIsEdit function', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(typeof result.current.setIsEdit).toBe('function')
|
||||
})
|
||||
|
||||
it('should return list without built-in items', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
const hasBuiltIn = result.current.list.some(item => item.id === 'built-in')
|
||||
expect(hasBuiltIn).toBe(false)
|
||||
})
|
||||
|
||||
it('should return builtList with only built-in items', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
const allBuiltIn = result.current.builtList.every(item => item.id === 'built-in')
|
||||
expect(allBuiltIn).toBe(true)
|
||||
})
|
||||
|
||||
it('should return tempList', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(Array.isArray(result.current.tempList)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return setTempList function', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(typeof result.current.setTempList).toBe('function')
|
||||
})
|
||||
|
||||
it('should return hasData based on list length', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(result.current.hasData).toBe(result.current.list.length > 0)
|
||||
})
|
||||
|
||||
it('should return builtInEnabled', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(typeof result.current.builtInEnabled).toBe('boolean')
|
||||
})
|
||||
|
||||
it('should return originInfo', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(Array.isArray(result.current.originInfo)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return technicalParameters', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
expect(Array.isArray(result.current.technicalParameters)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
it('should enter edit mode when startToEdit is called', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.startToEdit()
|
||||
})
|
||||
|
||||
expect(result.current.isEdit).toBe(true)
|
||||
})
|
||||
|
||||
it('should exit edit mode when handleCancel is called', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.startToEdit()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancel()
|
||||
})
|
||||
|
||||
expect(result.current.isEdit).toBe(false)
|
||||
})
|
||||
|
||||
it('should reset tempList when handleCancel is called', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.startToEdit()
|
||||
})
|
||||
|
||||
const originalLength = result.current.list.length
|
||||
|
||||
act(() => {
|
||||
result.current.setTempList([])
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleCancel()
|
||||
})
|
||||
|
||||
expect(result.current.tempList.length).toBe(originalLength)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSelectMetaData', () => {
|
||||
it('should add metadata to tempList if not exists', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.startToEdit()
|
||||
})
|
||||
|
||||
const initialLength = result.current.tempList.length
|
||||
|
||||
act(() => {
|
||||
result.current.handleSelectMetaData({
|
||||
id: 'new-id',
|
||||
name: 'new_field',
|
||||
type: DataType.string,
|
||||
value: null,
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.tempList.length).toBe(initialLength + 1)
|
||||
})
|
||||
|
||||
it('should not add duplicate metadata', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.startToEdit()
|
||||
})
|
||||
|
||||
const initialLength = result.current.tempList.length
|
||||
|
||||
// Try to add existing item
|
||||
if (result.current.tempList.length > 0) {
|
||||
act(() => {
|
||||
result.current.handleSelectMetaData(result.current.tempList[0])
|
||||
})
|
||||
|
||||
expect(result.current.tempList.length).toBe(initialLength)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleAddMetaData', () => {
|
||||
it('should call doAddMetaData with valid name', async () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleAddMetaData({
|
||||
name: 'valid_field',
|
||||
type: DataType.string,
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockDoAddMetaData).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject invalid name', async () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
await expect(
|
||||
act(async () => {
|
||||
await result.current.handleAddMetaData({
|
||||
name: '',
|
||||
type: DataType.string,
|
||||
})
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSave', () => {
|
||||
it('should call mutateAsync to save metadata', async () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.startToEdit()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should exit edit mode after save', async () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
act(() => {
|
||||
result.current.startToEdit()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleSave()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isEdit).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getReadOnlyMetaData - originInfo', () => {
|
||||
it('should return origin info with correct structure', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
expect(result.current.originInfo).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: DataType.string,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('should use languageMap for language field (select type)', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
// Find language field in originInfo
|
||||
const languageField = result.current.originInfo.find(
|
||||
item => item.name === 'Language',
|
||||
)
|
||||
|
||||
// If language field exists and docDetail has language 'en', value should be 'English'
|
||||
if (languageField)
|
||||
expect(languageField.value).toBe('English')
|
||||
})
|
||||
|
||||
it('should return dash for empty field values', () => {
|
||||
const docDetailWithEmpty: DocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
data_source_type: 'upload_file',
|
||||
word_count: 100,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataDocument({
|
||||
...defaultProps,
|
||||
docDetail: docDetailWithEmpty as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Check if there's any field with '-' value (meaning empty)
|
||||
const hasEmptyField = result.current.originInfo.some(
|
||||
item => item.value === '-',
|
||||
)
|
||||
// language field should return '-' since it's not set
|
||||
expect(hasEmptyField).toBe(true)
|
||||
})
|
||||
|
||||
it('should return empty object for non-language select fields', () => {
|
||||
// This tests the else branch of getTargetMap where field !== 'language'
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
// The data_source_type field is a text field, not select
|
||||
const sourceTypeField = result.current.originInfo.find(
|
||||
item => item.name === 'Source Type',
|
||||
)
|
||||
|
||||
// It should return the raw value since it's not a select type
|
||||
if (sourceTypeField)
|
||||
expect(sourceTypeField.value).toBe('upload_file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getReadOnlyMetaData - technicalParameters', () => {
|
||||
it('should return technical parameters with correct structure', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
expect(result.current.technicalParameters).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: DataType.string,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('should use render function when available', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
// Find hit_count field which has a render function
|
||||
const hitCountField = result.current.technicalParameters.find(
|
||||
item => item.name === 'Hit Count',
|
||||
)
|
||||
|
||||
// The render function should format as "val/segmentCount"
|
||||
if (hitCountField)
|
||||
expect(hitCountField.value).toBe('50/10')
|
||||
})
|
||||
|
||||
it('should return raw value when no render function', () => {
|
||||
const { result } = renderHook(() => useMetadataDocument(defaultProps))
|
||||
|
||||
// Find word_count field which has no render function
|
||||
const wordCountField = result.current.technicalParameters.find(
|
||||
item => item.name === 'Word Count',
|
||||
)
|
||||
|
||||
if (wordCountField)
|
||||
expect(wordCountField.value).toBe(100)
|
||||
})
|
||||
|
||||
it('should handle fields with render function and undefined segment_count', () => {
|
||||
const docDetailNoSegment: DocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
data_source_type: 'upload_file',
|
||||
word_count: 100,
|
||||
hit_count: 25,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataDocument({
|
||||
...defaultProps,
|
||||
docDetail: docDetailNoSegment as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}),
|
||||
)
|
||||
|
||||
const hitCountField = result.current.technicalParameters.find(
|
||||
item => item.name === 'Hit Count',
|
||||
)
|
||||
|
||||
// Should use 0 as default for segment_count
|
||||
if (hitCountField)
|
||||
expect(hitCountField.value).toBe('25/0')
|
||||
})
|
||||
|
||||
it('should return dash for null/undefined values', () => {
|
||||
const docDetailWithNull: DocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
data_source_type: '',
|
||||
word_count: 0,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataDocument({
|
||||
...defaultProps,
|
||||
docDetail: docDetailWithNull as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}),
|
||||
)
|
||||
|
||||
// 0 should still be shown, but empty string should show '-'
|
||||
const sourceTypeField = result.current.originInfo.find(
|
||||
item => item.name === 'Source Type',
|
||||
)
|
||||
|
||||
if (sourceTypeField)
|
||||
expect(sourceTypeField.value).toBe('-')
|
||||
})
|
||||
|
||||
it('should handle 0 value correctly (not treated as empty)', () => {
|
||||
const docDetailWithZero: DocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
data_source_type: 'upload_file',
|
||||
word_count: 0,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataDocument({
|
||||
...defaultProps,
|
||||
docDetail: docDetailWithZero as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}),
|
||||
)
|
||||
|
||||
// word_count of 0 should still show 0, not '-'
|
||||
const wordCountField = result.current.technicalParameters.find(
|
||||
item => item.name === 'Word Count',
|
||||
)
|
||||
|
||||
if (wordCountField)
|
||||
expect(wordCountField.value).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty docDetail', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataDocument({
|
||||
...defaultProps,
|
||||
docDetail: {} as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle different datasetIds', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
props => useMetadataDocument(props),
|
||||
{ initialProps: defaultProps },
|
||||
)
|
||||
|
||||
expect(result.current).toBeDefined()
|
||||
|
||||
rerender({ ...defaultProps, datasetId: 'ds-2' })
|
||||
|
||||
expect(result.current).toBeDefined()
|
||||
})
|
||||
|
||||
it('should handle docDetail with all fields', () => {
|
||||
const fullDocDetail: DocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Full Document',
|
||||
data_source_type: 'website',
|
||||
word_count: 500,
|
||||
language: 'zh',
|
||||
hit_count: 100,
|
||||
segment_count: 20,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataDocument({
|
||||
...defaultProps,
|
||||
docDetail: fullDocDetail as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Language should be mapped
|
||||
const languageField = result.current.originInfo.find(
|
||||
item => item.name === 'Language',
|
||||
)
|
||||
if (languageField)
|
||||
expect(languageField.value).toBe('Chinese')
|
||||
|
||||
// Hit count should be rendered
|
||||
const hitCountField = result.current.technicalParameters.find(
|
||||
item => item.name === 'Hit Count',
|
||||
)
|
||||
if (hitCountField)
|
||||
expect(hitCountField.value).toBe('100/20')
|
||||
})
|
||||
|
||||
it('should handle unknown language', () => {
|
||||
const unknownLangDetail: DocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Unknown Lang Document',
|
||||
data_source_type: 'upload_file',
|
||||
word_count: 100,
|
||||
language: 'unknown_lang',
|
||||
}
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useMetadataDocument({
|
||||
...defaultProps,
|
||||
docDetail: unknownLangDetail as Parameters<typeof useMetadataDocument>[0]['docDetail'],
|
||||
}),
|
||||
)
|
||||
|
||||
// Unknown language should return undefined from the map
|
||||
const languageField = result.current.originInfo.find(
|
||||
item => item.name === 'Language',
|
||||
)
|
||||
// When language is not in map, it returns undefined
|
||||
expect(languageField?.value).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,268 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import CreateContent from './create-content'
|
||||
|
||||
type ModalLikeWrapProps = {
|
||||
children: React.ReactNode
|
||||
title: string
|
||||
onClose?: () => void
|
||||
onConfirm: () => void
|
||||
beforeHeader?: React.ReactNode
|
||||
}
|
||||
|
||||
type OptionCardProps = {
|
||||
title: string
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
// Mock ModalLikeWrap
|
||||
vi.mock('../../../base/modal-like-wrap', () => ({
|
||||
default: ({ children, title, onClose, onConfirm, beforeHeader }: ModalLikeWrapProps) => (
|
||||
<div data-testid="modal-wrap">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
{beforeHeader && <div data-testid="before-header">{beforeHeader}</div>}
|
||||
<div data-testid="modal-content">{children}</div>
|
||||
<button data-testid="close-btn" onClick={onClose}>Close</button>
|
||||
<button data-testid="confirm-btn" onClick={onConfirm}>Confirm</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock OptionCard
|
||||
vi.mock('../../../workflow/nodes/_base/components/option-card', () => ({
|
||||
default: ({ title, selected, onSelect }: OptionCardProps) => (
|
||||
<button
|
||||
data-testid={`option-${title.toLowerCase()}`}
|
||||
data-selected={selected}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Field
|
||||
vi.mock('./field', () => ({
|
||||
default: ({ label, children }: FieldProps) => (
|
||||
<div data-testid="field">
|
||||
<label data-testid="field-label">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('CreateContent', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
expect(screen.getByTestId('modal-wrap')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render modal title', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
expect(screen.getByTestId('modal-title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render type selection options', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
expect(screen.getByTestId('option-string')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('option-number')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('option-time')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render name input field', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render confirm button', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
expect(screen.getByTestId('confirm-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
expect(screen.getByTestId('close-btn')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Type Selection', () => {
|
||||
it('should default to string type', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
|
||||
it('should select number type when clicked', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('option-number'))
|
||||
|
||||
expect(screen.getByTestId('option-number')).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
|
||||
it('should select time type when clicked', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('option-time'))
|
||||
|
||||
expect(screen.getByTestId('option-time')).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
|
||||
it('should deselect previous type when new type is selected', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
// Initially string is selected
|
||||
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
|
||||
|
||||
// Select number
|
||||
fireEvent.click(screen.getByTestId('option-number'))
|
||||
|
||||
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'false')
|
||||
expect(screen.getByTestId('option-number')).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Name Input', () => {
|
||||
it('should update name when typing', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'new_field' } })
|
||||
|
||||
expect(input).toHaveValue('new_field')
|
||||
})
|
||||
|
||||
it('should start with empty name', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onSave with type and name when confirmed', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'test_field' } })
|
||||
fireEvent.click(screen.getByTestId('confirm-btn'))
|
||||
|
||||
expect(handleSave).toHaveBeenCalledWith({
|
||||
type: DataType.string,
|
||||
name: 'test_field',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onSave with correct type after changing type', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('option-number'))
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'num_field' } })
|
||||
fireEvent.click(screen.getByTestId('confirm-btn'))
|
||||
|
||||
expect(handleSave).toHaveBeenCalledWith({
|
||||
type: DataType.number,
|
||||
name: 'num_field',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onClose when close button is clicked', () => {
|
||||
const handleSave = vi.fn()
|
||||
const handleClose = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} onClose={handleClose} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-btn'))
|
||||
|
||||
expect(handleClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Back Button', () => {
|
||||
it('should show back button when hasBack is true', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} hasBack />)
|
||||
|
||||
expect(screen.getByTestId('before-header')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show back button when hasBack is false', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} hasBack={false} />)
|
||||
|
||||
expect(screen.queryByTestId('before-header')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onBack when back button is clicked', () => {
|
||||
const handleSave = vi.fn()
|
||||
const handleBack = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} hasBack onBack={handleBack} />)
|
||||
|
||||
const backButton = screen.getByTestId('before-header')
|
||||
// Find the clickable element inside
|
||||
const clickable = backButton.querySelector('.cursor-pointer') || backButton.firstChild
|
||||
if (clickable)
|
||||
fireEvent.click(clickable)
|
||||
|
||||
// The back functionality is tested through the actual implementation
|
||||
expect(screen.getByTestId('before-header')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty name submission', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('confirm-btn'))
|
||||
|
||||
expect(handleSave).toHaveBeenCalledWith({
|
||||
type: DataType.string,
|
||||
name: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle type cycling', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
// Cycle through all types
|
||||
fireEvent.click(screen.getByTestId('option-number'))
|
||||
fireEvent.click(screen.getByTestId('option-time'))
|
||||
fireEvent.click(screen.getByTestId('option-string'))
|
||||
|
||||
expect(screen.getByTestId('option-string')).toHaveAttribute('data-selected', 'true')
|
||||
})
|
||||
|
||||
it('should handle special characters in name', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(<CreateContent onSave={handleSave} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'test_field_123' } })
|
||||
|
||||
expect(input).toHaveValue('test_field_123')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,246 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import CreateMetadataModal from './create-metadata-modal'
|
||||
|
||||
type PortalProps = {
|
||||
children: React.ReactNode
|
||||
open: boolean
|
||||
}
|
||||
|
||||
type TriggerProps = {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
type ContentProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
type CreateContentProps = {
|
||||
onSave: (data: { type: DataType, name: string }) => void
|
||||
onClose?: () => void
|
||||
onBack?: () => void
|
||||
hasBack?: boolean
|
||||
}
|
||||
|
||||
// Mock PortalToFollowElem components
|
||||
vi.mock('../../../base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: PortalProps) => (
|
||||
<div data-testid="portal-wrapper" data-open={open}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children, className }: ContentProps) => (
|
||||
<div data-testid="portal-content" className={className}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock CreateContent component
|
||||
vi.mock('./create-content', () => ({
|
||||
default: ({ onSave, onClose, onBack, hasBack }: CreateContentProps) => (
|
||||
<div data-testid="create-content">
|
||||
<span data-testid="has-back">{String(hasBack)}</span>
|
||||
<button data-testid="save-btn" onClick={() => onSave({ type: DataType.string, name: 'test' })}>Save</button>
|
||||
<button data-testid="close-btn" onClick={onClose}>Close</button>
|
||||
{hasBack && <button data-testid="back-btn" onClick={onBack}>Back</button>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('CreateMetadataModal', () => {
|
||||
const mockTrigger = <button data-testid="trigger-button">Open Modal</button>
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render trigger when closed', () => {
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={false}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// Portal wrapper should exist but closed
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
|
||||
})
|
||||
|
||||
it('should render content when open', () => {
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('create-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trigger element', () => {
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={false}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass hasBack to CreateContent', () => {
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
hasBack
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('has-back')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should pass hasBack=undefined when not provided', () => {
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('has-back')).toHaveTextContent('undefined')
|
||||
})
|
||||
|
||||
it('should accept custom popupLeft', () => {
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
popupLeft={50}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should toggle open state when trigger is clicked', () => {
|
||||
const setOpen = vi.fn()
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={false}
|
||||
setOpen={setOpen}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
expect(setOpen).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should call onSave when save button is clicked', () => {
|
||||
const handleSave = vi.fn()
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={handleSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('save-btn'))
|
||||
|
||||
expect(handleSave).toHaveBeenCalledWith({
|
||||
type: DataType.string,
|
||||
name: 'test',
|
||||
})
|
||||
})
|
||||
|
||||
it('should close modal when close button is clicked', () => {
|
||||
const setOpen = vi.fn()
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-btn'))
|
||||
|
||||
expect(setOpen).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should close modal when back button is clicked', () => {
|
||||
const setOpen = vi.fn()
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
hasBack
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('back-btn'))
|
||||
|
||||
expect(setOpen).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle switching open state', () => {
|
||||
const { rerender } = render(
|
||||
<CreateMetadataModal
|
||||
open={false}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'false')
|
||||
|
||||
rerender(
|
||||
<CreateMetadataModal
|
||||
open={true}
|
||||
setOpen={vi.fn()}
|
||||
trigger={mockTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toHaveAttribute('data-open', 'true')
|
||||
})
|
||||
|
||||
it('should handle different trigger elements', () => {
|
||||
const customTrigger = <div data-testid="custom-trigger">Custom</div>
|
||||
render(
|
||||
<CreateMetadataModal
|
||||
open={false}
|
||||
setOpen={vi.fn()}
|
||||
trigger={customTrigger}
|
||||
onSave={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,587 @@
|
||||
import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import DatasetMetadataDrawer from './dataset-metadata-drawer'
|
||||
|
||||
// Mock service/API calls
|
||||
vi.mock('@/service/knowledge/use-metadata', () => ({
|
||||
useDatasetMetaData: () => ({
|
||||
data: {
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'existing_field', type: DataType.string },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock check name hook
|
||||
vi.mock('../hooks/use-check-metadata-name', () => ({
|
||||
default: () => ({
|
||||
checkName: () => ({ errorMsg: '' }),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock Toast
|
||||
const mockToastNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (args: unknown) => mockToastNotify(args),
|
||||
},
|
||||
}))
|
||||
|
||||
// Type definitions for mock props
|
||||
type CreateModalProps = {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
trigger: React.ReactNode
|
||||
onSave: (data: BuiltInMetadataItem) => void
|
||||
}
|
||||
|
||||
// Mock CreateModal to expose callbacks
|
||||
vi.mock('@/app/components/datasets/metadata/metadata-dataset/create-metadata-modal', () => ({
|
||||
default: ({ open, setOpen, trigger, onSave }: CreateModalProps) => (
|
||||
<div data-testid="create-modal-wrapper">
|
||||
<div data-testid="create-trigger" onClick={() => setOpen(true)}>{trigger}</div>
|
||||
{open && (
|
||||
<div data-testid="create-modal">
|
||||
<button data-testid="create-save" onClick={() => onSave({ name: 'new_field', type: DataType.string })}>
|
||||
Save
|
||||
</button>
|
||||
<button data-testid="create-close" onClick={() => setOpen(false)}>Close</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('DatasetMetadataDrawer', () => {
|
||||
const mockUserMetadata: MetadataItemWithValueLength[] = [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, count: 5 },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, count: 3 },
|
||||
]
|
||||
|
||||
const mockBuiltInMetadata: BuiltInMetadataItem[] = [
|
||||
{ name: 'created_at', type: DataType.time },
|
||||
{ name: 'modified_at', type: DataType.time },
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
userMetadata: mockUserMetadata,
|
||||
builtInMetadata: mockBuiltInMetadata,
|
||||
isBuiltInEnabled: false,
|
||||
onIsBuiltInEnabledChange: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onAdd: vi.fn().mockResolvedValue({}),
|
||||
onRename: vi.fn().mockResolvedValue({}),
|
||||
onRemove: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render user metadata items', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_two')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render built-in metadata items', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('created_at')).toBeInTheDocument()
|
||||
expect(screen.getByText('modified_at')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render metadata type for each item', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(DataType.string).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(DataType.number).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render add metadata button', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render switch for built-in toggle', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
await waitFor(() => {
|
||||
const switchBtn = screen.getByRole('switch')
|
||||
expect(switchBtn).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onIsBuiltInEnabledChange when switch is toggled', async () => {
|
||||
const onIsBuiltInEnabledChange = vi.fn()
|
||||
render(
|
||||
<DatasetMetadataDrawer
|
||||
{...defaultProps}
|
||||
onIsBuiltInEnabledChange={onIsBuiltInEnabledChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const switchBtn = screen.getByRole('switch')
|
||||
fireEvent.click(switchBtn)
|
||||
|
||||
expect(onIsBuiltInEnabledChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add Metadata', () => {
|
||||
it('should open create modal when add button is clicked', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const trigger = screen.getByTestId('create-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onAdd and show success toast when metadata is added', async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue({})
|
||||
render(<DatasetMetadataDrawer {...defaultProps} onAdd={onAdd} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Open create modal
|
||||
const trigger = screen.getByTestId('create-trigger')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Save new metadata
|
||||
fireEvent.click(screen.getByTestId('create-save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onAdd).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'success',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should close create modal after save', async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue({})
|
||||
render(<DatasetMetadataDrawer {...defaultProps} onAdd={onAdd} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Open create modal
|
||||
fireEvent.click(screen.getByTestId('create-trigger'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByTestId('create-save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('create-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rename Metadata', () => {
|
||||
it('should open rename modal when edit icon is clicked', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find user metadata items with group/item class (these have edit/delete icons)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const items = dialog.querySelectorAll('.group\\/item')
|
||||
expect(items.length).toBe(2) // 2 user metadata items
|
||||
|
||||
// Find the hidden container with edit/delete icons
|
||||
const actionsContainer = items[0].querySelector('.hidden.items-center')
|
||||
expect(actionsContainer).toBeTruthy()
|
||||
|
||||
// Find and click the first SVG (edit icon)
|
||||
if (actionsContainer) {
|
||||
const svgs = actionsContainer.querySelectorAll('svg')
|
||||
expect(svgs.length).toBeGreaterThan(0)
|
||||
fireEvent.click(svgs[0])
|
||||
}
|
||||
|
||||
// Wait for rename modal (contains input)
|
||||
await waitFor(() => {
|
||||
const inputs = document.querySelectorAll('input')
|
||||
expect(inputs.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onRename when rename is saved', async () => {
|
||||
const onRename = vi.fn().mockResolvedValue({})
|
||||
render(<DatasetMetadataDrawer {...defaultProps} onRename={onRename} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click edit icon
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const items = dialog.querySelectorAll('.group\\/item')
|
||||
const actionsContainer = items[0].querySelector('.hidden.items-center')
|
||||
if (actionsContainer) {
|
||||
const svgs = actionsContainer.querySelectorAll('svg')
|
||||
fireEvent.click(svgs[0])
|
||||
}
|
||||
|
||||
// Change name and save
|
||||
await waitFor(() => {
|
||||
const inputs = document.querySelectorAll('input')
|
||||
expect(inputs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
const inputs = document.querySelectorAll('input')
|
||||
fireEvent.change(inputs[0], { target: { value: 'renamed_field' } })
|
||||
|
||||
// Find and click save button
|
||||
const saveBtns = screen.getAllByText(/save/i)
|
||||
const primaryBtn = saveBtns.find(btn =>
|
||||
btn.closest('button')?.classList.contains('btn-primary'),
|
||||
)
|
||||
if (primaryBtn)
|
||||
fireEvent.click(primaryBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRename).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'success',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should close rename modal when cancel is clicked', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click edit icon
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const items = dialog.querySelectorAll('.group\\/item')
|
||||
const actionsContainer = items[0].querySelector('.hidden.items-center')
|
||||
if (actionsContainer) {
|
||||
const svgs = actionsContainer.querySelectorAll('svg')
|
||||
fireEvent.click(svgs[0])
|
||||
}
|
||||
|
||||
// Wait for modal and click cancel
|
||||
await waitFor(() => {
|
||||
const inputs = document.querySelectorAll('input')
|
||||
expect(inputs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// Change name first
|
||||
const inputs = document.querySelectorAll('input')
|
||||
fireEvent.change(inputs[0], { target: { value: 'changed_name' } })
|
||||
|
||||
// Find and click cancel button
|
||||
const cancelBtns = screen.getAllByText(/cancel/i)
|
||||
const cancelBtn = cancelBtns.find(btn =>
|
||||
!btn.closest('button')?.classList.contains('btn-primary'),
|
||||
)
|
||||
if (cancelBtn)
|
||||
fireEvent.click(cancelBtn)
|
||||
|
||||
// Verify input resets or modal closes
|
||||
await waitFor(() => {
|
||||
const currentInputs = document.querySelectorAll('input')
|
||||
// Either no inputs (modal closed) or value reset
|
||||
expect(currentInputs.length === 0 || currentInputs[0].value !== 'changed_name').toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should close rename modal when modal close button is clicked', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click edit icon
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const items = dialog.querySelectorAll('.group\\/item')
|
||||
const actionsContainer = items[0].querySelector('.hidden.items-center')
|
||||
if (actionsContainer) {
|
||||
const svgs = actionsContainer.querySelectorAll('svg')
|
||||
fireEvent.click(svgs[0])
|
||||
}
|
||||
|
||||
// Wait for rename modal
|
||||
await waitFor(() => {
|
||||
const inputs = document.querySelectorAll('input')
|
||||
expect(inputs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// Find and click the modal close button (X button)
|
||||
// The Modal component has a close button in the header
|
||||
const dialogs = screen.getAllByRole('dialog')
|
||||
const renameModal = dialogs.find(d => d.querySelector('input'))
|
||||
if (renameModal) {
|
||||
// Find close button by looking for a button with close-related class or X icon
|
||||
const closeButtons = renameModal.querySelectorAll('button')
|
||||
for (const btn of Array.from(closeButtons)) {
|
||||
// Skip cancel/save buttons
|
||||
if (!btn.textContent?.toLowerCase().includes('cancel')
|
||||
&& !btn.textContent?.toLowerCase().includes('save')
|
||||
&& btn.querySelector('svg')) {
|
||||
fireEvent.click(btn)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete Metadata', () => {
|
||||
it('should show confirm dialog when delete icon is clicked', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find user metadata items
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const items = dialog.querySelectorAll('.group\\/item')
|
||||
|
||||
// Find the delete container
|
||||
const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive')
|
||||
expect(deleteContainer).toBeTruthy()
|
||||
|
||||
// Click delete icon
|
||||
if (deleteContainer) {
|
||||
const deleteIcon = deleteContainer.querySelector('svg')
|
||||
if (deleteIcon)
|
||||
fireEvent.click(deleteIcon)
|
||||
}
|
||||
|
||||
// Confirm dialog should appear
|
||||
await waitFor(() => {
|
||||
const confirmBtns = screen.getAllByRole('button')
|
||||
const hasConfirmBtn = confirmBtns.some(btn =>
|
||||
btn.textContent?.toLowerCase().includes('confirm'),
|
||||
)
|
||||
expect(hasConfirmBtn).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onRemove when delete is confirmed', async () => {
|
||||
const onRemove = vi.fn().mockResolvedValue({})
|
||||
render(<DatasetMetadataDrawer {...defaultProps} onRemove={onRemove} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click delete icon
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const items = dialog.querySelectorAll('.group\\/item')
|
||||
const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive')
|
||||
if (deleteContainer) {
|
||||
const deleteIcon = deleteContainer.querySelector('svg')
|
||||
if (deleteIcon)
|
||||
fireEvent.click(deleteIcon)
|
||||
}
|
||||
|
||||
// Wait for confirm dialog
|
||||
await waitFor(() => {
|
||||
const confirmBtns = screen.getAllByRole('button')
|
||||
const hasConfirmBtn = confirmBtns.some(btn =>
|
||||
btn.textContent?.toLowerCase().includes('confirm'),
|
||||
)
|
||||
expect(hasConfirmBtn).toBe(true)
|
||||
})
|
||||
|
||||
// Click confirm
|
||||
const confirmBtns = screen.getAllByRole('button')
|
||||
const confirmBtn = confirmBtns.find(btn =>
|
||||
btn.textContent?.toLowerCase().includes('confirm'),
|
||||
)
|
||||
if (confirmBtn)
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRemove).toHaveBeenCalledWith('1')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'success',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should close confirm dialog when cancel is clicked', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click delete icon
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const items = dialog.querySelectorAll('.group\\/item')
|
||||
const deleteContainer = items[0].querySelector('.hover\\:text-text-destructive')
|
||||
if (deleteContainer) {
|
||||
const deleteIcon = deleteContainer.querySelector('svg')
|
||||
if (deleteIcon)
|
||||
fireEvent.click(deleteIcon)
|
||||
}
|
||||
|
||||
// Wait for confirm dialog
|
||||
await waitFor(() => {
|
||||
const confirmBtns = screen.getAllByRole('button')
|
||||
const hasConfirmBtn = confirmBtns.some(btn =>
|
||||
btn.textContent?.toLowerCase().includes('confirm'),
|
||||
)
|
||||
expect(hasConfirmBtn).toBe(true)
|
||||
})
|
||||
|
||||
// Click cancel
|
||||
const cancelBtns = screen.getAllByRole('button')
|
||||
const cancelBtn = cancelBtns.find(btn =>
|
||||
btn.textContent?.toLowerCase().includes('cancel'),
|
||||
)
|
||||
if (cancelBtn)
|
||||
fireEvent.click(cancelBtn)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should handle empty userMetadata', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={[]} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty builtInMetadata', async () => {
|
||||
render(<DatasetMetadataDrawer {...defaultProps} builtInMetadata={[]} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Built-in Items State', () => {
|
||||
it('should show disabled styling when built-in is disabled', async () => {
|
||||
render(
|
||||
<DatasetMetadataDrawer {...defaultProps} isBuiltInEnabled={false} />,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const disabledItems = dialog.querySelectorAll('.opacity-30')
|
||||
expect(disabledItems.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not show disabled styling when built-in is enabled', async () => {
|
||||
render(
|
||||
<DatasetMetadataDrawer {...defaultProps} isBuiltInEnabled />,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle items with special characters in name', async () => {
|
||||
const specialMetadata: MetadataItemWithValueLength[] = [
|
||||
{ id: '1', name: 'field_with_underscore', type: DataType.string, count: 1 },
|
||||
]
|
||||
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={specialMetadata} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('field_with_underscore')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle single user metadata item', async () => {
|
||||
const singleMetadata: MetadataItemWithValueLength[] = [
|
||||
{ id: '1', name: 'only_field', type: DataType.string, count: 10 },
|
||||
]
|
||||
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={singleMetadata} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('only_field')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle single built-in metadata item', async () => {
|
||||
const singleBuiltIn: BuiltInMetadataItem[] = [
|
||||
{ name: 'created_at', type: DataType.time },
|
||||
]
|
||||
render(<DatasetMetadataDrawer {...defaultProps} builtInMetadata={singleBuiltIn} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('created_at')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle metadata with zero count', async () => {
|
||||
const zeroCountMetadata: MetadataItemWithValueLength[] = [
|
||||
{ id: '1', name: 'empty_field', type: DataType.string, count: 0 },
|
||||
]
|
||||
render(<DatasetMetadataDrawer {...defaultProps} userMetadata={zeroCountMetadata} />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('empty_field')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,122 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Field from './field'
|
||||
|
||||
describe('Field', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Field label="Test Label">Content</Field>)
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label with correct styling', () => {
|
||||
render(<Field label="My Label">Content</Field>)
|
||||
const labelElement = screen.getByText('My Label')
|
||||
expect(labelElement).toHaveClass('system-sm-semibold', 'py-1', 'text-text-secondary')
|
||||
})
|
||||
|
||||
it('should render children in content container', () => {
|
||||
const { container } = render(<Field label="Label">Child Content</Field>)
|
||||
// The children wrapper has mt-1 class
|
||||
const contentWrapper = container.querySelector('.mt-1')
|
||||
expect(contentWrapper).toBeInTheDocument()
|
||||
expect(contentWrapper).toHaveTextContent('Child Content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<Field label="Label" className="custom-class">Content</Field>)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should render with string children', () => {
|
||||
render(<Field label="Label">Simple Text</Field>)
|
||||
expect(screen.getByText('Simple Text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with complex children', () => {
|
||||
render(
|
||||
<Field label="Label">
|
||||
<div data-testid="complex-child">
|
||||
<span>Nested Content</span>
|
||||
</div>
|
||||
</Field>,
|
||||
)
|
||||
expect(screen.getByTestId('complex-child')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nested Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with multiple children', () => {
|
||||
render(
|
||||
<Field label="Label">
|
||||
<span>First</span>
|
||||
<span>Second</span>
|
||||
</Field>,
|
||||
)
|
||||
expect(screen.getByText('First')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render different labels correctly', () => {
|
||||
const { rerender } = render(<Field label="First Label">Content</Field>)
|
||||
expect(screen.getByText('First Label')).toBeInTheDocument()
|
||||
|
||||
rerender(<Field label="Second Label">Content</Field>)
|
||||
expect(screen.getByText('Second Label')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout', () => {
|
||||
it('should have label above content', () => {
|
||||
const { container } = render(<Field label="Label">Content</Field>)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper?.children).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render label element first', () => {
|
||||
const { container } = render(<Field label="Label">Content</Field>)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
const firstChild = wrapper?.firstChild as HTMLElement
|
||||
expect(firstChild).toHaveClass('system-sm-semibold')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with undefined className', () => {
|
||||
render(<Field label="Label" className={undefined}>Content</Field>)
|
||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty className', () => {
|
||||
render(<Field label="Label" className="">Content</Field>)
|
||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty label', () => {
|
||||
render(<Field label="">Content</Field>)
|
||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty children', () => {
|
||||
const { container } = render(<Field label="Label"><span></span></Field>)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with null children', () => {
|
||||
const { container } = render(<Field label="Label">{null}</Field>)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with number as children', () => {
|
||||
render(<Field label="Label">{42}</Field>)
|
||||
expect(screen.getByText('42')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in label', () => {
|
||||
render(<Field label={'Label & "chars"'}>Content</Field>)
|
||||
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,348 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import SelectMetadataModal from './select-metadata-modal'
|
||||
|
||||
type MetadataItem = {
|
||||
id: string
|
||||
name: string
|
||||
type: DataType
|
||||
}
|
||||
|
||||
type PortalProps = {
|
||||
children: React.ReactNode
|
||||
open: boolean
|
||||
}
|
||||
|
||||
type TriggerProps = {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
type ContentProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type SelectMetadataProps = {
|
||||
onSelect: (item: MetadataItem) => void
|
||||
onNew: () => void
|
||||
onManage: () => void
|
||||
list: MetadataItem[]
|
||||
}
|
||||
|
||||
type CreateContentProps = {
|
||||
onSave: (data: { type: DataType, name: string }) => void
|
||||
onBack?: () => void
|
||||
onClose?: () => void
|
||||
hasBack?: boolean
|
||||
}
|
||||
|
||||
// Mock useDatasetMetaData hook
|
||||
vi.mock('@/service/knowledge/use-metadata', () => ({
|
||||
useDatasetMetaData: () => ({
|
||||
data: {
|
||||
doc_metadata: [
|
||||
{ id: '1', name: 'field_one', type: DataType.string },
|
||||
{ id: '2', name: 'field_two', type: DataType.number },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock PortalToFollowElem components
|
||||
vi.mock('../../../base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children, open }: PortalProps) => (
|
||||
<div data-testid="portal-wrapper" data-open={open}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: TriggerProps) => (
|
||||
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: ContentProps) => (
|
||||
<div data-testid="portal-content">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock SelectMetadata component
|
||||
vi.mock('./select-metadata', () => ({
|
||||
default: ({ onSelect, onNew, onManage, list }: SelectMetadataProps) => (
|
||||
<div data-testid="select-metadata">
|
||||
<span data-testid="list-count">{list?.length || 0}</span>
|
||||
<button data-testid="select-item" onClick={() => onSelect({ id: '1', name: 'field_one', type: DataType.string })}>Select</button>
|
||||
<button data-testid="new-btn" onClick={onNew}>New</button>
|
||||
<button data-testid="manage-btn" onClick={onManage}>Manage</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock CreateContent component
|
||||
vi.mock('./create-content', () => ({
|
||||
default: ({ onSave, onBack, onClose, hasBack }: CreateContentProps) => (
|
||||
<div data-testid="create-content">
|
||||
<button data-testid="save-btn" onClick={() => onSave({ type: DataType.string, name: 'new_field' })}>Save</button>
|
||||
{hasBack && <button data-testid="back-btn" onClick={onBack}>Back</button>}
|
||||
<button data-testid="close-btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('SelectMetadataModal', () => {
|
||||
const mockTrigger = <button data-testid="trigger-button">Select Metadata</button>
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render trigger element', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('trigger-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render SelectMetadata by default', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('select-metadata')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass dataset metadata to SelectMetadata', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('list-count')).toHaveTextContent('2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should toggle open state when trigger is clicked', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||
|
||||
// State should toggle
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSelect and close when item is selected', () => {
|
||||
const handleSelect = vi.fn()
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={handleSelect}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-item'))
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith({
|
||||
id: '1',
|
||||
name: 'field_one',
|
||||
type: DataType.string,
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch to create step when new button is clicked', async () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('new-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onManage when manage button is clicked', () => {
|
||||
const handleManage = vi.fn()
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={handleManage}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('manage-btn'))
|
||||
|
||||
expect(handleManage).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Create Flow', () => {
|
||||
it('should switch back to select when back is clicked in create step', async () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Go to create step
|
||||
fireEvent.click(screen.getByTestId('new-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Go back to select step
|
||||
fireEvent.click(screen.getByTestId('back-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('select-metadata')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onSave and return to select step when save is clicked', async () => {
|
||||
const handleSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={handleSave}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Go to create step
|
||||
fireEvent.click(screen.getByTestId('new-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Save new metadata
|
||||
fireEvent.click(screen.getByTestId('save-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleSave).toHaveBeenCalledWith({
|
||||
type: DataType.string,
|
||||
name: 'new_field',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should accept custom popupPlacement', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
popupPlacement="bottom-start"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should accept custom popupOffset', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
popupOffset={{ mainAxis: 10, crossAxis: 5 }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle different datasetIds', () => {
|
||||
const { rerender } = render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-2"
|
||||
trigger={mockTrigger}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('portal-wrapper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty trigger', () => {
|
||||
render(
|
||||
<SelectMetadataModal
|
||||
datasetId="dataset-1"
|
||||
trigger={<span data-testid="empty-trigger" />}
|
||||
onSelect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('empty-trigger')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,332 @@
|
||||
import type { MetadataItem } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import SelectMetadata from './select-metadata'
|
||||
|
||||
type IconProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Mock getIcon utility
|
||||
vi.mock('../utils/get-icon', () => ({
|
||||
getIcon: () => (props: IconProps) => <span data-testid="icon" className={props.className}>Icon</span>,
|
||||
}))
|
||||
|
||||
describe('SelectMetadata', () => {
|
||||
const mockList: MetadataItem[] = [
|
||||
{ id: '1', name: 'field_one', type: DataType.string },
|
||||
{ id: '2', name: 'field_two', type: DataType.number },
|
||||
{ id: '3', name: 'field_three', type: DataType.time },
|
||||
]
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render search input', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all metadata items', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_two')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_three')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render new action button', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// New action button should be present (from i18n)
|
||||
expect(screen.getByText(/new/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render manage action button', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// Manage action button should be present (from i18n)
|
||||
expect(screen.getByText(/manage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display type for each item', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByText(DataType.string).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(DataType.number).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(DataType.time).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should filter items based on search query', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const searchInput = screen.getByRole('textbox')
|
||||
fireEvent.change(searchInput, { target: { value: 'one' } })
|
||||
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.queryByText('field_two')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('field_three')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be case insensitive search', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const searchInput = screen.getByRole('textbox')
|
||||
fireEvent.change(searchInput, { target: { value: 'ONE' } })
|
||||
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show all items when search is cleared', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const searchInput = screen.getByRole('textbox')
|
||||
|
||||
// Search for something
|
||||
fireEvent.change(searchInput, { target: { value: 'one' } })
|
||||
expect(screen.queryByText('field_two')).not.toBeInTheDocument()
|
||||
|
||||
// Clear search
|
||||
fireEvent.change(searchInput, { target: { value: '' } })
|
||||
expect(screen.getByText('field_two')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show no results when search matches nothing', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const searchInput = screen.getByRole('textbox')
|
||||
fireEvent.change(searchInput, { target: { value: 'xyz' } })
|
||||
|
||||
expect(screen.queryByText('field_one')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('field_two')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('field_three')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onSelect with item data when item is clicked', () => {
|
||||
const handleSelect = vi.fn()
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={handleSelect}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('field_one'))
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith({
|
||||
id: '1',
|
||||
name: 'field_one',
|
||||
type: DataType.string,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onNew when new button is clicked', () => {
|
||||
const handleNew = vi.fn()
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={handleNew}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find and click the new action button
|
||||
const newButton = screen.getByText(/new/i)
|
||||
fireEvent.click(newButton.closest('div') || newButton)
|
||||
|
||||
expect(handleNew).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onManage when manage button is clicked', () => {
|
||||
const handleManage = vi.fn()
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={handleManage}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find and click the manage action button
|
||||
const manageButton = screen.getByText(/manage/i)
|
||||
fireEvent.click(manageButton.closest('div') || manageButton)
|
||||
|
||||
expect(handleManage).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should render empty list', () => {
|
||||
const { container } = render(
|
||||
<SelectMetadata
|
||||
list={[]}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should still show new and manage buttons with empty list', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={[]}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/new/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/manage/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct container styling', () => {
|
||||
const { container } = render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('w-[320px]', 'rounded-xl')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle single item list', () => {
|
||||
const singleItem: MetadataItem[] = [
|
||||
{ id: '1', name: 'only_one', type: DataType.string },
|
||||
]
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={singleItem}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('only_one')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle item with long name', () => {
|
||||
const longNameItem: MetadataItem[] = [
|
||||
{ id: '1', name: 'this_is_a_very_long_field_name_that_might_overflow', type: DataType.string },
|
||||
]
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={longNameItem}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('this_is_a_very_long_field_name_that_might_overflow')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle rapid search input changes', () => {
|
||||
render(
|
||||
<SelectMetadata
|
||||
list={mockList}
|
||||
onSelect={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onManage={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const searchInput = screen.getByRole('textbox')
|
||||
|
||||
// Rapid typing
|
||||
fireEvent.change(searchInput, { target: { value: 'f' } })
|
||||
fireEvent.change(searchInput, { target: { value: 'fi' } })
|
||||
fireEvent.change(searchInput, { target: { value: 'fie' } })
|
||||
fireEvent.change(searchInput, { target: { value: 'fiel' } })
|
||||
fireEvent.change(searchInput, { target: { value: 'field' } })
|
||||
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_two')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_three')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,113 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Field from './field'
|
||||
|
||||
describe('Field', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Field label="Test Label">Content</Field>)
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label with correct styling', () => {
|
||||
render(<Field label="My Label">Content</Field>)
|
||||
const labelElement = screen.getByText('My Label')
|
||||
expect(labelElement).toHaveClass('system-xs-medium', 'w-[128px]', 'shrink-0', 'truncate', 'py-1', 'text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should render children in correct container', () => {
|
||||
const { container } = render(<Field label="Label">Child Content</Field>)
|
||||
// The children are wrapped in a div with w-[244px] class
|
||||
const contentWrapper = container.querySelector('.w-\\[244px\\]')
|
||||
expect(contentWrapper).toBeInTheDocument()
|
||||
expect(contentWrapper).toHaveClass('shrink-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should render with string children', () => {
|
||||
render(<Field label="Label">Simple Text</Field>)
|
||||
expect(screen.getByText('Simple Text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with complex children', () => {
|
||||
render(
|
||||
<Field label="Label">
|
||||
<div data-testid="complex-child">
|
||||
<span>Nested Content</span>
|
||||
</div>
|
||||
</Field>,
|
||||
)
|
||||
expect(screen.getByTestId('complex-child')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nested Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with multiple children', () => {
|
||||
render(
|
||||
<Field label="Label">
|
||||
<span>First</span>
|
||||
<span>Second</span>
|
||||
</Field>,
|
||||
)
|
||||
expect(screen.getByText('First')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render different labels correctly', () => {
|
||||
const { rerender } = render(<Field label="First Label">Content</Field>)
|
||||
expect(screen.getByText('First Label')).toBeInTheDocument()
|
||||
|
||||
rerender(<Field label="Second Label">Content</Field>)
|
||||
expect(screen.getByText('Second Label')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout', () => {
|
||||
it('should have flex layout with space between elements', () => {
|
||||
const { container } = render(<Field label="Label">Content</Field>)
|
||||
const wrapper = container.firstChild
|
||||
expect(wrapper).toHaveClass('flex', 'items-start', 'space-x-2')
|
||||
})
|
||||
|
||||
it('should render label and content side by side', () => {
|
||||
const { container } = render(<Field label="Label">Content</Field>)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper?.children).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with empty label', () => {
|
||||
render(<Field label="">Content</Field>)
|
||||
expect(screen.getByText('Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with long label (truncation)', () => {
|
||||
const longLabel = 'This is a very long label that should be truncated'
|
||||
render(<Field label={longLabel}>Content</Field>)
|
||||
const labelElement = screen.getByText(longLabel)
|
||||
expect(labelElement).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('should render with empty children', () => {
|
||||
const { container } = render(<Field label="Label"><span></span></Field>)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with null children', () => {
|
||||
const { container } = render(<Field label="Label">{null}</Field>)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with number as children', () => {
|
||||
render(<Field label="Label">{42}</Field>)
|
||||
expect(screen.getByText('42')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in label', () => {
|
||||
render(<Field label={'Label & "chars"'}>Content</Field>)
|
||||
expect(screen.getByText('Label & "chars"')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,752 @@
|
||||
import type { MetadataItemWithValue } from '../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import MetadataDocument from './index'
|
||||
|
||||
type MockHookReturn = {
|
||||
embeddingAvailable: boolean
|
||||
isEdit: boolean
|
||||
setIsEdit: ReturnType<typeof vi.fn>
|
||||
list: MetadataItemWithValue[]
|
||||
tempList: MetadataItemWithValue[]
|
||||
setTempList: ReturnType<typeof vi.fn>
|
||||
handleSelectMetaData: ReturnType<typeof vi.fn>
|
||||
handleAddMetaData: ReturnType<typeof vi.fn>
|
||||
hasData: boolean
|
||||
builtList: MetadataItemWithValue[]
|
||||
builtInEnabled: boolean
|
||||
startToEdit: ReturnType<typeof vi.fn>
|
||||
handleSave: ReturnType<typeof vi.fn>
|
||||
handleCancel: ReturnType<typeof vi.fn>
|
||||
originInfo: MetadataItemWithValue[]
|
||||
technicalParameters: MetadataItemWithValue[]
|
||||
}
|
||||
|
||||
// Mock useMetadataDocument hook - need to control state
|
||||
const mockUseMetadataDocument = vi.fn<() => MockHookReturn>()
|
||||
vi.mock('../hooks/use-metadata-document', () => ({
|
||||
default: () => mockUseMetadataDocument(),
|
||||
}))
|
||||
|
||||
// Mock service calls
|
||||
vi.mock('@/service/knowledge/use-metadata', () => ({
|
||||
useDatasetMetaData: () => ({
|
||||
data: {
|
||||
doc_metadata: [],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock check name hook
|
||||
vi.mock('../hooks/use-check-metadata-name', () => ({
|
||||
default: () => ({
|
||||
checkName: () => ({ errorMsg: '' }),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('MetadataDocument', () => {
|
||||
const mockDocDetail = {
|
||||
id: 'doc-1',
|
||||
name: 'Test Document',
|
||||
data_source_type: 'upload_file',
|
||||
indexing_status: 'completed',
|
||||
created_at: 1609459200,
|
||||
word_count: 100,
|
||||
}
|
||||
|
||||
const mockList: MetadataItemWithValue[] = [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
|
||||
]
|
||||
|
||||
const defaultHookReturn: MockHookReturn = {
|
||||
embeddingAvailable: true,
|
||||
isEdit: false,
|
||||
setIsEdit: vi.fn(),
|
||||
list: mockList,
|
||||
tempList: mockList,
|
||||
setTempList: vi.fn(),
|
||||
handleSelectMetaData: vi.fn(),
|
||||
handleAddMetaData: vi.fn(),
|
||||
hasData: true,
|
||||
builtList: [],
|
||||
builtInEnabled: false,
|
||||
startToEdit: vi.fn(),
|
||||
handleSave: vi.fn(),
|
||||
handleCancel: vi.fn(),
|
||||
originInfo: [],
|
||||
technicalParameters: [],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseMetadataDocument.mockReturnValue(defaultHookReturn)
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render metadata fields when hasData is true', () => {
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_two')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render no-data state when hasData is false and not in edit mode', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
hasData: false,
|
||||
list: [],
|
||||
tempList: [],
|
||||
isEdit: false,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByText(/metadata/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render edit UI when in edit mode', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/save/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render built-in section when builtInEnabled is true', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
builtInEnabled: true,
|
||||
builtList: [{ id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 }],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('created_at')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider when builtInEnabled is true', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
builtInEnabled: true,
|
||||
builtList: [{ id: 'built-in', name: 'created_at', type: DataType.time, value: 1609459200 }],
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
const divider = container.querySelector('[class*="bg-gradient"]')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render origin info section', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
originInfo: [{ id: 'origin-1', name: 'source', type: DataType.string, value: 'upload' }],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('source')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render technical parameters section', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
technicalParameters: [{ id: 'tech-1', name: 'word_count', type: DataType.number, value: 100 }],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('word_count')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all sections together', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
builtInEnabled: true,
|
||||
builtList: [{ id: 'built-1', name: 'created_at', type: DataType.time, value: 1609459200 }],
|
||||
originInfo: [{ id: 'origin-1', name: 'source', type: DataType.string, value: 'upload' }],
|
||||
technicalParameters: [{ id: 'tech-1', name: 'word_count', type: DataType.number, value: 100 }],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.getByText('created_at')).toBeInTheDocument()
|
||||
expect(screen.getByText('source')).toBeInTheDocument()
|
||||
expect(screen.getByText('word_count')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
it('should show edit button when not in edit mode and embedding available', () => {
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/edit/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call startToEdit when edit button is clicked', () => {
|
||||
const startToEdit = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: false,
|
||||
startToEdit,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/edit/i))
|
||||
expect(startToEdit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleSave when save button is clicked', () => {
|
||||
const handleSave = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
handleSave,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/save/i))
|
||||
expect(handleSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call handleCancel when cancel button is clicked', () => {
|
||||
const handleCancel = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
handleCancel,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/cancel/i))
|
||||
expect(handleCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call setIsEdit(true) when start button is clicked in no-data state', () => {
|
||||
const setIsEdit = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
hasData: false,
|
||||
list: [],
|
||||
tempList: [],
|
||||
isEdit: false,
|
||||
setIsEdit,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
const startBtn = screen.queryByText(/start/i)
|
||||
if (startBtn) {
|
||||
fireEvent.click(startBtn)
|
||||
expect(setIsEdit).toHaveBeenCalledWith(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('should show InfoGroup when in edit mode without data', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
hasData: false,
|
||||
list: [],
|
||||
tempList: [],
|
||||
isEdit: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should show save/cancel buttons
|
||||
expect(screen.getByText(/save/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Operations', () => {
|
||||
it('should call setTempList when field value changes', async () => {
|
||||
const setTempList = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
setTempList,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = container.querySelectorAll('input')
|
||||
if (inputs.length > 0) {
|
||||
fireEvent.change(inputs[0], { target: { value: 'new value' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setTempList).toHaveBeenCalled()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should have handleAddMetaData function available', () => {
|
||||
const handleAddMetaData = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
handleAddMetaData,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(typeof handleAddMetaData).toBe('function')
|
||||
})
|
||||
|
||||
it('should have handleSelectMetaData function available', () => {
|
||||
const handleSelectMetaData = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
handleSelectMetaData,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(typeof handleSelectMetaData).toBe('function')
|
||||
})
|
||||
|
||||
it('should pass onChange callback to InfoGroup', async () => {
|
||||
const setTempList = vi.fn()
|
||||
const tempList = [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
]
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
tempList,
|
||||
setTempList,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
const inputs = container.querySelectorAll('input')
|
||||
if (inputs.length > 0) {
|
||||
fireEvent.change(inputs[0], { target: { value: 'updated' } })
|
||||
await waitFor(() => {
|
||||
expect(setTempList).toHaveBeenCalled()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should pass onDelete callback to InfoGroup', async () => {
|
||||
const setTempList = vi.fn()
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
tempList: mockList,
|
||||
setTempList,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Look for delete buttons - they are inside hover:bg-state-destructive-hover divs
|
||||
const deleteContainers = container.querySelectorAll('.hover\\:bg-state-destructive-hover')
|
||||
expect(deleteContainers.length).toBeGreaterThan(0)
|
||||
|
||||
// Click the delete icon (SVG inside the container)
|
||||
if (deleteContainers.length > 0) {
|
||||
const deleteIcon = deleteContainers[0].querySelector('svg')
|
||||
if (deleteIcon)
|
||||
fireEvent.click(deleteIcon)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setTempList).toHaveBeenCalled()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
className="custom-class"
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should use tempList when in edit mode', () => {
|
||||
const tempList = [{ id: 'temp-1', name: 'temp_field', type: DataType.string, value: 'temp' }]
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
tempList,
|
||||
list: mockList,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('temp_field')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use list when not in edit mode', () => {
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
expect(screen.getByText('field_two')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass datasetId to child components', () => {
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="custom-ds-id"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
// Component should render without errors
|
||||
expect(screen.getByText('field_one')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Embedding Availability', () => {
|
||||
it('should not show edit button when embedding is not available', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
embeddingAvailable: false,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/^edit$/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show NoData when embedding is not available', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
embeddingAvailable: false,
|
||||
hasData: false,
|
||||
list: [],
|
||||
tempList: [],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
// NoData component should not be rendered
|
||||
expect(screen.queryByText(/start/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show edit buttons in edit mode when embedding not available', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
embeddingAvailable: false,
|
||||
isEdit: false,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
// headerRight should be null/undefined
|
||||
expect(screen.queryByText(/^edit$/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty lists', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
list: [],
|
||||
tempList: [],
|
||||
hasData: false,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly with minimal props', () => {
|
||||
const { container } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle switching between view and edit mode', () => {
|
||||
const { unmount } = render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/edit/i)).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
isEdit: true,
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/save/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple items in all sections', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
list: [
|
||||
{ id: '1', name: 'user_field_1', type: DataType.string, value: 'v1' },
|
||||
{ id: '2', name: 'user_field_2', type: DataType.number, value: 42 },
|
||||
],
|
||||
builtInEnabled: true,
|
||||
builtList: [
|
||||
{ id: 'b1', name: 'created_at', type: DataType.time, value: 1609459200 },
|
||||
{ id: 'b2', name: 'modified_at', type: DataType.time, value: 1609459200 },
|
||||
],
|
||||
originInfo: [
|
||||
{ id: 'o1', name: 'source', type: DataType.string, value: 'file' },
|
||||
{ id: 'o2', name: 'format', type: DataType.string, value: 'txt' },
|
||||
],
|
||||
technicalParameters: [
|
||||
{ id: 't1', name: 'word_count', type: DataType.number, value: 100 },
|
||||
{ id: 't2', name: 'char_count', type: DataType.number, value: 500 },
|
||||
],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('user_field_1')).toBeInTheDocument()
|
||||
expect(screen.getByText('user_field_2')).toBeInTheDocument()
|
||||
expect(screen.getByText('created_at')).toBeInTheDocument()
|
||||
expect(screen.getByText('source')).toBeInTheDocument()
|
||||
expect(screen.getByText('word_count')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null values in metadata', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
list: [
|
||||
{ id: '1', name: 'null_field', type: DataType.string, value: null },
|
||||
],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('null_field')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined values in metadata', () => {
|
||||
mockUseMetadataDocument.mockReturnValue({
|
||||
...defaultHookReturn,
|
||||
list: [
|
||||
{ id: '1', name: 'undefined_field', type: DataType.string, value: undefined as unknown as null },
|
||||
],
|
||||
})
|
||||
|
||||
render(
|
||||
<MetadataDocument
|
||||
datasetId="ds-1"
|
||||
documentId="doc-1"
|
||||
docDetail={mockDocDetail as Parameters<typeof MetadataDocument>[0]['docDetail']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('undefined_field')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,341 @@
|
||||
import type { MetadataItemWithValue } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import InfoGroup from './info-group'
|
||||
|
||||
type SelectModalProps = {
|
||||
trigger: React.ReactNode
|
||||
onSelect: (item: MetadataItemWithValue) => void
|
||||
onSave: (data: { name: string, type: DataType }) => void
|
||||
onManage: () => void
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type InputCombinedProps = {
|
||||
value: string | number | null
|
||||
onChange: (value: string | number) => void
|
||||
type: DataType
|
||||
}
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useTimestamp
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({
|
||||
formatTime: (timestamp: number) => {
|
||||
if (!timestamp)
|
||||
return ''
|
||||
return new Date(timestamp * 1000).toLocaleDateString()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock AddMetadataButton
|
||||
vi.mock('../add-metadata-button', () => ({
|
||||
default: () => <button data-testid="add-metadata-btn">Add Metadata</button>,
|
||||
}))
|
||||
|
||||
// Mock InputCombined
|
||||
vi.mock('../edit-metadata-batch/input-combined', () => ({
|
||||
default: ({ value, onChange, type }: InputCombinedProps) => (
|
||||
<input
|
||||
data-testid="input-combined"
|
||||
data-type={type}
|
||||
value={value || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock SelectMetadataModal
|
||||
vi.mock('../metadata-dataset/select-metadata-modal', () => ({
|
||||
default: ({ trigger, onSelect, onSave, onManage }: SelectModalProps) => (
|
||||
<div data-testid="select-metadata-modal">
|
||||
{trigger}
|
||||
<button data-testid="select-action" onClick={() => onSelect({ id: '1', name: 'test', type: DataType.string, value: null })}>Select</button>
|
||||
<button data-testid="save-action" onClick={() => onSave({ name: 'new_field', type: DataType.string })}>Save</button>
|
||||
<button data-testid="manage-action" onClick={onManage}>Manage</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Field
|
||||
vi.mock('./field', () => ({
|
||||
default: ({ label, children }: FieldProps) => (
|
||||
<div data-testid="field">
|
||||
<span data-testid="field-label">{label}</span>
|
||||
<div data-testid="field-content">{children}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('InfoGroup', () => {
|
||||
const mockList: MetadataItemWithValue[] = [
|
||||
{ id: '1', name: 'field_one', type: DataType.string, value: 'Value 1' },
|
||||
{ id: '2', name: 'field_two', type: DataType.number, value: 42 },
|
||||
{ id: '3', name: 'built-in', type: DataType.time, value: 1609459200 },
|
||||
]
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} />,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title when provided', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" />,
|
||||
)
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render header when noHeader is true', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" noHeader />,
|
||||
)
|
||||
expect(screen.queryByText('Test Title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all list items', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} />,
|
||||
)
|
||||
const fields = screen.getAllByTestId('field')
|
||||
expect(fields).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render tooltip when titleTooltip is provided', () => {
|
||||
render(
|
||||
<InfoGroup
|
||||
dataSetId="ds-1"
|
||||
list={mockList}
|
||||
title="Test"
|
||||
titleTooltip="This is a tooltip"
|
||||
/>,
|
||||
)
|
||||
// Tooltip icon should be present
|
||||
const tooltipIcon = screen.getByText('Test').closest('.flex')?.querySelector('svg')
|
||||
expect(tooltipIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render headerRight content', () => {
|
||||
render(
|
||||
<InfoGroup
|
||||
dataSetId="ds-1"
|
||||
list={mockList}
|
||||
title="Test"
|
||||
headerRight={<button data-testid="header-right-btn">Action</button>}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('header-right-btn')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
it('should render add metadata button when isEdit is true', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
|
||||
)
|
||||
expect(screen.getByTestId('add-metadata-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render add metadata button when isEdit is false', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit={false} />,
|
||||
)
|
||||
expect(screen.queryByTestId('add-metadata-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render input combined for each item in edit mode', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
|
||||
)
|
||||
const inputs = screen.getAllByTestId('input-combined')
|
||||
expect(inputs).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render delete icons in edit mode', () => {
|
||||
const { container } = render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
|
||||
)
|
||||
const deleteIcons = container.querySelectorAll('.cursor-pointer svg')
|
||||
expect(deleteIcons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange when input value changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onChange={handleChange} />,
|
||||
)
|
||||
|
||||
const inputs = screen.getAllByTestId('input-combined')
|
||||
fireEvent.change(inputs[0], { target: { value: 'New Value' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onDelete when delete icon is clicked', () => {
|
||||
const handleDelete = vi.fn()
|
||||
const { container } = render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onDelete={handleDelete} />,
|
||||
)
|
||||
|
||||
// Find delete icons (RiDeleteBinLine SVGs inside cursor-pointer divs)
|
||||
const deleteButtons = container.querySelectorAll('svg.size-4')
|
||||
if (deleteButtons.length > 0)
|
||||
fireEvent.click(deleteButtons[0])
|
||||
|
||||
expect(handleDelete).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onSelect when metadata is selected', () => {
|
||||
const handleSelect = vi.fn()
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onSelect={handleSelect} />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-action'))
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith({
|
||||
id: '1',
|
||||
name: 'test',
|
||||
type: DataType.string,
|
||||
value: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onAdd when new metadata is saved', () => {
|
||||
const handleAdd = vi.fn()
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit onAdd={handleAdd} />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('save-action'))
|
||||
|
||||
expect(handleAdd).toHaveBeenCalledWith({
|
||||
name: 'new_field',
|
||||
type: DataType.string,
|
||||
})
|
||||
})
|
||||
|
||||
it('should navigate to documents page when manage is clicked', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} isEdit />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('manage-action'))
|
||||
|
||||
// The onManage callback triggers the navigation
|
||||
expect(screen.getByTestId('manage-action')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} className="custom-class" />,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should apply contentClassName', () => {
|
||||
const { container } = render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} contentClassName="content-custom" />,
|
||||
)
|
||||
const contentDiv = container.querySelector('.content-custom')
|
||||
expect(contentDiv).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use uppercase title by default', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" />,
|
||||
)
|
||||
const titleElement = screen.getByText('Test Title')
|
||||
expect(titleElement).toHaveClass('system-xs-semibold-uppercase')
|
||||
})
|
||||
|
||||
it('should not use uppercase when uppercaseTitle is false', () => {
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={mockList} title="Test Title" uppercaseTitle={false} />,
|
||||
)
|
||||
const titleElement = screen.getByText('Test Title')
|
||||
expect(titleElement).toHaveClass('system-md-semibold')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value Display', () => {
|
||||
it('should display string value directly', () => {
|
||||
const stringList: MetadataItemWithValue[] = [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: 'Test Value' },
|
||||
]
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={stringList} />,
|
||||
)
|
||||
expect(screen.getByText('Test Value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display number value', () => {
|
||||
const numberList: MetadataItemWithValue[] = [
|
||||
{ id: '1', name: 'field', type: DataType.number, value: 123 },
|
||||
]
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={numberList} />,
|
||||
)
|
||||
expect(screen.getByText('123')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should format time value', () => {
|
||||
const timeList: MetadataItemWithValue[] = [
|
||||
{ id: '1', name: 'field', type: DataType.time, value: 1609459200 },
|
||||
]
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={timeList} />,
|
||||
)
|
||||
// The mock formatTime returns formatted date
|
||||
expect(screen.getByTestId('field-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty list', () => {
|
||||
const { container } = render(
|
||||
<InfoGroup dataSetId="ds-1" list={[]} />,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle null value in list', () => {
|
||||
const nullList: MetadataItemWithValue[] = [
|
||||
{ id: '1', name: 'field', type: DataType.string, value: null },
|
||||
]
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={nullList} />,
|
||||
)
|
||||
expect(screen.getByTestId('field')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle items with built-in id', () => {
|
||||
const builtInList: MetadataItemWithValue[] = [
|
||||
{ id: 'built-in', name: 'field', type: DataType.string, value: 'test' },
|
||||
]
|
||||
render(
|
||||
<InfoGroup dataSetId="ds-1" list={builtInList} />,
|
||||
)
|
||||
expect(screen.getByTestId('field')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,131 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import NoData from './no-data'
|
||||
|
||||
describe('NoData', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const handleStart = vi.fn()
|
||||
const { container } = render(<NoData onStart={handleStart} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with gradient background', () => {
|
||||
const handleStart = vi.fn()
|
||||
const { container } = render(<NoData onStart={handleStart} />)
|
||||
expect(container.firstChild).toHaveClass('rounded-xl', 'bg-gradient-to-r', 'p-4', 'pt-3')
|
||||
})
|
||||
|
||||
it('should render title text', () => {
|
||||
const handleStart = vi.fn()
|
||||
const { container } = render(<NoData onStart={handleStart} />)
|
||||
// Title should have correct styling
|
||||
const title = container.querySelector('.text-xs.font-semibold')
|
||||
expect(title).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description text', () => {
|
||||
const handleStart = vi.fn()
|
||||
const { container } = render(<NoData onStart={handleStart} />)
|
||||
// Description should have correct styling
|
||||
const description = container.querySelector('.system-xs-regular.mt-1')
|
||||
expect(description).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render start labeling button', () => {
|
||||
const handleStart = vi.fn()
|
||||
render(<NoData onStart={handleStart} />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render arrow icon in button', () => {
|
||||
const handleStart = vi.fn()
|
||||
const { container } = render(<NoData onStart={handleStart} />)
|
||||
// RiArrowRightLine icon should be present
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should accept onStart prop', () => {
|
||||
const handleStart = vi.fn()
|
||||
render(<NoData onStart={handleStart} />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onStart when button is clicked', () => {
|
||||
const handleStart = vi.fn()
|
||||
render(<NoData onStart={handleStart} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handleStart).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onStart multiple times on multiple clicks', () => {
|
||||
const handleStart = vi.fn()
|
||||
render(<NoData onStart={handleStart} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(handleStart).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should have been called when button is clicked', () => {
|
||||
const handleStart = vi.fn()
|
||||
render(<NoData onStart={handleStart} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// The onClick handler passes the event to onStart
|
||||
expect(handleStart).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button Styling', () => {
|
||||
it('should have primary variant button', () => {
|
||||
const handleStart = vi.fn()
|
||||
render(<NoData onStart={handleStart} />)
|
||||
const button = screen.getByRole('button')
|
||||
// Button should have primary styling
|
||||
expect(button).toHaveClass('mt-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout', () => {
|
||||
it('should have correct title styling', () => {
|
||||
const handleStart = vi.fn()
|
||||
const { container } = render(<NoData onStart={handleStart} />)
|
||||
const title = container.querySelector('.text-xs.font-semibold')
|
||||
expect(title).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct description styling', () => {
|
||||
const handleStart = vi.fn()
|
||||
const { container } = render(<NoData onStart={handleStart} />)
|
||||
const description = container.querySelector('.system-xs-regular.mt-1')
|
||||
expect(description).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle rapid clicks', () => {
|
||||
const handleStart = vi.fn()
|
||||
render(<NoData onStart={handleStart} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
for (let i = 0; i < 10; i++) {
|
||||
fireEvent.click(button)
|
||||
}
|
||||
|
||||
expect(handleStart).toHaveBeenCalledTimes(10)
|
||||
})
|
||||
})
|
||||
})
|
||||
45
web/app/components/datasets/metadata/utils/get-icon.spec.ts
Normal file
45
web/app/components/datasets/metadata/utils/get-icon.spec.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { RiHashtag, RiTextSnippet, RiTimeLine } from '@remixicon/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DataType } from '../types'
|
||||
import { getIcon } from './get-icon'
|
||||
|
||||
describe('getIcon', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should return RiTextSnippet for DataType.string', () => {
|
||||
const result = getIcon(DataType.string)
|
||||
expect(result).toBe(RiTextSnippet)
|
||||
})
|
||||
|
||||
it('should return RiHashtag for DataType.number', () => {
|
||||
const result = getIcon(DataType.number)
|
||||
expect(result).toBe(RiHashtag)
|
||||
})
|
||||
|
||||
it('should return RiTimeLine for DataType.time', () => {
|
||||
const result = getIcon(DataType.time)
|
||||
expect(result).toBe(RiTimeLine)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should return RiTextSnippet as fallback for unknown type', () => {
|
||||
const result = getIcon('unknown' as DataType)
|
||||
expect(result).toBe(RiTextSnippet)
|
||||
})
|
||||
|
||||
it('should return RiTextSnippet for undefined type', () => {
|
||||
const result = getIcon(undefined as unknown as DataType)
|
||||
expect(result).toBe(RiTextSnippet)
|
||||
})
|
||||
|
||||
it('should return RiTextSnippet for null type', () => {
|
||||
const result = getIcon(null as unknown as DataType)
|
||||
expect(result).toBe(RiTextSnippet)
|
||||
})
|
||||
|
||||
it('should return RiTextSnippet for empty string type', () => {
|
||||
const result = getIcon('' as DataType)
|
||||
expect(result).toBe(RiTextSnippet)
|
||||
})
|
||||
})
|
||||
})
|
||||
1173
web/app/components/datasets/rename-modal/index.spec.tsx
Normal file
1173
web/app/components/datasets/rename-modal/index.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,239 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useChunkStructure } from './hooks'
|
||||
import { EffectColor } from './types'
|
||||
|
||||
// Note: react-i18next is globally mocked in vitest.setup.ts
|
||||
|
||||
describe('useChunkStructure', () => {
|
||||
describe('Hook Initialization', () => {
|
||||
it('should return options array', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
expect(result.current.options).toBeDefined()
|
||||
expect(Array.isArray(result.current.options)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return exactly 3 options', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
expect(result.current.options).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('General Option', () => {
|
||||
it('should have correct id for General option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalOption = result.current.options[0]
|
||||
expect(generalOption.id).toBe('text_model')
|
||||
})
|
||||
|
||||
it('should have icon for General option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalOption = result.current.options[0]
|
||||
expect(generalOption.icon).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have correct iconActiveColor for General option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalOption = result.current.options[0]
|
||||
expect(generalOption.iconActiveColor).toBe('text-util-colors-indigo-indigo-600')
|
||||
})
|
||||
|
||||
it('should have title for General option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalOption = result.current.options[0]
|
||||
expect(generalOption.title).toBe('General')
|
||||
})
|
||||
|
||||
it('should have description for General option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalOption = result.current.options[0]
|
||||
expect(generalOption.description).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have indigo effectColor for General option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalOption = result.current.options[0]
|
||||
expect(generalOption.effectColor).toBe(EffectColor.indigo)
|
||||
})
|
||||
|
||||
it('should have showEffectColor true for General option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const generalOption = result.current.options[0]
|
||||
expect(generalOption.showEffectColor).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Parent-Child Option', () => {
|
||||
it('should have correct id for Parent-Child option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const parentChildOption = result.current.options[1]
|
||||
expect(parentChildOption.id).toBe('hierarchical_model')
|
||||
})
|
||||
|
||||
it('should have icon for Parent-Child option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const parentChildOption = result.current.options[1]
|
||||
expect(parentChildOption.icon).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have correct iconActiveColor for Parent-Child option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const parentChildOption = result.current.options[1]
|
||||
expect(parentChildOption.iconActiveColor).toBe('text-util-colors-blue-light-blue-light-500')
|
||||
})
|
||||
|
||||
it('should have title for Parent-Child option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const parentChildOption = result.current.options[1]
|
||||
expect(parentChildOption.title).toBe('Parent-Child')
|
||||
})
|
||||
|
||||
it('should have description for Parent-Child option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const parentChildOption = result.current.options[1]
|
||||
expect(parentChildOption.description).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have blueLight effectColor for Parent-Child option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const parentChildOption = result.current.options[1]
|
||||
expect(parentChildOption.effectColor).toBe(EffectColor.blueLight)
|
||||
})
|
||||
|
||||
it('should have showEffectColor true for Parent-Child option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const parentChildOption = result.current.options[1]
|
||||
expect(parentChildOption.showEffectColor).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Q&A Option', () => {
|
||||
it('should have correct id for Q&A option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const qaOption = result.current.options[2]
|
||||
expect(qaOption.id).toBe('qa_model')
|
||||
})
|
||||
|
||||
it('should have icon for Q&A option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const qaOption = result.current.options[2]
|
||||
expect(qaOption.icon).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have title for Q&A option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const qaOption = result.current.options[2]
|
||||
expect(qaOption.title).toBe('Q&A')
|
||||
})
|
||||
|
||||
it('should have description for Q&A option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const qaOption = result.current.options[2]
|
||||
expect(qaOption.description).toBeDefined()
|
||||
})
|
||||
|
||||
it('should not have effectColor for Q&A option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const qaOption = result.current.options[2]
|
||||
expect(qaOption.effectColor).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not have showEffectColor for Q&A option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const qaOption = result.current.options[2]
|
||||
expect(qaOption.showEffectColor).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not have iconActiveColor for Q&A option', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const qaOption = result.current.options[2]
|
||||
expect(qaOption.iconActiveColor).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Options Structure', () => {
|
||||
it('should return options in correct order', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
const ids = result.current.options.map(opt => opt.id)
|
||||
expect(ids).toEqual(['text_model', 'hierarchical_model', 'qa_model'])
|
||||
})
|
||||
|
||||
it('should return all options with required id property', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
result.current.options.forEach((option) => {
|
||||
expect(option.id).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return all options with required title property', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
result.current.options.forEach((option) => {
|
||||
expect(option.title).toBeDefined()
|
||||
expect(typeof option.title).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
it('should return all options with description property', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
result.current.options.forEach((option) => {
|
||||
expect(option.description).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return all options with icon property', () => {
|
||||
const { result } = renderHook(() => useChunkStructure())
|
||||
|
||||
result.current.options.forEach((option) => {
|
||||
expect(option.icon).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hook Stability', () => {
|
||||
it('should return consistent options on multiple renders', () => {
|
||||
const { result, rerender } = renderHook(() => useChunkStructure())
|
||||
|
||||
const firstRenderOptions = result.current.options.map(opt => opt.id)
|
||||
rerender()
|
||||
const secondRenderOptions = result.current.options.map(opt => opt.id)
|
||||
|
||||
expect(firstRenderOptions).toEqual(secondRenderOptions)
|
||||
})
|
||||
|
||||
it('should return options with stable structure', () => {
|
||||
const { result, rerender } = renderHook(() => useChunkStructure())
|
||||
|
||||
const firstLength = result.current.options.length
|
||||
rerender()
|
||||
const secondLength = result.current.options.length
|
||||
|
||||
expect(firstLength).toBe(secondLength)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -2,84 +2,124 @@ import { render, screen } from '@testing-library/react'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import ChunkStructure from './index'
|
||||
|
||||
type MockOptionCardProps = {
|
||||
id: string
|
||||
title: string
|
||||
isActive?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../option-card', () => ({
|
||||
default: ({ id, title, isActive, disabled }: MockOptionCardProps) => (
|
||||
<div
|
||||
data-testid="option-card"
|
||||
data-id={id}
|
||||
data-active={isActive}
|
||||
data-disabled={disabled}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock hook
|
||||
vi.mock('./hooks', () => ({
|
||||
useChunkStructure: () => ({
|
||||
options: [
|
||||
{
|
||||
id: ChunkingMode.text,
|
||||
title: 'General',
|
||||
description: 'General description',
|
||||
icon: <svg />,
|
||||
effectColor: 'indigo',
|
||||
iconActiveColor: 'indigo',
|
||||
},
|
||||
{
|
||||
id: ChunkingMode.parentChild,
|
||||
title: 'Parent-Child',
|
||||
description: 'PC description',
|
||||
icon: <svg />,
|
||||
effectColor: 'blue',
|
||||
iconActiveColor: 'blue',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}))
|
||||
// Note: react-i18next is globally mocked in vitest.setup.ts
|
||||
|
||||
describe('ChunkStructure', () => {
|
||||
it('should render all options', () => {
|
||||
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
expect(screen.getByText('General')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const options = screen.getAllByTestId('option-card')
|
||||
expect(options).toHaveLength(2)
|
||||
expect(options[0]).toHaveTextContent('General')
|
||||
expect(options[1]).toHaveTextContent('Parent-Child')
|
||||
it('should render all three options', () => {
|
||||
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
expect(screen.getByText('General')).toBeInTheDocument()
|
||||
expect(screen.getByText('Parent-Child')).toBeInTheDocument()
|
||||
expect(screen.getByText('Q&A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render in a vertical layout', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
const wrapper = container.firstChild
|
||||
expect(wrapper).toHaveClass('flex-col')
|
||||
})
|
||||
})
|
||||
|
||||
it('should set active state correctly', () => {
|
||||
// Render with 'text' active
|
||||
const { unmount } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
describe('Active State', () => {
|
||||
it('should mark General option as active when chunkStructure is text', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
// The active card has ring styling
|
||||
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
|
||||
const options = screen.getAllByTestId('option-card')
|
||||
expect(options[0]).toHaveAttribute('data-active', 'true')
|
||||
expect(options[1]).toHaveAttribute('data-active', 'false')
|
||||
it('should mark Parent-Child option as active when chunkStructure is parentChild', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
|
||||
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
// Render with 'parentChild' active
|
||||
render(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
|
||||
const newOptions = screen.getAllByTestId('option-card')
|
||||
expect(newOptions[0]).toHaveAttribute('data-active', 'false')
|
||||
expect(newOptions[1]).toHaveAttribute('data-active', 'true')
|
||||
it('should mark Q&A option as active when chunkStructure is qa', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.qa} />)
|
||||
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should be always disabled', () => {
|
||||
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
describe('Disabled State', () => {
|
||||
it('should render all options as disabled', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
// All cards should have cursor-not-allowed (disabled)
|
||||
const disabledCards = container.querySelectorAll('.cursor-not-allowed')
|
||||
expect(disabledCards.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
const options = screen.getAllByTestId('option-card')
|
||||
options.forEach((option) => {
|
||||
expect(option).toHaveAttribute('data-disabled', 'true')
|
||||
describe('Option Cards', () => {
|
||||
it('should render option cards with correct structure', () => {
|
||||
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
|
||||
// All options should have descriptions
|
||||
expect(screen.getByText(/stepTwo\.generalTip/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/stepTwo\.parentChildTip/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/stepTwo\.qaTip/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icons for all options', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
// Each option card should have an icon (SVG elements)
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs.length).toBeGreaterThanOrEqual(3) // At least 3 icons
|
||||
})
|
||||
})
|
||||
|
||||
describe('Effect Colors', () => {
|
||||
it('should show effect color for active General option', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
const effectElement = container.querySelector('.bg-util-colors-indigo-indigo-600')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show effect color for active Parent-Child option', () => {
|
||||
const { container } = render(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
|
||||
const effectElement = container.querySelector('.bg-util-colors-blue-light-blue-light-600')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should update active state when chunkStructure prop changes', () => {
|
||||
const { rerender, container } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
|
||||
// Initially one card is active
|
||||
let activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
|
||||
// Change to parentChild
|
||||
rerender(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
|
||||
|
||||
// Still one card should be active
|
||||
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
|
||||
// Change to qa
|
||||
rerender(<ChunkStructure chunkStructure={ChunkingMode.qa} />)
|
||||
|
||||
// Still one card should be active
|
||||
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration with useChunkStructure hook', () => {
|
||||
it('should use options from useChunkStructure hook', () => {
|
||||
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
|
||||
|
||||
// Verify all expected options are rendered
|
||||
const expectedTitles = ['General', 'Parent-Child', 'Q&A']
|
||||
expectedTitles.forEach((title) => {
|
||||
expect(screen.getByText(title)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
208
web/app/components/datasets/settings/index-method/index.spec.tsx
Normal file
208
web/app/components/datasets/settings/index-method/index.spec.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { IndexingType } from '../../create/step-two'
|
||||
import IndexMethod from './index'
|
||||
|
||||
// Note: react-i18next is globally mocked in vitest.setup.ts
|
||||
|
||||
describe('IndexMethod', () => {
|
||||
const defaultProps = {
|
||||
value: IndexingType.QUALIFIED,
|
||||
onChange: vi.fn(),
|
||||
keywordNumber: 10,
|
||||
onKeywordNumberChange: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render High Quality option', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Economy option', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getAllByText(/form\.indexMethodEconomy/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render High Quality description', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getByText(/form\.indexMethodHighQualityTip/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Economy description', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getByText(/form\.indexMethodEconomyTip/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render recommended badge on High Quality', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(screen.getByText(/stepTwo\.recommend/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Active State', () => {
|
||||
it('should mark High Quality as active when value is QUALIFIED', () => {
|
||||
const { container } = render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />)
|
||||
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should mark Economy as active when value is ECONOMICAL', () => {
|
||||
const { container } = render(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} />)
|
||||
const activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with QUALIFIED when High Quality is clicked', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} onChange={handleChange} />)
|
||||
|
||||
// Find and click High Quality option
|
||||
const highQualityTitle = screen.getByText(/stepTwo\.qualified/)
|
||||
const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(IndexingType.QUALIFIED)
|
||||
})
|
||||
|
||||
it('should call onChange with ECONOMICAL when Economy is clicked', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} currentValue={IndexingType.ECONOMICAL} />)
|
||||
|
||||
// Find and click Economy option - use getAllByText and get the first one (title)
|
||||
const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/)
|
||||
const economyTitle = economyTitles[0]
|
||||
const card = economyTitle.closest('div')?.parentElement?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(IndexingType.ECONOMICAL)
|
||||
})
|
||||
|
||||
it('should not call onChange when clicking already active option', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} onChange={handleChange} />)
|
||||
|
||||
// Click on already active High Quality
|
||||
const highQualityTitle = screen.getByText(/stepTwo\.qualified/)
|
||||
const card = highQualityTitle.closest('div')?.parentElement?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('should disable both options when disabled is true', () => {
|
||||
const { container } = render(<IndexMethod {...defaultProps} disabled={true} />)
|
||||
const disabledCards = container.querySelectorAll('.cursor-not-allowed')
|
||||
expect(disabledCards.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should disable Economy option when currentValue is QUALIFIED', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} currentValue={IndexingType.QUALIFIED} onChange={handleChange} value={IndexingType.ECONOMICAL} />)
|
||||
|
||||
// Try to click Economy option - use getAllByText and get the first one (title)
|
||||
const economyTitles = screen.getAllByText(/form\.indexMethodEconomy/)
|
||||
const economyTitle = economyTitles[0]
|
||||
const card = economyTitle.closest('div')?.parentElement?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
// Should not call onChange because Economy is disabled when current is QUALIFIED
|
||||
expect(handleChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('KeywordNumber', () => {
|
||||
it('should render KeywordNumber component inside Economy option', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
// KeywordNumber has a slider
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass keywordNumber to KeywordNumber component', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={25} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
})
|
||||
|
||||
it('should call onKeywordNumberChange when KeywordNumber changes', () => {
|
||||
const handleKeywordChange = vi.fn()
|
||||
render(<IndexMethod {...defaultProps} onKeywordNumberChange={handleKeywordChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '30' } })
|
||||
|
||||
expect(handleKeywordChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip', () => {
|
||||
it('should show tooltip when hovering over disabled Economy option', () => {
|
||||
// The tooltip is shown via PortalToFollowElem when hovering
|
||||
// This is controlled by useHover hook
|
||||
render(<IndexMethod {...defaultProps} currentValue={IndexingType.QUALIFIED} />)
|
||||
// The tooltip content should exist in DOM but may not be visible
|
||||
// We just verify the component renders without error
|
||||
expect(screen.getAllByText(/form\.indexMethodEconomy/).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Effect Colors', () => {
|
||||
it('should show orange effect color for High Quality option', () => {
|
||||
const { container } = render(<IndexMethod {...defaultProps} />)
|
||||
const orangeEffect = container.querySelector('.bg-util-colors-orange-orange-500')
|
||||
expect(orangeEffect).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show indigo effect color for Economy option', () => {
|
||||
const { container } = render(<IndexMethod {...defaultProps} />)
|
||||
const indigoEffect = container.querySelector('.bg-util-colors-indigo-indigo-600')
|
||||
expect(indigoEffect).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should update active state when value prop changes', () => {
|
||||
const { rerender, container } = render(<IndexMethod {...defaultProps} value={IndexingType.QUALIFIED} />)
|
||||
|
||||
let activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
|
||||
rerender(<IndexMethod {...defaultProps} value={IndexingType.ECONOMICAL} currentValue={IndexingType.ECONOMICAL} />)
|
||||
|
||||
activeCards = container.querySelectorAll('.ring-\\[1px\\]')
|
||||
expect(activeCards).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined currentValue', () => {
|
||||
render(<IndexMethod {...defaultProps} currentValue={undefined} />)
|
||||
// Should render without error
|
||||
expect(screen.getByText(/stepTwo\.qualified/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle keywordNumber of 0', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={0} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(0)
|
||||
})
|
||||
|
||||
it('should handle max keywordNumber', () => {
|
||||
render(<IndexMethod {...defaultProps} keywordNumber={50} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(50)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,171 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import KeyWordNumber from './keyword-number'
|
||||
|
||||
// Note: react-i18next is globally mocked in vitest.setup.ts
|
||||
|
||||
describe('KeyWordNumber', () => {
|
||||
const defaultProps = {
|
||||
keywordNumber: 10,
|
||||
onKeywordNumberChange: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label text', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
expect(screen.getByText(/form\.numberOfKeywords/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tooltip with question icon', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
// RiQuestionLine renders as an svg
|
||||
const container = screen.getByText(/form\.numberOfKeywords/).closest('div')?.parentElement
|
||||
const questionIcon = container?.querySelector('svg')
|
||||
expect(questionIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render slider', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
// Slider has a slider role
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render input number field', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display correct keywordNumber value in input', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
})
|
||||
|
||||
it('should display different keywordNumber values', () => {
|
||||
const values = [1, 10, 25, 50]
|
||||
|
||||
values.forEach((value) => {
|
||||
const { unmount } = render(<KeyWordNumber {...defaultProps} keywordNumber={value} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(value)
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass correct value to slider', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={30} />)
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should render slider that accepts onChange', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
// Verify slider is rendered and interactive
|
||||
expect(slider).toBeInTheDocument()
|
||||
expect(slider).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should call onKeywordNumberChange when input value changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '30' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onKeywordNumberChange with undefined value', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
// When value is empty/undefined, handleInputChange should not call onKeywordNumberChange
|
||||
expect(handleChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Slider Configuration', () => {
|
||||
it('should have max value of 50', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '50')
|
||||
})
|
||||
|
||||
it('should have min value of 0', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle minimum value (0)', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={0} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(0)
|
||||
})
|
||||
|
||||
it('should handle maximum value (50)', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={50} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(50)
|
||||
})
|
||||
|
||||
it('should handle value updates correctly', () => {
|
||||
const { rerender } = render(<KeyWordNumber {...defaultProps} keywordNumber={10} />)
|
||||
|
||||
let input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(10)
|
||||
|
||||
rerender(<KeyWordNumber {...defaultProps} keywordNumber={25} />)
|
||||
input = screen.getByRole('spinbutton')
|
||||
expect(input).toHaveValue(25)
|
||||
})
|
||||
|
||||
it('should handle rapid value changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
|
||||
// Simulate rapid changes via input with different values
|
||||
fireEvent.change(input, { target: { value: '15' } })
|
||||
fireEvent.change(input, { target: { value: '25' } })
|
||||
fireEvent.change(input, { target: { value: '35' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible slider', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have accessible input', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
317
web/app/components/datasets/settings/option-card.spec.tsx
Normal file
317
web/app/components/datasets/settings/option-card.spec.tsx
Normal file
@ -0,0 +1,317 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { EffectColor } from './chunk-structure/types'
|
||||
import OptionCard from './option-card'
|
||||
|
||||
// Note: react-i18next is globally mocked in vitest.setup.ts
|
||||
|
||||
describe('OptionCard', () => {
|
||||
const defaultProps = {
|
||||
id: 'test-id',
|
||||
title: 'Test Title',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<OptionCard {...defaultProps} />)
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title', () => {
|
||||
render(<OptionCard {...defaultProps} title="Custom Title" />)
|
||||
expect(screen.getByText('Custom Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when provided', () => {
|
||||
render(<OptionCard {...defaultProps} description="Test Description" />)
|
||||
expect(screen.getByText('Test Description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render description when not provided', () => {
|
||||
render(<OptionCard {...defaultProps} />)
|
||||
expect(screen.queryByText(/description/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon when provided', () => {
|
||||
render(<OptionCard {...defaultProps} icon={<span data-testid="test-icon">Icon</span>} />)
|
||||
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render icon container when icon is not provided', () => {
|
||||
const { container } = render(<OptionCard {...defaultProps} />)
|
||||
const iconContainers = container.querySelectorAll('.size-6')
|
||||
expect(iconContainers).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Active State', () => {
|
||||
it('should apply active styles when isActive is true', () => {
|
||||
const { container } = render(<OptionCard {...defaultProps} isActive={true} />)
|
||||
const card = container.firstChild
|
||||
expect(card).toHaveClass('ring-[1px]')
|
||||
})
|
||||
|
||||
it('should not apply active styles when isActive is false', () => {
|
||||
const { container } = render(<OptionCard {...defaultProps} isActive={false} />)
|
||||
const card = container.firstChild
|
||||
expect(card).not.toHaveClass('ring-[1px]')
|
||||
})
|
||||
|
||||
it('should apply iconActiveColor when isActive is true and icon is present', () => {
|
||||
const { container } = render(
|
||||
<OptionCard
|
||||
{...defaultProps}
|
||||
isActive={true}
|
||||
icon={<span>Icon</span>}
|
||||
iconActiveColor="text-red-500"
|
||||
/>,
|
||||
)
|
||||
const iconContainer = container.querySelector('.text-red-500')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('should apply disabled styles when disabled is true', () => {
|
||||
const { container } = render(<OptionCard {...defaultProps} disabled={true} />)
|
||||
const card = container.firstChild
|
||||
expect(card).toHaveClass('cursor-not-allowed')
|
||||
expect(card).toHaveClass('opacity-50')
|
||||
})
|
||||
|
||||
it('should not call onClick when disabled', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<OptionCard {...defaultProps} disabled={true} onClick={handleClick} />)
|
||||
|
||||
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onClick when isActive', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<OptionCard {...defaultProps} isActive={true} onClick={handleClick} />)
|
||||
|
||||
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Recommended Badge', () => {
|
||||
it('should render recommended badge when isRecommended is true', () => {
|
||||
render(<OptionCard {...defaultProps} isRecommended={true} />)
|
||||
// Badge uses translation key
|
||||
expect(screen.getByText(/stepTwo\.recommend/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render recommended badge when isRecommended is false', () => {
|
||||
render(<OptionCard {...defaultProps} isRecommended={false} />)
|
||||
expect(screen.queryByText(/stepTwo\.recommend/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Effect Color', () => {
|
||||
it('should render effect color when effectColor and showEffectColor are provided', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} effectColor={EffectColor.indigo} showEffectColor={true} />,
|
||||
)
|
||||
const effectElement = container.querySelector('.blur-\\[80px\\]')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render effect color when showEffectColor is false', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} effectColor={EffectColor.indigo} showEffectColor={false} />,
|
||||
)
|
||||
const effectElement = container.querySelector('.blur-\\[80px\\]')
|
||||
expect(effectElement).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render effect color when effectColor is not provided', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} showEffectColor={true} />,
|
||||
)
|
||||
const effectElement = container.querySelector('.blur-\\[80px\\]')
|
||||
expect(effectElement).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply indigo effect color class', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} effectColor={EffectColor.indigo} showEffectColor={true} />,
|
||||
)
|
||||
const effectElement = container.querySelector('.bg-util-colors-indigo-indigo-600')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply blueLight effect color class', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} effectColor={EffectColor.blueLight} showEffectColor={true} />,
|
||||
)
|
||||
const effectElement = container.querySelector('.bg-util-colors-blue-light-blue-light-600')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply orange effect color class', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} effectColor={EffectColor.orange} showEffectColor={true} />,
|
||||
)
|
||||
const effectElement = container.querySelector('.bg-util-colors-orange-orange-500')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply purple effect color class', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} effectColor={EffectColor.purple} showEffectColor={true} />,
|
||||
)
|
||||
const effectElement = container.querySelector('.bg-util-colors-purple-purple-600')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Children', () => {
|
||||
it('should render children when children and showChildren are provided', () => {
|
||||
render(
|
||||
<OptionCard {...defaultProps} showChildren={true}>
|
||||
<div data-testid="child-content">Child Content</div>
|
||||
</OptionCard>,
|
||||
)
|
||||
expect(screen.getByTestId('child-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render children when showChildren is false', () => {
|
||||
render(
|
||||
<OptionCard {...defaultProps} showChildren={false}>
|
||||
<div data-testid="child-content">Child Content</div>
|
||||
</OptionCard>,
|
||||
)
|
||||
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render children container when children is not provided', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} showChildren={true} />,
|
||||
)
|
||||
const childContainer = container.querySelector('.bg-components-panel-bg')
|
||||
expect(childContainer).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render arrow shape when children are shown', () => {
|
||||
const { container } = render(
|
||||
<OptionCard {...defaultProps} showChildren={true}>
|
||||
<div>Child</div>
|
||||
</OptionCard>,
|
||||
)
|
||||
// ArrowShape renders an SVG
|
||||
const childSection = container.querySelector('.bg-components-panel-bg')
|
||||
expect(childSection).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick with id when clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<OptionCard {...defaultProps} id="my-id" onClick={handleClick} />)
|
||||
|
||||
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledWith('my-id')
|
||||
})
|
||||
|
||||
it('should have cursor-pointer class', () => {
|
||||
const { container } = render(<OptionCard {...defaultProps} />)
|
||||
const card = container.firstChild
|
||||
expect(card).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<OptionCard {...defaultProps} className="custom-class" />)
|
||||
const innerContainer = container.querySelector('.custom-class')
|
||||
expect(innerContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should forward ref', () => {
|
||||
const ref = vi.fn()
|
||||
render(<OptionCard {...defaultProps} ref={ref} />)
|
||||
expect(ref).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty title', () => {
|
||||
render(<OptionCard {...defaultProps} title="" />)
|
||||
// Component should still render
|
||||
const { container } = render(<OptionCard {...defaultProps} title="" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle complex id types', () => {
|
||||
const handleClick = vi.fn()
|
||||
const complexId = { key: 'value' }
|
||||
render(<OptionCard {...defaultProps} id={complexId} onClick={handleClick} />)
|
||||
|
||||
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledWith(complexId)
|
||||
})
|
||||
|
||||
it('should handle numeric id', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<OptionCard {...defaultProps} id={123} onClick={handleClick} />)
|
||||
|
||||
const card = screen.getByText('Test Title').closest('div')?.parentElement?.parentElement
|
||||
fireEvent.click(card!)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledWith(123)
|
||||
})
|
||||
|
||||
it('should handle long title', () => {
|
||||
const longTitle = 'A'.repeat(200)
|
||||
render(<OptionCard {...defaultProps} title={longTitle} />)
|
||||
expect(screen.getByText(longTitle)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long description', () => {
|
||||
const longDesc = 'B'.repeat(500)
|
||||
render(<OptionCard {...defaultProps} description={longDesc} />)
|
||||
expect(screen.getByText(longDesc)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle all props together', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(
|
||||
<OptionCard
|
||||
id="full-test"
|
||||
title="Full Test"
|
||||
description="Full Description"
|
||||
icon={<span data-testid="full-icon">Icon</span>}
|
||||
iconActiveColor="text-blue-500"
|
||||
isActive={true}
|
||||
isRecommended={true}
|
||||
effectColor={EffectColor.indigo}
|
||||
showEffectColor={true}
|
||||
disabled={false}
|
||||
onClick={handleClick}
|
||||
className="full-class"
|
||||
showChildren={true}
|
||||
>
|
||||
<div data-testid="full-children">Children</div>
|
||||
</OptionCard>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Full Test')).toBeInTheDocument()
|
||||
expect(screen.getByText('Full Description')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('full-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('full-children')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,512 @@
|
||||
import type { Member } from '@/models/common'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { DatasetPermission } from '@/models/datasets'
|
||||
import PermissionSelector from './index'
|
||||
|
||||
// Mock app-context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: () => ({
|
||||
id: 'user-1',
|
||||
name: 'Current User',
|
||||
email: 'current@example.com',
|
||||
avatar_url: '',
|
||||
role: 'owner',
|
||||
}),
|
||||
}))
|
||||
|
||||
// Note: react-i18next is globally mocked in vitest.setup.ts
|
||||
|
||||
describe('PermissionSelector', () => {
|
||||
const mockMemberList: Member[] = [
|
||||
{ id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', last_login_at: '', created_at: '', status: 'active' },
|
||||
{ id: 'user-2', name: 'John Doe', email: 'john@example.com', avatar: '', avatar_url: '', role: 'admin', last_login_at: '', created_at: '', status: 'active' },
|
||||
{ id: 'user-3', name: 'Jane Smith', email: 'jane@example.com', avatar: '', avatar_url: '', role: 'editor', last_login_at: '', created_at: '', status: 'active' },
|
||||
{ id: 'user-4', name: 'Dataset Operator', email: 'operator@example.com', avatar: '', avatar_url: '', role: 'dataset_operator', last_login_at: '', created_at: '', status: 'active' },
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
permission: DatasetPermission.onlyMe,
|
||||
value: ['user-1'],
|
||||
memberList: mockMemberList,
|
||||
onChange: vi.fn(),
|
||||
onMemberSelect: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<PermissionSelector {...defaultProps} />)
|
||||
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Only Me option when permission is onlyMe', () => {
|
||||
render(<PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />)
|
||||
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render All Team Members option when permission is allTeamMembers', () => {
|
||||
render(<PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />)
|
||||
expect(screen.getByText(/form\.permissionsAllMember/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render selected member names when permission is partialMembers', () => {
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
value={['user-1', 'user-2']}
|
||||
/>,
|
||||
)
|
||||
// Should show member names
|
||||
expect(screen.getByTitle(/Current User/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dropdown Toggle', () => {
|
||||
it('should open dropdown when clicked', async () => {
|
||||
render(<PermissionSelector {...defaultProps} />)
|
||||
|
||||
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show all permission options in dropdown
|
||||
expect(screen.getAllByText(/form\.permissionsOnlyMe/).length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not open dropdown when disabled', () => {
|
||||
render(<PermissionSelector {...defaultProps} disabled={true} />)
|
||||
|
||||
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Dropdown should not open - only the trigger text should be visible
|
||||
expect(screen.getAllByText(/form\.permissionsOnlyMe/).length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission Selection', () => {
|
||||
it('should call onChange with onlyMe when Only Me is selected', async () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<PermissionSelector {...defaultProps} onChange={handleChange} permission={DatasetPermission.allTeamMembers} />)
|
||||
|
||||
// Open dropdown
|
||||
const trigger = screen.getByText(/form\.permissionsAllMember/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
// Click Only Me option
|
||||
const onlyMeOptions = screen.getAllByText(/form\.permissionsOnlyMe/)
|
||||
fireEvent.click(onlyMeOptions[0])
|
||||
})
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(DatasetPermission.onlyMe)
|
||||
})
|
||||
|
||||
it('should call onChange with allTeamMembers when All Team Members is selected', async () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<PermissionSelector {...defaultProps} onChange={handleChange} />)
|
||||
|
||||
// Open dropdown
|
||||
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
// Click All Team Members option
|
||||
const allMemberOptions = screen.getAllByText(/form\.permissionsAllMember/)
|
||||
fireEvent.click(allMemberOptions[0])
|
||||
})
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(DatasetPermission.allTeamMembers)
|
||||
})
|
||||
|
||||
it('should call onChange with partialMembers when Invited Members is selected', async () => {
|
||||
const handleChange = vi.fn()
|
||||
const handleMemberSelect = vi.fn()
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
onChange={handleChange}
|
||||
onMemberSelect={handleMemberSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open dropdown
|
||||
const trigger = screen.getByText(/form\.permissionsOnlyMe/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
// Click Invited Members option
|
||||
const invitedOptions = screen.getAllByText(/form\.permissionsInvitedMembers/)
|
||||
fireEvent.click(invitedOptions[0])
|
||||
})
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith(DatasetPermission.partialMembers)
|
||||
expect(handleMemberSelect).toHaveBeenCalledWith(['user-1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Member Selection', () => {
|
||||
it('should show member list when partialMembers is selected', async () => {
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open dropdown
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show member list
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
||||
expect(screen.getByText('Jane Smith')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onMemberSelect when a member is clicked', async () => {
|
||||
const handleMemberSelect = vi.fn()
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
onMemberSelect={handleMemberSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open dropdown
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
// Click on John Doe
|
||||
const johnDoe = screen.getByText('John Doe')
|
||||
fireEvent.click(johnDoe)
|
||||
})
|
||||
|
||||
expect(handleMemberSelect).toHaveBeenCalledWith(['user-1', 'user-2'])
|
||||
})
|
||||
|
||||
it('should deselect member when clicked again', async () => {
|
||||
const handleMemberSelect = vi.fn()
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
value={['user-1', 'user-2']}
|
||||
onMemberSelect={handleMemberSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open dropdown
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
// Click on John Doe to deselect
|
||||
const johnDoe = screen.getByText('John Doe')
|
||||
fireEvent.click(johnDoe)
|
||||
})
|
||||
|
||||
expect(handleMemberSelect).toHaveBeenCalledWith(['user-1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should allow typing in search input', async () => {
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open dropdown
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
|
||||
// Type in search
|
||||
fireEvent.change(searchInput, { target: { value: 'John' } })
|
||||
expect(searchInput).toHaveValue('John')
|
||||
})
|
||||
|
||||
it('should render search input in partial members mode', async () => {
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open dropdown
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open and search input to be available
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
expect(searchInput).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter members after debounce completes', async () => {
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open dropdown
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
|
||||
// Type in search
|
||||
fireEvent.change(searchInput, { target: { value: 'John' } })
|
||||
|
||||
// Wait for debounce (500ms) + buffer
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle clear search functionality', async () => {
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open dropdown
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
|
||||
// Type in search
|
||||
fireEvent.change(searchInput, { target: { value: 'test' } })
|
||||
expect(searchInput).toHaveValue('test')
|
||||
|
||||
// Click the clear button using data-testid
|
||||
const clearButton = screen.getByTestId('input-clear')
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
// After clicking clear, input should be empty
|
||||
await waitFor(() => {
|
||||
expect(searchInput).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
it('should filter members by email', async () => {
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open dropdown
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
|
||||
// Search by email
|
||||
fireEvent.change(searchInput, { target: { value: 'john@example' } })
|
||||
|
||||
// Wait for debounce
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('should show no results message when search matches nothing', async () => {
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open dropdown
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
|
||||
// Search for non-existent member
|
||||
fireEvent.change(searchInput, { target: { value: 'nonexistent12345' } })
|
||||
|
||||
// Wait for debounce and no results message
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText(/form\.onSearchResults/)).toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('should show current user when search matches user name', async () => {
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open dropdown
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
|
||||
// Search for current user by name - partial match
|
||||
fireEvent.change(searchInput, { target: { value: 'Current' } })
|
||||
|
||||
// Current user (showMe) should remain visible based on name match
|
||||
// The component uses useMemo to check if userProfile.name.includes(searchKeywords)
|
||||
expect(searchInput).toHaveValue('Current')
|
||||
// Current User label appears multiple times (trigger + member list)
|
||||
expect(screen.getAllByText('Current User').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should show current user when search matches user email', async () => {
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open dropdown
|
||||
const trigger = screen.getByTitle(/Current User/)
|
||||
fireEvent.click(trigger)
|
||||
|
||||
// Wait for dropdown to open
|
||||
const searchInput = await screen.findByRole('textbox')
|
||||
|
||||
// Search for current user by email
|
||||
fireEvent.change(searchInput, { target: { value: 'current@' } })
|
||||
|
||||
// The component checks userProfile.email.includes(searchKeywords)
|
||||
expect(searchInput).toHaveValue('current@')
|
||||
// Current User should remain visible based on email match
|
||||
expect(screen.getAllByText('Current User').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('should apply disabled styles when disabled', () => {
|
||||
const { container } = render(<PermissionSelector {...defaultProps} disabled={true} />)
|
||||
// When disabled, the component has !cursor-not-allowed class (escaped in Tailwind)
|
||||
const triggerElement = container.querySelector('[class*="cursor-not-allowed"]')
|
||||
expect(triggerElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Display Variations', () => {
|
||||
it('should display single avatar when only one member selected', () => {
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
value={['user-1']}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should display single avatar
|
||||
expect(screen.getByTitle(/Current User/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display two avatars when two or more members selected', () => {
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
value={['user-1', 'user-2']}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should display member names
|
||||
expect(screen.getByTitle(/Current User, John Doe/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty member list', () => {
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
memberList={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle member list with only current user', () => {
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
memberList={[mockMemberList[0]]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should only show members with allowed roles', () => {
|
||||
// The component filters members by role in useMemo
|
||||
// Allowed roles are: owner, admin, editor, dataset_operator
|
||||
// This is tested indirectly through the memberList filtering
|
||||
const memberListWithNormalUser: Member[] = [
|
||||
...mockMemberList,
|
||||
{ id: 'user-5', name: 'Normal User', email: 'normal@example.com', avatar: '', avatar_url: '', role: 'normal', last_login_at: '', created_at: '', status: 'active' },
|
||||
]
|
||||
|
||||
render(
|
||||
<PermissionSelector
|
||||
{...defaultProps}
|
||||
memberList={memberListWithNormalUser}
|
||||
permission={DatasetPermission.partialMembers}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The component renders - the filtering logic is internal
|
||||
expect(screen.getByTitle(/Current User/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should update when permission prop changes', () => {
|
||||
const { rerender } = render(<PermissionSelector {...defaultProps} permission={DatasetPermission.onlyMe} />)
|
||||
|
||||
expect(screen.getByText(/form\.permissionsOnlyMe/)).toBeInTheDocument()
|
||||
|
||||
rerender(<PermissionSelector {...defaultProps} permission={DatasetPermission.allTeamMembers} />)
|
||||
|
||||
expect(screen.getByText(/form\.permissionsAllMember/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,195 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import MemberItem from './member-item'
|
||||
|
||||
// Note: react-i18next is globally mocked in vitest.setup.ts
|
||||
|
||||
describe('MemberItem', () => {
|
||||
const defaultProps = {
|
||||
leftIcon: <span data-testid="avatar-icon">Avatar</span>,
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
isSelected: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<MemberItem {...defaultProps} />)
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render left icon (avatar)', () => {
|
||||
render(<MemberItem {...defaultProps} />)
|
||||
expect(screen.getByTestId('avatar-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render member name', () => {
|
||||
render(<MemberItem {...defaultProps} />)
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render member email', () => {
|
||||
render(<MemberItem {...defaultProps} />)
|
||||
expect(screen.getByText('john@example.com')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection State', () => {
|
||||
it('should show checkmark icon when selected', () => {
|
||||
render(<MemberItem {...defaultProps} isSelected={true} />)
|
||||
const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
|
||||
const checkIcon = container?.querySelector('svg')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show checkmark icon when not selected', () => {
|
||||
render(<MemberItem {...defaultProps} isSelected={false} />)
|
||||
const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
|
||||
const checkIcon = container?.querySelector('svg')
|
||||
expect(checkIcon).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply opacity class to checkmark when isMe is true', () => {
|
||||
render(<MemberItem {...defaultProps} isSelected={true} isMe={true} />)
|
||||
const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
|
||||
const checkIcon = container?.querySelector('svg')
|
||||
expect(checkIcon).toHaveClass('opacity-30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMe Flag', () => {
|
||||
it('should show me indicator when isMe is true', () => {
|
||||
render(<MemberItem {...defaultProps} isMe={true} />)
|
||||
// The translation key is 'form.me' which will be rendered by the mock
|
||||
expect(screen.getByText(/form\.me/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show me indicator when isMe is false', () => {
|
||||
render(<MemberItem {...defaultProps} isMe={false} />)
|
||||
expect(screen.queryByText(/form\.me/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show me indicator by default', () => {
|
||||
render(<MemberItem {...defaultProps} />)
|
||||
expect(screen.queryByText(/form\.me/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<MemberItem {...defaultProps} onClick={handleClick} />)
|
||||
|
||||
const item = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
|
||||
fireEvent.click(item!)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not throw when onClick is not provided', () => {
|
||||
render(<MemberItem {...defaultProps} />)
|
||||
|
||||
const item = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
|
||||
expect(() => fireEvent.click(item!)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should have cursor-pointer class for interactivity', () => {
|
||||
render(<MemberItem {...defaultProps} />)
|
||||
const item = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
|
||||
expect(item).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should render different names', () => {
|
||||
const names = ['Alice', 'Bob', 'Charlie']
|
||||
|
||||
names.forEach((name) => {
|
||||
const { unmount } = render(<MemberItem {...defaultProps} name={name} />)
|
||||
expect(screen.getByText(name)).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render different emails', () => {
|
||||
const emails = ['alice@test.com', 'bob@company.org', 'charlie@domain.net']
|
||||
|
||||
emails.forEach((email) => {
|
||||
const { unmount } = render(<MemberItem {...defaultProps} email={email} />)
|
||||
expect(screen.getByText(email)).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render different left icons', () => {
|
||||
const customIcon = <img data-testid="custom-avatar" alt="avatar" />
|
||||
render(<MemberItem {...defaultProps} leftIcon={customIcon} />)
|
||||
expect(screen.getByTestId('custom-avatar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle isSelected toggle correctly', () => {
|
||||
const { rerender } = render(<MemberItem {...defaultProps} isSelected={false} />)
|
||||
|
||||
// Initially not selected
|
||||
let container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
|
||||
expect(container?.querySelector('svg')).not.toBeInTheDocument()
|
||||
|
||||
// Update to selected
|
||||
rerender(<MemberItem {...defaultProps} isSelected={true} />)
|
||||
container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
|
||||
expect(container?.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty name', () => {
|
||||
render(<MemberItem {...defaultProps} name="" />)
|
||||
expect(screen.getByText('john@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty email', () => {
|
||||
render(<MemberItem {...defaultProps} email="" />)
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long name with truncation', () => {
|
||||
const longName = 'A'.repeat(100)
|
||||
render(<MemberItem {...defaultProps} name={longName} />)
|
||||
const nameElement = screen.getByText(longName)
|
||||
expect(nameElement).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('should handle long email with truncation', () => {
|
||||
const longEmail = `${'a'.repeat(50)}@${'b'.repeat(50)}.com`
|
||||
render(<MemberItem {...defaultProps} email={longEmail} />)
|
||||
const emailElement = screen.getByText(longEmail)
|
||||
expect(emailElement).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('should handle special characters in name', () => {
|
||||
const specialName = 'O\'Connor-Smith'
|
||||
render(<MemberItem {...defaultProps} name={specialName} />)
|
||||
expect(screen.getByText(specialName)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
const unicodeName = '张三'
|
||||
const unicodeEmail = '张三@example.com'
|
||||
render(<MemberItem {...defaultProps} name={unicodeName} email={unicodeEmail} />)
|
||||
expect(screen.getByText(unicodeName)).toBeInTheDocument()
|
||||
expect(screen.getByText(unicodeEmail)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both isMe and isSelected together', () => {
|
||||
render(<MemberItem {...defaultProps} isMe={true} isSelected={true} />)
|
||||
expect(screen.getByText(/form\.me/)).toBeInTheDocument()
|
||||
const container = screen.getByText('John Doe').closest('div')?.parentElement?.parentElement
|
||||
const checkIcon = container?.querySelector('svg')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
expect(checkIcon).toHaveClass('opacity-30')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,130 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import PermissionItem from './permission-item'
|
||||
|
||||
describe('PermissionItem', () => {
|
||||
const defaultProps = {
|
||||
leftIcon: <span data-testid="left-icon">Icon</span>,
|
||||
text: 'Test Permission',
|
||||
onClick: vi.fn(),
|
||||
isSelected: false,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<PermissionItem {...defaultProps} />)
|
||||
expect(screen.getByText('Test Permission')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render left icon', () => {
|
||||
render(<PermissionItem {...defaultProps} />)
|
||||
expect(screen.getByTestId('left-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text content', () => {
|
||||
const text = 'Custom Permission Text'
|
||||
render(<PermissionItem {...defaultProps} text={text} />)
|
||||
expect(screen.getByText(text)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection State', () => {
|
||||
it('should show checkmark icon when selected', () => {
|
||||
render(<PermissionItem {...defaultProps} isSelected={true} />)
|
||||
// RiCheckLine renders as an svg element
|
||||
const container = screen.getByText('Test Permission').closest('div')?.parentElement
|
||||
const checkIcon = container?.querySelector('svg')
|
||||
expect(checkIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show checkmark icon when not selected', () => {
|
||||
render(<PermissionItem {...defaultProps} isSelected={false} />)
|
||||
const container = screen.getByText('Test Permission').closest('div')?.parentElement
|
||||
const checkIcon = container?.querySelector('svg')
|
||||
expect(checkIcon).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<PermissionItem {...defaultProps} onClick={handleClick} />)
|
||||
|
||||
const item = screen.getByText('Test Permission').closest('div')?.parentElement
|
||||
fireEvent.click(item!)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should have cursor-pointer class for interactivity', () => {
|
||||
render(<PermissionItem {...defaultProps} />)
|
||||
const item = screen.getByText('Test Permission').closest('div')?.parentElement
|
||||
expect(item).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should render different left icons', () => {
|
||||
const customIcon = <span data-testid="custom-icon">Custom</span>
|
||||
render(<PermissionItem {...defaultProps} leftIcon={customIcon} />)
|
||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle different text values', () => {
|
||||
const texts = ['Only Me', 'All Team Members', 'Invited Members']
|
||||
|
||||
texts.forEach((text) => {
|
||||
const { unmount } = render(<PermissionItem {...defaultProps} text={text} />)
|
||||
expect(screen.getByText(text)).toBeInTheDocument()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle isSelected toggle correctly', () => {
|
||||
const { rerender } = render(<PermissionItem {...defaultProps} isSelected={false} />)
|
||||
|
||||
// Initially not selected - no checkmark
|
||||
let container = screen.getByText('Test Permission').closest('div')?.parentElement
|
||||
expect(container?.querySelector('svg')).not.toBeInTheDocument()
|
||||
|
||||
// Update to selected
|
||||
rerender(<PermissionItem {...defaultProps} isSelected={true} />)
|
||||
container = screen.getByText('Test Permission').closest('div')?.parentElement
|
||||
expect(container?.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty text', () => {
|
||||
render(<PermissionItem {...defaultProps} text="" />)
|
||||
// The component should still render
|
||||
expect(screen.getByTestId('left-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long text content', () => {
|
||||
const longText = 'A'.repeat(200)
|
||||
render(<PermissionItem {...defaultProps} text={longText} />)
|
||||
expect(screen.getByText(longText)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in text', () => {
|
||||
const specialText = '<script>alert("xss")</script>'
|
||||
render(<PermissionItem {...defaultProps} text={specialText} />)
|
||||
expect(screen.getByText(specialText)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle complex left icon nodes', () => {
|
||||
const complexIcon = (
|
||||
<div data-testid="complex-icon">
|
||||
<span>Nested</span>
|
||||
<div>Content</div>
|
||||
</div>
|
||||
)
|
||||
render(<PermissionItem {...defaultProps} leftIcon={complexIcon} />)
|
||||
expect(screen.getByTestId('complex-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
297
web/app/components/datasets/settings/utils/index.spec.ts
Normal file
297
web/app/components/datasets/settings/utils/index.spec.ts
Normal file
@ -0,0 +1,297 @@
|
||||
import type { DefaultModel, Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { IndexingType } from '../../create/step-two'
|
||||
import { checkShowMultiModalTip } from './index'
|
||||
|
||||
describe('checkShowMultiModalTip', () => {
|
||||
// Helper to create a model item with specific features
|
||||
const createModelItem = (model: string, features: ModelFeatureEnum[] = []): ModelItem => ({
|
||||
model,
|
||||
label: { en_US: model, zh_Hans: model },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
features,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
deprecated: false,
|
||||
})
|
||||
|
||||
// Helper to create a model provider
|
||||
const createModelProvider = (provider: string, models: ModelItem[]): Model => ({
|
||||
provider,
|
||||
label: { en_US: provider, zh_Hans: provider },
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
status: ModelStatusEnum.active,
|
||||
models,
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
embeddingModel: {
|
||||
provider: 'openai',
|
||||
model: 'text-embedding-ada-002',
|
||||
} as DefaultModel,
|
||||
rerankingEnable: true,
|
||||
rerankModel: {
|
||||
rerankingProviderName: 'cohere',
|
||||
rerankingModelName: 'rerank-english-v2.0',
|
||||
},
|
||||
indexMethod: IndexingType.QUALIFIED,
|
||||
embeddingModelList: [
|
||||
createModelProvider('openai', [
|
||||
createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]),
|
||||
]),
|
||||
],
|
||||
rerankModelList: [
|
||||
createModelProvider('cohere', [
|
||||
createModelItem('rerank-english-v2.0', []),
|
||||
]),
|
||||
],
|
||||
}
|
||||
|
||||
describe('Return false conditions', () => {
|
||||
it('should return false when indexMethod is not QUALIFIED', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
indexMethod: IndexingType.ECONOMICAL,
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when indexMethod is undefined', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
indexMethod: undefined,
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when embeddingModel.provider is empty', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModel: { provider: '', model: 'text-embedding-ada-002' },
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when embeddingModel.model is empty', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModel: { provider: 'openai', model: '' },
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when embedding model provider is not found', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModel: { provider: 'unknown-provider', model: 'text-embedding-ada-002' },
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when embedding model is not found in provider', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModel: { provider: 'openai', model: 'unknown-model' },
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when embedding model does not support vision', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModelList: [
|
||||
createModelProvider('openai', [
|
||||
createModelItem('text-embedding-ada-002', []), // No vision feature
|
||||
]),
|
||||
],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when rerankingEnable is false', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
rerankingEnable: false,
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when rerankingModelName is empty', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
rerankModel: {
|
||||
rerankingProviderName: 'cohere',
|
||||
rerankingModelName: '',
|
||||
},
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when rerankingProviderName is empty', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
rerankModel: {
|
||||
rerankingProviderName: '',
|
||||
rerankingModelName: 'rerank-english-v2.0',
|
||||
},
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when reranking model provider is not found', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
rerankModel: {
|
||||
rerankingProviderName: 'unknown-provider',
|
||||
rerankingModelName: 'rerank-english-v2.0',
|
||||
},
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when reranking model is not found in provider', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
rerankModel: {
|
||||
rerankingProviderName: 'cohere',
|
||||
rerankingModelName: 'unknown-model',
|
||||
},
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when reranking model supports vision', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
rerankModelList: [
|
||||
createModelProvider('cohere', [
|
||||
createModelItem('rerank-english-v2.0', [ModelFeatureEnum.vision]), // Has vision feature
|
||||
]),
|
||||
],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Return true condition', () => {
|
||||
it('should return true when embedding model supports vision but reranking model does not', () => {
|
||||
const result = checkShowMultiModalTip(defaultProps)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true with different providers', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModel: { provider: 'azure', model: 'azure-embedding' },
|
||||
rerankModel: {
|
||||
rerankingProviderName: 'jina',
|
||||
rerankingModelName: 'jina-reranker',
|
||||
},
|
||||
embeddingModelList: [
|
||||
createModelProvider('azure', [
|
||||
createModelItem('azure-embedding', [ModelFeatureEnum.vision]),
|
||||
]),
|
||||
],
|
||||
rerankModelList: [
|
||||
createModelProvider('jina', [
|
||||
createModelItem('jina-reranker', []),
|
||||
]),
|
||||
],
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty embeddingModelList', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModelList: [],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty rerankModelList', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
rerankModelList: [],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle model with undefined features', () => {
|
||||
const modelItem: ModelItem = {
|
||||
model: 'test-model',
|
||||
label: { en_US: 'test', zh_Hans: 'test' },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
features: undefined as unknown as ModelFeatureEnum[],
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
deprecated: false,
|
||||
}
|
||||
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModelList: [
|
||||
createModelProvider('openai', [modelItem]),
|
||||
],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle model with null features', () => {
|
||||
const modelItem: ModelItem = {
|
||||
model: 'text-embedding-ada-002',
|
||||
label: { en_US: 'test', zh_Hans: 'test' },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
features: null as unknown as ModelFeatureEnum[],
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
deprecated: false,
|
||||
}
|
||||
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModelList: [
|
||||
createModelProvider('openai', [modelItem]),
|
||||
],
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle multiple models in provider', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModelList: [
|
||||
createModelProvider('openai', [
|
||||
createModelItem('text-embedding-1', []),
|
||||
createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]),
|
||||
createModelItem('text-embedding-3', []),
|
||||
]),
|
||||
],
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle multiple providers in list', () => {
|
||||
const result = checkShowMultiModalTip({
|
||||
...defaultProps,
|
||||
embeddingModelList: [
|
||||
createModelProvider('azure', [
|
||||
createModelItem('azure-model', []),
|
||||
]),
|
||||
createModelProvider('openai', [
|
||||
createModelItem('text-embedding-ada-002', [ModelFeatureEnum.vision]),
|
||||
]),
|
||||
],
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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.",
|
||||
|
||||
@ -61,6 +61,7 @@
|
||||
"account.workspaceName": "Workspace Name",
|
||||
"account.workspaceNamePlaceholder": "Enter workspace name",
|
||||
"actionMsg.copySuccessfully": "Copied successfully",
|
||||
"actionMsg.downloadUnsuccessfully": "Download failed. Please try again later.",
|
||||
"actionMsg.generatedSuccessfully": "Generated successfully",
|
||||
"actionMsg.generatedUnsuccessfully": "Generated unsuccessfully",
|
||||
"actionMsg.modifiedSuccessfully": "Modified successfully",
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
"list.action.archive": "Archive",
|
||||
"list.action.batchAdd": "Batch add",
|
||||
"list.action.delete": "Delete",
|
||||
"list.action.download": "Download",
|
||||
"list.action.enableWarning": "Archived file cannot be enabled",
|
||||
"list.action.pause": "Pause",
|
||||
"list.action.resume": "Resume",
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
"batchAction.cancel": "Cancel",
|
||||
"batchAction.delete": "Delete",
|
||||
"batchAction.disable": "Disable",
|
||||
"batchAction.download": "Download",
|
||||
"batchAction.enable": "Enable",
|
||||
"batchAction.reIndex": "Re-index",
|
||||
"batchAction.selected": "Selected",
|
||||
|
||||
@ -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}}.",
|
||||
|
||||
@ -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}} رسیدهاید.",
|
||||
|
||||
@ -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}}.",
|
||||
|
||||
@ -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}} की बैच अपलोड सीमा तक पहुँच गए हैं।",
|
||||
|
||||
@ -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}}.",
|
||||
|
||||
@ -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}}個)に達しました。",
|
||||
|
||||
@ -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}}개) 에 도달했습니다.",
|
||||
|
||||
@ -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}}.",
|
||||
|
||||
@ -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}}.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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}} файлов.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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}} แล้ว",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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}}.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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}}。",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
@ -179,6 +180,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",
|
||||
|
||||
246
web/pnpm-lock.yaml
generated
246
web/pnpm-lock.yaml
generated
@ -433,6 +433,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
|
||||
@ -1647,14 +1656,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}
|
||||
@ -1667,6 +1692,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}
|
||||
@ -1679,6 +1708,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}
|
||||
@ -1993,6 +2026,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'}
|
||||
@ -3474,6 +3515,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==}
|
||||
|
||||
@ -3893,6 +3964,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==}
|
||||
|
||||
@ -5242,6 +5325,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}
|
||||
@ -6474,6 +6567,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==}
|
||||
|
||||
@ -9805,6 +9902,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)
|
||||
@ -9893,6 +9995,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
|
||||
@ -9901,10 +10011,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
|
||||
@ -9927,6 +10047,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@eslint/js@9.27.0': {}
|
||||
|
||||
'@eslint/js@9.39.2': {}
|
||||
|
||||
'@eslint/markdown@7.5.1':
|
||||
@ -9945,6 +10067,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
|
||||
@ -10196,6 +10323,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
|
||||
@ -11808,6 +11941,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
|
||||
@ -12087,6 +12261,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
|
||||
@ -12301,6 +12487,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
|
||||
@ -13880,6 +14080,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))
|
||||
@ -15511,6 +15753,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
|
||||
|
||||
@ -40,6 +40,15 @@ type CommonDocReq = {
|
||||
documentId: string
|
||||
}
|
||||
|
||||
export type DocumentDownloadResponse = {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type DocumentDownloadZipRequest = {
|
||||
datasetId: string
|
||||
documentIds: string[]
|
||||
}
|
||||
|
||||
type BatchReq = {
|
||||
datasetId: string
|
||||
batchId: string
|
||||
@ -158,6 +167,18 @@ export const resumeDocIndexing = ({ datasetId, documentId }: CommonDocReq): Prom
|
||||
return patch<CommonResponse>(`/datasets/${datasetId}/documents/${documentId}/processing/resume`)
|
||||
}
|
||||
|
||||
export const fetchDocumentDownloadUrl = ({ datasetId, documentId }: CommonDocReq): Promise<DocumentDownloadResponse> => {
|
||||
return get<DocumentDownloadResponse>(`/datasets/${datasetId}/documents/${documentId}/download`, {})
|
||||
}
|
||||
|
||||
export const downloadDocumentsZip = ({ datasetId, documentIds }: DocumentDownloadZipRequest): Promise<Blob> => {
|
||||
return post<Blob>(`/datasets/${datasetId}/documents/download-zip`, {
|
||||
body: {
|
||||
document_ids: documentIds,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const preImportNotionPages = ({ url, datasetId }: { url: string, datasetId?: string }): Promise<{ notion_info: DataSourceNotionWorkspace[] }> => {
|
||||
return get<{ notion_info: DataSourceNotionWorkspace[] }>(url, { params: { dataset_id: datasetId } })
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { MetadataType, SortType } from '../datasets'
|
||||
import type { DocumentDownloadResponse, DocumentDownloadZipRequest, MetadataType, SortType } from '../datasets'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import type { DocumentDetailResponse, DocumentListResponse, UpdateDocumentBatchParams } from '@/models/datasets'
|
||||
import {
|
||||
@ -8,7 +8,7 @@ import {
|
||||
import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter'
|
||||
import { DocumentActionType } from '@/models/datasets'
|
||||
import { del, get, patch, post } from '../base'
|
||||
import { pauseDocIndexing, resumeDocIndexing } from '../datasets'
|
||||
import { downloadDocumentsZip, fetchDocumentDownloadUrl, pauseDocIndexing, resumeDocIndexing } from '../datasets'
|
||||
import { useInvalid } from '../use-base'
|
||||
|
||||
const NAME_SPACE = 'knowledge/document'
|
||||
@ -164,6 +164,26 @@ export const useDocumentResume = () => {
|
||||
})
|
||||
}
|
||||
|
||||
export const useDocumentDownload = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => {
|
||||
if (!datasetId || !documentId)
|
||||
throw new Error('datasetId and documentId are required')
|
||||
return fetchDocumentDownloadUrl({ datasetId, documentId }) as Promise<DocumentDownloadResponse>
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDocumentDownloadZip = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ datasetId, documentIds }: DocumentDownloadZipRequest) => {
|
||||
if (!datasetId || !documentIds?.length)
|
||||
throw new Error('datasetId and documentIds are required')
|
||||
return downloadDocumentsZip({ datasetId, documentIds })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDocumentBatchRetryIndex = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ datasetId, documentIds }: { datasetId: string, documentIds: string[] }) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user