mirror of
https://github.com/langgenius/dify.git
synced 2026-01-28 15:56:00 +08:00
Compare commits
3 Commits
1-26-css-i
...
fix/trigge
| Author | SHA1 | Date | |
|---|---|---|---|
| 354d0e2038 | |||
| 0c495c5d75 | |||
| 186f89a9c7 |
24
AGENTS.md
24
AGENTS.md
@ -25,30 +25,6 @@ pnpm type-check:tsgo
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### Frontend Linting
|
||||
|
||||
ESLint is used for frontend code quality. Available commands:
|
||||
|
||||
```bash
|
||||
# Lint all files (report only)
|
||||
pnpm lint
|
||||
|
||||
# Lint and auto-fix issues
|
||||
pnpm lint:fix
|
||||
|
||||
# Lint specific files or directories
|
||||
pnpm lint:fix app/components/base/button/
|
||||
pnpm lint:fix app/components/base/button/index.tsx
|
||||
|
||||
# Lint quietly (errors only, no warnings)
|
||||
pnpm lint:quiet
|
||||
|
||||
# Check code complexity
|
||||
pnpm lint:complexity
|
||||
```
|
||||
|
||||
**Important**: Always run `pnpm lint:fix` before committing. The pre-commit hook runs `lint-staged` which only lints staged files.
|
||||
|
||||
## Testing & Quality Practices
|
||||
|
||||
- Follow TDD: red → green → refactor.
|
||||
|
||||
0
agent-notes/.gitkeep
Normal file
0
agent-notes/.gitkeep
Normal file
@ -0,0 +1,27 @@
|
||||
# Notes: `large_language_model.py`
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the base `LargeLanguageModel` implementation used by the model runtime to invoke plugin-backed LLMs and to
|
||||
bridge plugin daemon streaming semantics back into API-layer entities (`LLMResult`, `LLMResultChunk`).
|
||||
|
||||
## Key behaviors / invariants
|
||||
|
||||
- `invoke(..., stream=False)` still calls the plugin in streaming mode and then synthesizes a single `LLMResult` from
|
||||
the first yielded `LLMResultChunk`.
|
||||
- Plugin invocation is wrapped by `_invoke_llm_via_plugin(...)`, and `stream=False` normalization is handled by
|
||||
`_normalize_non_stream_plugin_result(...)` / `_build_llm_result_from_first_chunk(...)`.
|
||||
- Tool call deltas are merged incrementally via `_increase_tool_call(...)` to support multiple provider chunking
|
||||
patterns (IDs anchored to first chunk, every chunk, or missing entirely).
|
||||
- A tool-call delta with an empty `id` requires at least one existing tool call; otherwise we raise `ValueError` to
|
||||
surface invalid delta sequences explicitly.
|
||||
- Callback invocation is centralized in `_run_callbacks(...)` to ensure consistent error handling/logging.
|
||||
- For compatibility with dify issue `#17799`, `prompt_messages` may be removed by the plugin daemon in chunks and must
|
||||
be re-attached in this layer before callbacks/consumers use them.
|
||||
- Callback hooks (`on_before_invoke`, `on_new_chunk`, `on_after_invoke`, `on_invoke_error`) must not break invocation
|
||||
unless `callback.raise_error` is true.
|
||||
|
||||
## Test focus
|
||||
|
||||
- `api/tests/unit_tests/core/model_runtime/__base/test_increase_tool_call.py` validates tool-call delta merging and
|
||||
patches `_gen_tool_call_id` for deterministic IDs.
|
||||
120
api/AGENTS.md
120
api/AGENTS.md
@ -1,47 +1,97 @@
|
||||
# API Agent Guide
|
||||
|
||||
## Notes for Agent (must-check)
|
||||
## Agent Notes (must-check)
|
||||
|
||||
Before changing any backend code under `api/`, you MUST read the surrounding docstrings and comments. These notes contain required context (invariants, edge cases, trade-offs) and are treated as part of the spec.
|
||||
Before you start work on any backend file under `api/`, you MUST check whether a related note exists under:
|
||||
|
||||
Look for:
|
||||
- `agent-notes/<same-relative-path-as-target-file>.md`
|
||||
|
||||
- The module (file) docstring at the top of a source code file
|
||||
- Docstrings on classes and functions/methods
|
||||
- Paragraph/block comments for non-obvious logic
|
||||
Rules:
|
||||
|
||||
### What to write where
|
||||
- **Path mapping**: for a target file `<path>/<name>.py`, the note must be `agent-notes/<path>/<name>.py.md` (same folder structure, same filename, plus `.md`).
|
||||
- **Before working**:
|
||||
- If the note exists, read it first and follow any constraints/decisions recorded there.
|
||||
- If the note conflicts with the current code, or references an "origin" file/path that has been deleted, renamed, or migrated, treat the **code as the single source of truth** and update the note to match reality.
|
||||
- If the note does not exist, create it with a short architecture/intent summary and any relevant invariants/edge cases.
|
||||
- **During working**:
|
||||
- Keep the note in sync as you discover constraints, make decisions, or change approach.
|
||||
- If you move/rename a file, migrate its note to the new mapped path (and fix any outdated references inside the note).
|
||||
- Record non-obvious edge cases, trade-offs, and the test/verification plan as you go (not just at the end).
|
||||
- Keep notes **coherent**: integrate new findings into the relevant sections and rewrite for clarity; avoid append-only “recent fix” / changelog-style additions unless the note is explicitly intended to be a changelog.
|
||||
- **When finishing work**:
|
||||
- Update the related note(s) to reflect what changed, why, and any new edge cases/tests.
|
||||
- If a file is deleted, remove or clearly deprecate the corresponding note so it cannot be mistaken as current guidance.
|
||||
- Keep notes concise and accurate; they are meant to prevent repeated rediscovery.
|
||||
|
||||
- Keep notes scoped: module notes cover module-wide context, class notes cover class-wide context, function/method notes cover behavioural contracts, and paragraph/block comments cover local “why”. Avoid duplicating the same content across scopes unless repetition prevents misuse.
|
||||
- **Module (file) docstring**: purpose, boundaries, key invariants, and “gotchas” that a new reader must know before editing.
|
||||
- Include cross-links to the key collaborators (modules/services) when discovery is otherwise hard.
|
||||
- Prefer stable facts (invariants, contracts) over ephemeral “today we…” notes.
|
||||
- **Class docstring**: responsibility, lifecycle, invariants, and how it should be used (or not used).
|
||||
- If the class is intentionally stateful, note what state exists and what methods mutate it.
|
||||
- If concurrency/async assumptions matter, state them explicitly.
|
||||
- **Function/method docstring**: behavioural contract.
|
||||
- Document arguments, return shape, side effects (DB writes, external I/O, task dispatch), and raised domain exceptions.
|
||||
- Add examples only when they prevent misuse.
|
||||
- **Paragraph/block comments**: explain *why* (trade-offs, historical constraints, surprising edge cases), not what the code already states.
|
||||
- Keep comments adjacent to the logic they justify; delete or rewrite comments that no longer match reality.
|
||||
## Skill Index
|
||||
|
||||
### Rules (must follow)
|
||||
Start with the section that best matches your need. Each entry lists the problems it solves plus key files/concepts so you know what to expect before opening it.
|
||||
|
||||
In this section, “notes” means module/class/function docstrings plus any relevant paragraph/block comments.
|
||||
### Platform Foundations
|
||||
|
||||
- **Before working**
|
||||
- Read the notes in the area you’ll touch; treat them as part of the spec.
|
||||
- If a docstring or comment conflicts with the current code, treat the **code as the single source of truth** and update the docstring or comment to match reality.
|
||||
- If important intent/invariants/edge cases are missing, add them in the closest docstring or comment (module for overall scope, function for behaviour).
|
||||
- **During working**
|
||||
- Keep the notes in sync as you discover constraints, make decisions, or change approach.
|
||||
- If you move/rename responsibilities across modules/classes, update the affected docstrings and comments so readers can still find the “why” and the invariants.
|
||||
- Record non-obvious edge cases, trade-offs, and the test/verification plan in the nearest docstring or comment that will stay correct.
|
||||
- Keep the notes **coherent**: integrate new findings into the relevant docstrings and comments; avoid append-only “recent fix” / changelog-style additions.
|
||||
- **When finishing**
|
||||
- Update the notes to reflect what changed, why, and any new edge cases/tests.
|
||||
- Remove or rewrite any comments that could be mistaken as current guidance but no longer apply.
|
||||
- Keep docstrings and comments concise and accurate; they are meant to prevent repeated rediscovery.
|
||||
#### [Infrastructure Overview](agent_skills/infra.md)
|
||||
|
||||
- **When to read this**
|
||||
- You need to understand where a feature belongs in the architecture.
|
||||
- You’re wiring storage, Redis, vector stores, or OTEL.
|
||||
- You’re about to add CLI commands or async jobs.
|
||||
- **What it covers**
|
||||
- Configuration stack (`configs/app_config.py`, remote settings)
|
||||
- Storage entry points (`extensions/ext_storage.py`, `core/file/file_manager.py`)
|
||||
- Redis conventions (`extensions/ext_redis.py`)
|
||||
- Plugin runtime topology
|
||||
- Vector-store factory (`core/rag/datasource/vdb/*`)
|
||||
- Observability hooks
|
||||
- SSRF proxy usage
|
||||
- Core CLI commands
|
||||
|
||||
### Plugin & Extension Development
|
||||
|
||||
#### [Plugin Systems](agent_skills/plugin.md)
|
||||
|
||||
- **When to read this**
|
||||
- You’re building or debugging a marketplace plugin.
|
||||
- You need to know how manifests, providers, daemons, and migrations fit together.
|
||||
- **What it covers**
|
||||
- Plugin manifests (`core/plugin/entities/plugin.py`)
|
||||
- Installation/upgrade flows (`services/plugin/plugin_service.py`, CLI commands)
|
||||
- Runtime adapters (`core/plugin/impl/*` for tool/model/datasource/trigger/endpoint/agent)
|
||||
- Daemon coordination (`core/plugin/entities/plugin_daemon.py`)
|
||||
- How provider registries surface capabilities to the rest of the platform
|
||||
|
||||
#### [Plugin OAuth](agent_skills/plugin_oauth.md)
|
||||
|
||||
- **When to read this**
|
||||
- You must integrate OAuth for a plugin or datasource.
|
||||
- You’re handling credential encryption or refresh flows.
|
||||
- **Topics**
|
||||
- Credential storage
|
||||
- Encryption helpers (`core/helper/provider_encryption.py`)
|
||||
- OAuth client bootstrap (`services/plugin/oauth_service.py`, `services/plugin/plugin_parameter_service.py`)
|
||||
- How console/API layers expose the flows
|
||||
|
||||
### Workflow Entry & Execution
|
||||
|
||||
#### [Trigger Concepts](agent_skills/trigger.md)
|
||||
|
||||
- **When to read this**
|
||||
- You’re debugging why a workflow didn’t start.
|
||||
- You’re adding a new trigger type or hook.
|
||||
- You need to trace async execution, draft debugging, or webhook/schedule pipelines.
|
||||
- **Details**
|
||||
- Start-node taxonomy
|
||||
- Webhook & schedule internals (`core/workflow/nodes/trigger_*`, `services/trigger/*`)
|
||||
- Async orchestration (`services/async_workflow_service.py`, Celery queues)
|
||||
- Debug event bus
|
||||
- Storage/logging interactions
|
||||
|
||||
## General Reminders
|
||||
|
||||
- All skill docs assume you follow the coding style rules below—run the lint/type/test commands before submitting changes.
|
||||
- When you cannot find an answer in these briefs, search the codebase using the paths referenced (e.g., `core/plugin/impl/tool.py`, `services/dataset_service.py`).
|
||||
- If you run into cross-cutting concerns (tenancy, configuration, storage), check the infrastructure guide first; it links to most supporting modules.
|
||||
- Keep multi-tenancy and configuration central: everything flows through `configs.dify_config` and `tenant_id`.
|
||||
- When touching plugins or triggers, consult both the system overview and the specialised doc to ensure you adjust lifecycle, storage, and observability consistently.
|
||||
|
||||
## Coding Style
|
||||
|
||||
@ -176,7 +226,7 @@ Before opening a PR / submitting:
|
||||
|
||||
- Controllers: parse input via Pydantic, invoke services, return serialised responses; no business logic.
|
||||
- Services: coordinate repositories, providers, background tasks; keep side effects explicit.
|
||||
- Document non-obvious behaviour with concise docstrings and comments.
|
||||
- Document non-obvious behaviour with concise comments.
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
|
||||
@ -36,16 +36,6 @@ class NotionEstimatePayload(BaseModel):
|
||||
doc_language: str = Field(default="English")
|
||||
|
||||
|
||||
class DataSourceNotionListQuery(BaseModel):
|
||||
dataset_id: str | None = Field(default=None, description="Dataset ID")
|
||||
credential_id: str = Field(..., description="Credential ID", min_length=1)
|
||||
datasource_parameters: dict[str, Any] | None = Field(default=None, description="Datasource parameters JSON string")
|
||||
|
||||
|
||||
class DataSourceNotionPreviewQuery(BaseModel):
|
||||
credential_id: str = Field(..., description="Credential ID", min_length=1)
|
||||
|
||||
|
||||
register_schema_model(console_ns, NotionEstimatePayload)
|
||||
|
||||
|
||||
@ -146,15 +136,26 @@ class DataSourceNotionListApi(Resource):
|
||||
def get(self):
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
query = DataSourceNotionListQuery.model_validate(request.args.to_dict())
|
||||
dataset_id = request.args.get("dataset_id", default=None, type=str)
|
||||
credential_id = request.args.get("credential_id", default=None, type=str)
|
||||
if not credential_id:
|
||||
raise ValueError("Credential id is required.")
|
||||
|
||||
# Get datasource_parameters from query string (optional, for GitHub and other datasources)
|
||||
datasource_parameters = query.datasource_parameters or {}
|
||||
datasource_parameters_str = request.args.get("datasource_parameters", default=None, type=str)
|
||||
datasource_parameters = {}
|
||||
if datasource_parameters_str:
|
||||
try:
|
||||
datasource_parameters = json.loads(datasource_parameters_str)
|
||||
if not isinstance(datasource_parameters, dict):
|
||||
raise ValueError("datasource_parameters must be a JSON object.")
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("Invalid datasource_parameters JSON format.")
|
||||
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
credential = datasource_provider_service.get_datasource_credentials(
|
||||
tenant_id=current_tenant_id,
|
||||
credential_id=query.credential_id,
|
||||
credential_id=credential_id,
|
||||
provider="notion_datasource",
|
||||
plugin_id="langgenius/notion_datasource",
|
||||
)
|
||||
@ -163,8 +164,8 @@ class DataSourceNotionListApi(Resource):
|
||||
exist_page_ids = []
|
||||
with Session(db.engine) as session:
|
||||
# import notion in the exist dataset
|
||||
if query.dataset_id:
|
||||
dataset = DatasetService.get_dataset(query.dataset_id)
|
||||
if dataset_id:
|
||||
dataset = DatasetService.get_dataset(dataset_id)
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
if dataset.data_source_type != "notion_import":
|
||||
@ -172,7 +173,7 @@ class DataSourceNotionListApi(Resource):
|
||||
|
||||
documents = session.scalars(
|
||||
select(Document).filter_by(
|
||||
dataset_id=query.dataset_id,
|
||||
dataset_id=dataset_id,
|
||||
tenant_id=current_tenant_id,
|
||||
data_source_type="notion_import",
|
||||
enabled=True,
|
||||
@ -239,12 +240,13 @@ class DataSourceNotionApi(Resource):
|
||||
def get(self, page_id, page_type):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
query = DataSourceNotionPreviewQuery.model_validate(request.args.to_dict())
|
||||
|
||||
credential_id = request.args.get("credential_id", default=None, type=str)
|
||||
if not credential_id:
|
||||
raise ValueError("Credential id is required.")
|
||||
datasource_provider_service = DatasourceProviderService()
|
||||
credential = datasource_provider_service.get_datasource_credentials(
|
||||
tenant_id=current_tenant_id,
|
||||
credential_id=query.credential_id,
|
||||
credential_id=credential_id,
|
||||
provider="notion_datasource",
|
||||
plugin_id="langgenius/notion_datasource",
|
||||
)
|
||||
|
||||
@ -176,18 +176,7 @@ class IndexingEstimatePayload(BaseModel):
|
||||
return result
|
||||
|
||||
|
||||
class ConsoleDatasetListQuery(BaseModel):
|
||||
page: int = Field(default=1, description="Page number")
|
||||
limit: int = Field(default=20, description="Number of items per page")
|
||||
keyword: str | None = Field(default=None, description="Search keyword")
|
||||
include_all: bool = Field(default=False, description="Include all datasets")
|
||||
ids: list[str] = Field(default_factory=list, description="Filter by dataset IDs")
|
||||
tag_ids: list[str] = Field(default_factory=list, description="Filter by tag IDs")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns, DatasetCreatePayload, DatasetUpdatePayload, IndexingEstimatePayload, ConsoleDatasetListQuery
|
||||
)
|
||||
register_schema_models(console_ns, DatasetCreatePayload, DatasetUpdatePayload, IndexingEstimatePayload)
|
||||
|
||||
|
||||
def _get_retrieval_methods_by_vector_type(vector_type: str | None, is_mock: bool = False) -> dict[str, list[str]]:
|
||||
@ -286,19 +275,18 @@ class DatasetListApi(Resource):
|
||||
@enterprise_license_required
|
||||
def get(self):
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
query = ConsoleDatasetListQuery.model_validate(request.args.to_dict())
|
||||
page = request.args.get("page", default=1, type=int)
|
||||
limit = request.args.get("limit", default=20, type=int)
|
||||
ids = request.args.getlist("ids")
|
||||
# provider = request.args.get("provider", default="vendor")
|
||||
if query.ids:
|
||||
datasets, total = DatasetService.get_datasets_by_ids(query.ids, current_tenant_id)
|
||||
search = request.args.get("keyword", default=None, type=str)
|
||||
tag_ids = request.args.getlist("tag_ids")
|
||||
include_all = request.args.get("include_all", default="false").lower() == "true"
|
||||
if ids:
|
||||
datasets, total = DatasetService.get_datasets_by_ids(ids, current_tenant_id)
|
||||
else:
|
||||
datasets, total = DatasetService.get_datasets(
|
||||
query.page,
|
||||
query.limit,
|
||||
current_tenant_id,
|
||||
current_user,
|
||||
query.keyword,
|
||||
query.tag_ids,
|
||||
query.include_all,
|
||||
page, limit, current_tenant_id, current_user, search, tag_ids, include_all
|
||||
)
|
||||
|
||||
# check embedding setting
|
||||
@ -330,13 +318,7 @@ class DatasetListApi(Resource):
|
||||
else:
|
||||
item.update({"partial_member_list": []})
|
||||
|
||||
response = {
|
||||
"data": data,
|
||||
"has_more": len(datasets) == query.limit,
|
||||
"limit": query.limit,
|
||||
"total": total,
|
||||
"page": query.page,
|
||||
}
|
||||
response = {"data": data, "has_more": len(datasets) == limit, "limit": limit, "total": total, "page": page}
|
||||
return response, 200
|
||||
|
||||
@console_ns.doc("create_dataset")
|
||||
|
||||
@ -98,19 +98,12 @@ class BedrockRetrievalPayload(BaseModel):
|
||||
knowledge_id: str
|
||||
|
||||
|
||||
class ExternalApiTemplateListQuery(BaseModel):
|
||||
page: int = Field(default=1, description="Page number")
|
||||
limit: int = Field(default=20, description="Number of items per page")
|
||||
keyword: str | None = Field(default=None, description="Search keyword")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
ExternalKnowledgeApiPayload,
|
||||
ExternalDatasetCreatePayload,
|
||||
ExternalHitTestingPayload,
|
||||
BedrockRetrievalPayload,
|
||||
ExternalApiTemplateListQuery,
|
||||
)
|
||||
|
||||
|
||||
@ -131,17 +124,19 @@ class ExternalApiTemplateListApi(Resource):
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
query = ExternalApiTemplateListQuery.model_validate(request.args.to_dict())
|
||||
page = request.args.get("page", default=1, type=int)
|
||||
limit = request.args.get("limit", default=20, type=int)
|
||||
search = request.args.get("keyword", default=None, type=str)
|
||||
|
||||
external_knowledge_apis, total = ExternalDatasetService.get_external_knowledge_apis(
|
||||
query.page, query.limit, current_tenant_id, query.keyword
|
||||
page, limit, current_tenant_id, search
|
||||
)
|
||||
response = {
|
||||
"data": [item.to_dict() for item in external_knowledge_apis],
|
||||
"has_more": len(external_knowledge_apis) == query.limit,
|
||||
"limit": query.limit,
|
||||
"has_more": len(external_knowledge_apis) == limit,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"page": query.page,
|
||||
"page": page,
|
||||
}
|
||||
return response, 200
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ from typing import Any
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource, marshal_with
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, select
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
|
||||
@ -28,10 +28,6 @@ class InstalledAppUpdatePayload(BaseModel):
|
||||
is_pinned: bool | None = None
|
||||
|
||||
|
||||
class InstalledAppsListQuery(BaseModel):
|
||||
app_id: str | None = Field(default=None, description="App ID to filter by")
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -41,13 +37,13 @@ class InstalledAppsListApi(Resource):
|
||||
@account_initialization_required
|
||||
@marshal_with(installed_app_list_fields)
|
||||
def get(self):
|
||||
query = InstalledAppsListQuery.model_validate(request.args.to_dict())
|
||||
app_id = request.args.get("app_id", default=None, type=str)
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
if query.app_id:
|
||||
if app_id:
|
||||
installed_apps = db.session.scalars(
|
||||
select(InstalledApp).where(
|
||||
and_(InstalledApp.tenant_id == current_tenant_id, InstalledApp.app_id == query.app_id)
|
||||
and_(InstalledApp.tenant_id == current_tenant_id, InstalledApp.app_id == app_id)
|
||||
)
|
||||
).all()
|
||||
else:
|
||||
|
||||
@ -40,7 +40,6 @@ register_schema_models(
|
||||
TagBasePayload,
|
||||
TagBindingPayload,
|
||||
TagBindingRemovePayload,
|
||||
TagListQueryParam,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -87,14 +87,6 @@ class TagUnbindingPayload(BaseModel):
|
||||
target_id: str
|
||||
|
||||
|
||||
class DatasetListQuery(BaseModel):
|
||||
page: int = Field(default=1, description="Page number")
|
||||
limit: int = Field(default=20, description="Number of items per page")
|
||||
keyword: str | None = Field(default=None, description="Search keyword")
|
||||
include_all: bool = Field(default=False, description="Include all datasets")
|
||||
tag_ids: list[str] = Field(default_factory=list, description="Filter by tag IDs")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
service_api_ns,
|
||||
DatasetCreatePayload,
|
||||
@ -104,7 +96,6 @@ register_schema_models(
|
||||
TagDeletePayload,
|
||||
TagBindingPayload,
|
||||
TagUnbindingPayload,
|
||||
DatasetListQuery,
|
||||
)
|
||||
|
||||
|
||||
@ -122,11 +113,15 @@ class DatasetListApi(DatasetApiResource):
|
||||
)
|
||||
def get(self, tenant_id):
|
||||
"""Resource for getting datasets."""
|
||||
query = DatasetListQuery.model_validate(request.args.to_dict())
|
||||
page = request.args.get("page", default=1, type=int)
|
||||
limit = request.args.get("limit", default=20, type=int)
|
||||
# provider = request.args.get("provider", default="vendor")
|
||||
search = request.args.get("keyword", default=None, type=str)
|
||||
tag_ids = request.args.getlist("tag_ids")
|
||||
include_all = request.args.get("include_all", default="false").lower() == "true"
|
||||
|
||||
datasets, total = DatasetService.get_datasets(
|
||||
query.page, query.limit, tenant_id, current_user, query.keyword, query.tag_ids, query.include_all
|
||||
page, limit, tenant_id, current_user, search, tag_ids, include_all
|
||||
)
|
||||
# check embedding setting
|
||||
provider_manager = ProviderManager()
|
||||
@ -152,13 +147,7 @@ class DatasetListApi(DatasetApiResource):
|
||||
item["embedding_available"] = False
|
||||
else:
|
||||
item["embedding_available"] = True
|
||||
response = {
|
||||
"data": data,
|
||||
"has_more": len(datasets) == query.limit,
|
||||
"limit": query.limit,
|
||||
"total": total,
|
||||
"page": query.page,
|
||||
}
|
||||
response = {"data": data, "has_more": len(datasets) == limit, "limit": limit, "total": total, "page": page}
|
||||
return response, 200
|
||||
|
||||
@service_api_ns.expect(service_api_ns.models[DatasetCreatePayload.__name__])
|
||||
|
||||
@ -69,14 +69,7 @@ class DocumentTextUpdate(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
class DocumentListQuery(BaseModel):
|
||||
page: int = Field(default=1, description="Page number")
|
||||
limit: int = Field(default=20, description="Number of items per page")
|
||||
keyword: str | None = Field(default=None, description="Search keyword")
|
||||
status: str | None = Field(default=None, description="Document status filter")
|
||||
|
||||
|
||||
for m in [ProcessRule, RetrievalModel, DocumentTextCreatePayload, DocumentTextUpdate, DocumentListQuery]:
|
||||
for m in [ProcessRule, RetrievalModel, DocumentTextCreatePayload, DocumentTextUpdate]:
|
||||
service_api_ns.schema_model(m.__name__, m.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) # type: ignore
|
||||
|
||||
|
||||
@ -467,33 +460,34 @@ class DocumentListApi(DatasetApiResource):
|
||||
def get(self, tenant_id, dataset_id):
|
||||
dataset_id = str(dataset_id)
|
||||
tenant_id = str(tenant_id)
|
||||
query_params = DocumentListQuery.model_validate(request.args.to_dict())
|
||||
page = request.args.get("page", default=1, type=int)
|
||||
limit = request.args.get("limit", default=20, type=int)
|
||||
search = request.args.get("keyword", default=None, type=str)
|
||||
status = request.args.get("status", default=None, type=str)
|
||||
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
query = select(Document).filter_by(dataset_id=str(dataset_id), tenant_id=tenant_id)
|
||||
|
||||
if query_params.status:
|
||||
query = DocumentService.apply_display_status_filter(query, query_params.status)
|
||||
if status:
|
||||
query = DocumentService.apply_display_status_filter(query, status)
|
||||
|
||||
if query_params.keyword:
|
||||
search = f"%{query_params.keyword}%"
|
||||
if search:
|
||||
search = f"%{search}%"
|
||||
query = query.where(Document.name.like(search))
|
||||
|
||||
query = query.order_by(desc(Document.created_at), desc(Document.position))
|
||||
|
||||
paginated_documents = db.paginate(
|
||||
select=query, page=query_params.page, per_page=query_params.limit, max_per_page=100, error_out=False
|
||||
)
|
||||
paginated_documents = db.paginate(select=query, page=page, per_page=limit, max_per_page=100, error_out=False)
|
||||
documents = paginated_documents.items
|
||||
|
||||
response = {
|
||||
"data": marshal(documents, document_fields),
|
||||
"has_more": len(documents) == query_params.limit,
|
||||
"limit": query_params.limit,
|
||||
"has_more": len(documents) == limit,
|
||||
"limit": limit,
|
||||
"total": paginated_documents.total,
|
||||
"page": query_params.page,
|
||||
"page": page,
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Generator, Mapping
|
||||
from typing import Any
|
||||
|
||||
from core.datasource.__base.datasource_plugin import DatasourcePlugin
|
||||
@ -34,7 +34,7 @@ class OnlineDocumentDatasourcePlugin(DatasourcePlugin):
|
||||
def get_online_document_pages(
|
||||
self,
|
||||
user_id: str,
|
||||
datasource_parameters: dict[str, Any],
|
||||
datasource_parameters: Mapping[str, Any],
|
||||
provider_type: str,
|
||||
) -> Generator[OnlineDocumentPagesMessage, None, None]:
|
||||
manager = PluginDatasourceManager()
|
||||
|
||||
@ -8,7 +8,7 @@ intercept and respond to GraphEngine events.
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from core.workflow.graph_engine.protocols.command_channel import CommandChannel
|
||||
from core.workflow.graph_events import GraphEngineEvent, GraphNodeEventBase
|
||||
from core.workflow.graph_events import GraphEngineEvent
|
||||
from core.workflow.nodes.base.node import Node
|
||||
from core.workflow.runtime import ReadOnlyGraphRuntimeState
|
||||
|
||||
@ -98,7 +98,7 @@ class GraphEngineLayer(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
def on_node_run_start(self, node: Node) -> None:
|
||||
def on_node_run_start(self, node: Node) -> None: # noqa: B027
|
||||
"""
|
||||
Called immediately before a node begins execution.
|
||||
|
||||
@ -109,11 +109,9 @@ class GraphEngineLayer(ABC):
|
||||
Args:
|
||||
node: The node instance about to be executed
|
||||
"""
|
||||
return
|
||||
pass
|
||||
|
||||
def on_node_run_end(
|
||||
self, node: Node, error: Exception | None, result_event: GraphNodeEventBase | None = None
|
||||
) -> None:
|
||||
def on_node_run_end(self, node: Node, error: Exception | None) -> None: # noqa: B027
|
||||
"""
|
||||
Called after a node finishes execution.
|
||||
|
||||
@ -123,6 +121,5 @@ class GraphEngineLayer(ABC):
|
||||
Args:
|
||||
node: The node instance that just finished execution
|
||||
error: Exception instance if the node failed, otherwise None
|
||||
result_event: The final result event from node execution (succeeded/failed/paused), if any
|
||||
"""
|
||||
return
|
||||
pass
|
||||
|
||||
61
api/core/workflow/graph_engine/layers/node_parsers.py
Normal file
61
api/core/workflow/graph_engine/layers/node_parsers.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""
|
||||
Node-level OpenTelemetry parser interfaces and defaults.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Protocol
|
||||
|
||||
from opentelemetry.trace import Span
|
||||
from opentelemetry.trace.status import Status, StatusCode
|
||||
|
||||
from core.workflow.nodes.base.node import Node
|
||||
from core.workflow.nodes.tool.entities import ToolNodeData
|
||||
|
||||
|
||||
class NodeOTelParser(Protocol):
|
||||
"""Parser interface for node-specific OpenTelemetry enrichment."""
|
||||
|
||||
def parse(self, *, node: Node, span: "Span", error: Exception | None) -> None: ...
|
||||
|
||||
|
||||
class DefaultNodeOTelParser:
|
||||
"""Fallback parser used when no node-specific parser is registered."""
|
||||
|
||||
def parse(self, *, node: Node, span: "Span", error: Exception | None) -> None:
|
||||
span.set_attribute("node.id", node.id)
|
||||
if node.execution_id:
|
||||
span.set_attribute("node.execution_id", node.execution_id)
|
||||
if hasattr(node, "node_type") and node.node_type:
|
||||
span.set_attribute("node.type", node.node_type.value)
|
||||
|
||||
if error:
|
||||
span.record_exception(error)
|
||||
span.set_status(Status(StatusCode.ERROR, str(error)))
|
||||
else:
|
||||
span.set_status(Status(StatusCode.OK))
|
||||
|
||||
|
||||
class ToolNodeOTelParser:
|
||||
"""Parser for tool nodes that captures tool-specific metadata."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._delegate = DefaultNodeOTelParser()
|
||||
|
||||
def parse(self, *, node: Node, span: "Span", error: Exception | None) -> None:
|
||||
self._delegate.parse(node=node, span=span, error=error)
|
||||
|
||||
tool_data = getattr(node, "_node_data", None)
|
||||
if not isinstance(tool_data, ToolNodeData):
|
||||
return
|
||||
|
||||
span.set_attribute("tool.provider.id", tool_data.provider_id)
|
||||
span.set_attribute("tool.provider.type", tool_data.provider_type.value)
|
||||
span.set_attribute("tool.provider.name", tool_data.provider_name)
|
||||
span.set_attribute("tool.name", tool_data.tool_name)
|
||||
span.set_attribute("tool.label", tool_data.tool_label)
|
||||
if tool_data.plugin_unique_identifier:
|
||||
span.set_attribute("tool.plugin.id", tool_data.plugin_unique_identifier)
|
||||
if tool_data.credential_id:
|
||||
span.set_attribute("tool.credential.id", tool_data.credential_id)
|
||||
if tool_data.tool_configurations:
|
||||
span.set_attribute("tool.config", json.dumps(tool_data.tool_configurations, ensure_ascii=False))
|
||||
@ -18,15 +18,12 @@ from typing_extensions import override
|
||||
from configs import dify_config
|
||||
from core.workflow.enums import NodeType
|
||||
from core.workflow.graph_engine.layers.base import GraphEngineLayer
|
||||
from core.workflow.graph_events import GraphNodeEventBase
|
||||
from core.workflow.nodes.base.node import Node
|
||||
from extensions.otel.parser import (
|
||||
from core.workflow.graph_engine.layers.node_parsers import (
|
||||
DefaultNodeOTelParser,
|
||||
LLMNodeOTelParser,
|
||||
NodeOTelParser,
|
||||
RetrievalNodeOTelParser,
|
||||
ToolNodeOTelParser,
|
||||
)
|
||||
from core.workflow.nodes.base.node import Node
|
||||
from extensions.otel.runtime import is_instrument_flag_enabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -75,8 +72,6 @@ class ObservabilityLayer(GraphEngineLayer):
|
||||
"""Initialize parser registry for node types."""
|
||||
self._parsers = {
|
||||
NodeType.TOOL: ToolNodeOTelParser(),
|
||||
NodeType.LLM: LLMNodeOTelParser(),
|
||||
NodeType.KNOWLEDGE_RETRIEVAL: RetrievalNodeOTelParser(),
|
||||
}
|
||||
|
||||
def _get_parser(self, node: Node) -> NodeOTelParser:
|
||||
@ -124,9 +119,7 @@ class ObservabilityLayer(GraphEngineLayer):
|
||||
logger.warning("Failed to create OpenTelemetry span for node %s: %s", node.id, e)
|
||||
|
||||
@override
|
||||
def on_node_run_end(
|
||||
self, node: Node, error: Exception | None, result_event: GraphNodeEventBase | None = None
|
||||
) -> None:
|
||||
def on_node_run_end(self, node: Node, error: Exception | None) -> None:
|
||||
"""
|
||||
Called when a node finishes execution.
|
||||
|
||||
@ -146,7 +139,7 @@ class ObservabilityLayer(GraphEngineLayer):
|
||||
span = node_context.span
|
||||
parser = self._get_parser(node)
|
||||
try:
|
||||
parser.parse(node=node, span=span, error=error, result_event=result_event)
|
||||
parser.parse(node=node, span=span, error=error)
|
||||
span.end()
|
||||
finally:
|
||||
token = node_context.token
|
||||
|
||||
@ -17,7 +17,7 @@ from typing_extensions import override
|
||||
from core.workflow.context import IExecutionContext
|
||||
from core.workflow.graph import Graph
|
||||
from core.workflow.graph_engine.layers.base import GraphEngineLayer
|
||||
from core.workflow.graph_events import GraphNodeEventBase, NodeRunFailedEvent, is_node_result_event
|
||||
from core.workflow.graph_events import GraphNodeEventBase, NodeRunFailedEvent
|
||||
from core.workflow.nodes.base.node import Node
|
||||
|
||||
from .ready_queue import ReadyQueue
|
||||
@ -131,7 +131,6 @@ class Worker(threading.Thread):
|
||||
node.ensure_execution_id()
|
||||
|
||||
error: Exception | None = None
|
||||
result_event: GraphNodeEventBase | None = None
|
||||
|
||||
# Execute the node with preserved context if execution context is provided
|
||||
if self._execution_context is not None:
|
||||
@ -141,26 +140,22 @@ class Worker(threading.Thread):
|
||||
node_events = node.run()
|
||||
for event in node_events:
|
||||
self._event_queue.put(event)
|
||||
if is_node_result_event(event):
|
||||
result_event = event
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
raise
|
||||
finally:
|
||||
self._invoke_node_run_end_hooks(node, error, result_event)
|
||||
self._invoke_node_run_end_hooks(node, error)
|
||||
else:
|
||||
self._invoke_node_run_start_hooks(node)
|
||||
try:
|
||||
node_events = node.run()
|
||||
for event in node_events:
|
||||
self._event_queue.put(event)
|
||||
if is_node_result_event(event):
|
||||
result_event = event
|
||||
except Exception as exc:
|
||||
error = exc
|
||||
raise
|
||||
finally:
|
||||
self._invoke_node_run_end_hooks(node, error, result_event)
|
||||
self._invoke_node_run_end_hooks(node, error)
|
||||
|
||||
def _invoke_node_run_start_hooks(self, node: Node) -> None:
|
||||
"""Invoke on_node_run_start hooks for all layers."""
|
||||
@ -171,13 +166,11 @@ class Worker(threading.Thread):
|
||||
# Silently ignore layer errors to prevent disrupting node execution
|
||||
continue
|
||||
|
||||
def _invoke_node_run_end_hooks(
|
||||
self, node: Node, error: Exception | None, result_event: GraphNodeEventBase | None = None
|
||||
) -> None:
|
||||
def _invoke_node_run_end_hooks(self, node: Node, error: Exception | None) -> None:
|
||||
"""Invoke on_node_run_end hooks for all layers."""
|
||||
for layer in self._layers:
|
||||
try:
|
||||
layer.on_node_run_end(node, error, result_event)
|
||||
layer.on_node_run_end(node, error)
|
||||
except Exception:
|
||||
# Silently ignore layer errors to prevent disrupting node execution
|
||||
continue
|
||||
|
||||
@ -44,7 +44,6 @@ from .node import (
|
||||
NodeRunStartedEvent,
|
||||
NodeRunStreamChunkEvent,
|
||||
NodeRunSucceededEvent,
|
||||
is_node_result_event,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@ -74,5 +73,4 @@ __all__ = [
|
||||
"NodeRunStartedEvent",
|
||||
"NodeRunStreamChunkEvent",
|
||||
"NodeRunSucceededEvent",
|
||||
"is_node_result_event",
|
||||
]
|
||||
|
||||
@ -56,26 +56,3 @@ class NodeRunRetryEvent(NodeRunStartedEvent):
|
||||
|
||||
class NodeRunPauseRequestedEvent(GraphNodeEventBase):
|
||||
reason: PauseReason = Field(..., description="pause reason")
|
||||
|
||||
|
||||
def is_node_result_event(event: GraphNodeEventBase) -> bool:
|
||||
"""
|
||||
Check if an event is a final result event from node execution.
|
||||
|
||||
A result event indicates the completion of a node execution and contains
|
||||
runtime information such as inputs, outputs, or error details.
|
||||
|
||||
Args:
|
||||
event: The event to check
|
||||
|
||||
Returns:
|
||||
True if the event is a node result event (succeeded/failed/paused), False otherwise
|
||||
"""
|
||||
return isinstance(
|
||||
event,
|
||||
(
|
||||
NodeRunSucceededEvent,
|
||||
NodeRunFailedEvent,
|
||||
NodeRunPauseRequestedEvent,
|
||||
),
|
||||
)
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
"""
|
||||
OpenTelemetry node parsers for workflow nodes.
|
||||
|
||||
This module provides parsers that extract node-specific metadata and set
|
||||
OpenTelemetry span attributes according to semantic conventions.
|
||||
"""
|
||||
|
||||
from extensions.otel.parser.base import DefaultNodeOTelParser, NodeOTelParser, safe_json_dumps
|
||||
from extensions.otel.parser.llm import LLMNodeOTelParser
|
||||
from extensions.otel.parser.retrieval import RetrievalNodeOTelParser
|
||||
from extensions.otel.parser.tool import ToolNodeOTelParser
|
||||
|
||||
__all__ = [
|
||||
"DefaultNodeOTelParser",
|
||||
"LLMNodeOTelParser",
|
||||
"NodeOTelParser",
|
||||
"RetrievalNodeOTelParser",
|
||||
"ToolNodeOTelParser",
|
||||
"safe_json_dumps",
|
||||
]
|
||||
@ -1,117 +0,0 @@
|
||||
"""
|
||||
Base parser interface and utilities for OpenTelemetry node parsers.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Protocol
|
||||
|
||||
from opentelemetry.trace import Span
|
||||
from opentelemetry.trace.status import Status, StatusCode
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.file.models import File
|
||||
from core.variables import Segment
|
||||
from core.workflow.enums import NodeType
|
||||
from core.workflow.graph_events import GraphNodeEventBase
|
||||
from core.workflow.nodes.base.node import Node
|
||||
from extensions.otel.semconv.gen_ai import ChainAttributes, GenAIAttributes
|
||||
|
||||
|
||||
def safe_json_dumps(obj: Any, ensure_ascii: bool = False) -> str:
|
||||
"""
|
||||
Safely serialize objects to JSON, handling non-serializable types.
|
||||
|
||||
Handles:
|
||||
- Segment types (ArrayFileSegment, FileSegment, etc.) - converts to their value
|
||||
- File objects - converts to dict using to_dict()
|
||||
- BaseModel objects - converts using model_dump()
|
||||
- Other types - falls back to str() representation
|
||||
|
||||
Args:
|
||||
obj: Object to serialize
|
||||
ensure_ascii: Whether to ensure ASCII encoding
|
||||
|
||||
Returns:
|
||||
JSON string representation of the object
|
||||
"""
|
||||
|
||||
def _convert_value(value: Any) -> Any:
|
||||
"""Recursively convert non-serializable values."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if isinstance(value, Segment):
|
||||
# Convert Segment to its underlying value
|
||||
return _convert_value(value.value)
|
||||
if isinstance(value, File):
|
||||
# Convert File to dict
|
||||
return value.to_dict()
|
||||
if isinstance(value, BaseModel):
|
||||
# Convert Pydantic model to dict
|
||||
return _convert_value(value.model_dump(mode="json"))
|
||||
if isinstance(value, dict):
|
||||
return {k: _convert_value(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_convert_value(item) for item in value]
|
||||
# Fallback to string representation for unknown types
|
||||
return str(value)
|
||||
|
||||
try:
|
||||
converted = _convert_value(obj)
|
||||
return json.dumps(converted, ensure_ascii=ensure_ascii)
|
||||
except (TypeError, ValueError) as e:
|
||||
# If conversion still fails, return error message as string
|
||||
return json.dumps(
|
||||
{"error": f"Failed to serialize: {type(obj).__name__}", "message": str(e)}, ensure_ascii=ensure_ascii
|
||||
)
|
||||
|
||||
|
||||
class NodeOTelParser(Protocol):
|
||||
"""Parser interface for node-specific OpenTelemetry enrichment."""
|
||||
|
||||
def parse(
|
||||
self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class DefaultNodeOTelParser:
|
||||
"""Fallback parser used when no node-specific parser is registered."""
|
||||
|
||||
def parse(
|
||||
self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None
|
||||
) -> None:
|
||||
span.set_attribute("node.id", node.id)
|
||||
if node.execution_id:
|
||||
span.set_attribute("node.execution_id", node.execution_id)
|
||||
if hasattr(node, "node_type") and node.node_type:
|
||||
span.set_attribute("node.type", node.node_type.value)
|
||||
|
||||
span.set_attribute(GenAIAttributes.FRAMEWORK, "dify")
|
||||
|
||||
node_type = getattr(node, "node_type", None)
|
||||
if isinstance(node_type, NodeType):
|
||||
if node_type == NodeType.LLM:
|
||||
span.set_attribute(GenAIAttributes.SPAN_KIND, "LLM")
|
||||
elif node_type == NodeType.KNOWLEDGE_RETRIEVAL:
|
||||
span.set_attribute(GenAIAttributes.SPAN_KIND, "RETRIEVER")
|
||||
elif node_type == NodeType.TOOL:
|
||||
span.set_attribute(GenAIAttributes.SPAN_KIND, "TOOL")
|
||||
else:
|
||||
span.set_attribute(GenAIAttributes.SPAN_KIND, "TASK")
|
||||
else:
|
||||
span.set_attribute(GenAIAttributes.SPAN_KIND, "TASK")
|
||||
|
||||
# Extract inputs and outputs from result_event
|
||||
if result_event and result_event.node_run_result:
|
||||
node_run_result = result_event.node_run_result
|
||||
if node_run_result.inputs:
|
||||
span.set_attribute(ChainAttributes.INPUT_VALUE, safe_json_dumps(node_run_result.inputs))
|
||||
if node_run_result.outputs:
|
||||
span.set_attribute(ChainAttributes.OUTPUT_VALUE, safe_json_dumps(node_run_result.outputs))
|
||||
|
||||
if error:
|
||||
span.record_exception(error)
|
||||
span.set_status(Status(StatusCode.ERROR, str(error)))
|
||||
else:
|
||||
span.set_status(Status(StatusCode.OK))
|
||||
@ -1,155 +0,0 @@
|
||||
"""
|
||||
Parser for LLM nodes that captures LLM-specific metadata.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from opentelemetry.trace import Span
|
||||
|
||||
from core.workflow.graph_events import GraphNodeEventBase
|
||||
from core.workflow.nodes.base.node import Node
|
||||
from extensions.otel.parser.base import DefaultNodeOTelParser, safe_json_dumps
|
||||
from extensions.otel.semconv.gen_ai import LLMAttributes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_input_messages(process_data: Mapping[str, Any]) -> str:
|
||||
"""
|
||||
Format input messages from process_data for LLM spans.
|
||||
|
||||
Args:
|
||||
process_data: Process data containing prompts
|
||||
|
||||
Returns:
|
||||
JSON string of formatted input messages
|
||||
"""
|
||||
try:
|
||||
if not isinstance(process_data, dict):
|
||||
return safe_json_dumps([])
|
||||
|
||||
prompts = process_data.get("prompts", [])
|
||||
if not prompts:
|
||||
return safe_json_dumps([])
|
||||
|
||||
valid_roles = {"system", "user", "assistant", "tool"}
|
||||
input_messages = []
|
||||
for prompt in prompts:
|
||||
if not isinstance(prompt, dict):
|
||||
continue
|
||||
|
||||
role = prompt.get("role", "")
|
||||
text = prompt.get("text", "")
|
||||
|
||||
if not role or role not in valid_roles:
|
||||
continue
|
||||
|
||||
if text:
|
||||
message = {"role": role, "parts": [{"type": "text", "content": text}]}
|
||||
input_messages.append(message)
|
||||
|
||||
return safe_json_dumps(input_messages)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to format input messages: %s", e, exc_info=True)
|
||||
return safe_json_dumps([])
|
||||
|
||||
|
||||
def _format_output_messages(outputs: Mapping[str, Any]) -> str:
|
||||
"""
|
||||
Format output messages from outputs for LLM spans.
|
||||
|
||||
Args:
|
||||
outputs: Output data containing text and finish_reason
|
||||
|
||||
Returns:
|
||||
JSON string of formatted output messages
|
||||
"""
|
||||
try:
|
||||
if not isinstance(outputs, dict):
|
||||
return safe_json_dumps([])
|
||||
|
||||
text = outputs.get("text", "")
|
||||
finish_reason = outputs.get("finish_reason", "")
|
||||
|
||||
if not text:
|
||||
return safe_json_dumps([])
|
||||
|
||||
valid_finish_reasons = {"stop", "length", "content_filter", "tool_call", "error"}
|
||||
if finish_reason not in valid_finish_reasons:
|
||||
finish_reason = "stop"
|
||||
|
||||
output_message = {
|
||||
"role": "assistant",
|
||||
"parts": [{"type": "text", "content": text}],
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
|
||||
return safe_json_dumps([output_message])
|
||||
except Exception as e:
|
||||
logger.warning("Failed to format output messages: %s", e, exc_info=True)
|
||||
return safe_json_dumps([])
|
||||
|
||||
|
||||
class LLMNodeOTelParser:
|
||||
"""Parser for LLM nodes that captures LLM-specific metadata."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._delegate = DefaultNodeOTelParser()
|
||||
|
||||
def parse(
|
||||
self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None
|
||||
) -> None:
|
||||
self._delegate.parse(node=node, span=span, error=error, result_event=result_event)
|
||||
|
||||
if not result_event or not result_event.node_run_result:
|
||||
return
|
||||
|
||||
node_run_result = result_event.node_run_result
|
||||
process_data = node_run_result.process_data or {}
|
||||
outputs = node_run_result.outputs or {}
|
||||
|
||||
# Extract usage data (from process_data or outputs)
|
||||
usage_data = process_data.get("usage") or outputs.get("usage") or {}
|
||||
|
||||
# Model and provider information
|
||||
model_name = process_data.get("model_name") or ""
|
||||
model_provider = process_data.get("model_provider") or ""
|
||||
|
||||
if model_name:
|
||||
span.set_attribute(LLMAttributes.REQUEST_MODEL, model_name)
|
||||
if model_provider:
|
||||
span.set_attribute(LLMAttributes.PROVIDER_NAME, model_provider)
|
||||
|
||||
# Token usage
|
||||
if usage_data:
|
||||
prompt_tokens = usage_data.get("prompt_tokens", 0)
|
||||
completion_tokens = usage_data.get("completion_tokens", 0)
|
||||
total_tokens = usage_data.get("total_tokens", 0)
|
||||
|
||||
span.set_attribute(LLMAttributes.USAGE_INPUT_TOKENS, prompt_tokens)
|
||||
span.set_attribute(LLMAttributes.USAGE_OUTPUT_TOKENS, completion_tokens)
|
||||
span.set_attribute(LLMAttributes.USAGE_TOTAL_TOKENS, total_tokens)
|
||||
|
||||
# Prompts and completion
|
||||
prompts = process_data.get("prompts", [])
|
||||
if prompts:
|
||||
prompts_json = safe_json_dumps(prompts)
|
||||
span.set_attribute(LLMAttributes.PROMPT, prompts_json)
|
||||
|
||||
text_output = str(outputs.get("text", ""))
|
||||
if text_output:
|
||||
span.set_attribute(LLMAttributes.COMPLETION, text_output)
|
||||
|
||||
# Finish reason
|
||||
finish_reason = outputs.get("finish_reason") or ""
|
||||
if finish_reason:
|
||||
span.set_attribute(LLMAttributes.RESPONSE_FINISH_REASON, finish_reason)
|
||||
|
||||
# Structured input/output messages
|
||||
gen_ai_input_message = _format_input_messages(process_data)
|
||||
gen_ai_output_message = _format_output_messages(outputs)
|
||||
|
||||
span.set_attribute(LLMAttributes.INPUT_MESSAGE, gen_ai_input_message)
|
||||
span.set_attribute(LLMAttributes.OUTPUT_MESSAGE, gen_ai_output_message)
|
||||
@ -1,105 +0,0 @@
|
||||
"""
|
||||
Parser for knowledge retrieval nodes that captures retrieval-specific metadata.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from opentelemetry.trace import Span
|
||||
|
||||
from core.variables import Segment
|
||||
from core.workflow.graph_events import GraphNodeEventBase
|
||||
from core.workflow.nodes.base.node import Node
|
||||
from extensions.otel.parser.base import DefaultNodeOTelParser, safe_json_dumps
|
||||
from extensions.otel.semconv.gen_ai import RetrieverAttributes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_retrieval_documents(retrieval_documents: list[Any]) -> list:
|
||||
"""
|
||||
Format retrieval documents for semantic conventions.
|
||||
|
||||
Args:
|
||||
retrieval_documents: List of retrieval document dictionaries
|
||||
|
||||
Returns:
|
||||
List of formatted semantic documents
|
||||
"""
|
||||
try:
|
||||
if not isinstance(retrieval_documents, list):
|
||||
return []
|
||||
|
||||
semantic_documents = []
|
||||
for doc in retrieval_documents:
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
|
||||
metadata = doc.get("metadata", {})
|
||||
content = doc.get("content", "")
|
||||
title = doc.get("title", "")
|
||||
score = metadata.get("score", 0.0)
|
||||
document_id = metadata.get("document_id", "")
|
||||
|
||||
semantic_metadata = {}
|
||||
if title:
|
||||
semantic_metadata["title"] = title
|
||||
if metadata.get("source"):
|
||||
semantic_metadata["source"] = metadata["source"]
|
||||
elif metadata.get("_source"):
|
||||
semantic_metadata["source"] = metadata["_source"]
|
||||
if metadata.get("doc_metadata"):
|
||||
doc_metadata = metadata["doc_metadata"]
|
||||
if isinstance(doc_metadata, dict):
|
||||
semantic_metadata.update(doc_metadata)
|
||||
|
||||
semantic_doc = {
|
||||
"document": {"content": content, "metadata": semantic_metadata, "score": score, "id": document_id}
|
||||
}
|
||||
semantic_documents.append(semantic_doc)
|
||||
|
||||
return semantic_documents
|
||||
except Exception as e:
|
||||
logger.warning("Failed to format retrieval documents: %s", e, exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
class RetrievalNodeOTelParser:
|
||||
"""Parser for knowledge retrieval nodes that captures retrieval-specific metadata."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._delegate = DefaultNodeOTelParser()
|
||||
|
||||
def parse(
|
||||
self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None
|
||||
) -> None:
|
||||
self._delegate.parse(node=node, span=span, error=error, result_event=result_event)
|
||||
|
||||
if not result_event or not result_event.node_run_result:
|
||||
return
|
||||
|
||||
node_run_result = result_event.node_run_result
|
||||
inputs = node_run_result.inputs or {}
|
||||
outputs = node_run_result.outputs or {}
|
||||
|
||||
# Extract query from inputs
|
||||
query = str(inputs.get("query", "")) if inputs else ""
|
||||
if query:
|
||||
span.set_attribute(RetrieverAttributes.QUERY, query)
|
||||
|
||||
# Extract and format retrieval documents from outputs
|
||||
result_value = outputs.get("result") if outputs else None
|
||||
retrieval_documents: list[Any] = []
|
||||
if result_value:
|
||||
value_to_check = result_value
|
||||
if isinstance(result_value, Segment):
|
||||
value_to_check = result_value.value
|
||||
|
||||
if isinstance(value_to_check, (list, Sequence)):
|
||||
retrieval_documents = list(value_to_check)
|
||||
|
||||
if retrieval_documents:
|
||||
semantic_retrieval_documents = _format_retrieval_documents(retrieval_documents)
|
||||
semantic_retrieval_documents_json = safe_json_dumps(semantic_retrieval_documents)
|
||||
span.set_attribute(RetrieverAttributes.DOCUMENT, semantic_retrieval_documents_json)
|
||||
@ -1,47 +0,0 @@
|
||||
"""
|
||||
Parser for tool nodes that captures tool-specific metadata.
|
||||
"""
|
||||
|
||||
from opentelemetry.trace import Span
|
||||
|
||||
from core.workflow.enums import WorkflowNodeExecutionMetadataKey
|
||||
from core.workflow.graph_events import GraphNodeEventBase
|
||||
from core.workflow.nodes.base.node import Node
|
||||
from core.workflow.nodes.tool.entities import ToolNodeData
|
||||
from extensions.otel.parser.base import DefaultNodeOTelParser, safe_json_dumps
|
||||
from extensions.otel.semconv.gen_ai import ToolAttributes
|
||||
|
||||
|
||||
class ToolNodeOTelParser:
|
||||
"""Parser for tool nodes that captures tool-specific metadata."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._delegate = DefaultNodeOTelParser()
|
||||
|
||||
def parse(
|
||||
self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None
|
||||
) -> None:
|
||||
self._delegate.parse(node=node, span=span, error=error, result_event=result_event)
|
||||
|
||||
tool_data = getattr(node, "_node_data", None)
|
||||
if not isinstance(tool_data, ToolNodeData):
|
||||
return
|
||||
|
||||
span.set_attribute(ToolAttributes.TOOL_NAME, node.title)
|
||||
span.set_attribute(ToolAttributes.TOOL_TYPE, tool_data.provider_type.value)
|
||||
|
||||
# Extract tool info from metadata (consistent with aliyun_trace)
|
||||
tool_info = {}
|
||||
if result_event and result_event.node_run_result:
|
||||
node_run_result = result_event.node_run_result
|
||||
if node_run_result.metadata:
|
||||
tool_info = node_run_result.metadata.get(WorkflowNodeExecutionMetadataKey.TOOL_INFO, {})
|
||||
|
||||
if tool_info:
|
||||
span.set_attribute(ToolAttributes.TOOL_DESCRIPTION, safe_json_dumps(tool_info))
|
||||
|
||||
if result_event and result_event.node_run_result and result_event.node_run_result.inputs:
|
||||
span.set_attribute(ToolAttributes.TOOL_CALL_ARGUMENTS, safe_json_dumps(result_event.node_run_result.inputs))
|
||||
|
||||
if result_event and result_event.node_run_result and result_event.node_run_result.outputs:
|
||||
span.set_attribute(ToolAttributes.TOOL_CALL_RESULT, safe_json_dumps(result_event.node_run_result.outputs))
|
||||
@ -1,13 +1,6 @@
|
||||
"""Semantic convention shortcuts for Dify-specific spans."""
|
||||
|
||||
from .dify import DifySpanAttributes
|
||||
from .gen_ai import ChainAttributes, GenAIAttributes, LLMAttributes, RetrieverAttributes, ToolAttributes
|
||||
from .gen_ai import GenAIAttributes
|
||||
|
||||
__all__ = [
|
||||
"ChainAttributes",
|
||||
"DifySpanAttributes",
|
||||
"GenAIAttributes",
|
||||
"LLMAttributes",
|
||||
"RetrieverAttributes",
|
||||
"ToolAttributes",
|
||||
]
|
||||
__all__ = ["DifySpanAttributes", "GenAIAttributes"]
|
||||
|
||||
@ -62,37 +62,3 @@ class ToolAttributes:
|
||||
|
||||
TOOL_CALL_RESULT = "gen_ai.tool.call.result"
|
||||
"""Tool invocation result."""
|
||||
|
||||
|
||||
class LLMAttributes:
|
||||
"""LLM operation attribute keys."""
|
||||
|
||||
REQUEST_MODEL = "gen_ai.request.model"
|
||||
"""Model identifier."""
|
||||
|
||||
PROVIDER_NAME = "gen_ai.provider.name"
|
||||
"""Provider name."""
|
||||
|
||||
USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
|
||||
"""Number of input tokens."""
|
||||
|
||||
USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
|
||||
"""Number of output tokens."""
|
||||
|
||||
USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens"
|
||||
"""Total number of tokens."""
|
||||
|
||||
PROMPT = "gen_ai.prompt"
|
||||
"""Prompt text."""
|
||||
|
||||
COMPLETION = "gen_ai.completion"
|
||||
"""Completion text."""
|
||||
|
||||
RESPONSE_FINISH_REASON = "gen_ai.response.finish_reason"
|
||||
"""Finish reason for the response."""
|
||||
|
||||
INPUT_MESSAGE = "gen_ai.input.messages"
|
||||
"""Input messages in structured format."""
|
||||
|
||||
OUTPUT_MESSAGE = "gen_ai.output.messages"
|
||||
"""Output messages in structured format."""
|
||||
|
||||
@ -64,7 +64,7 @@ dependencies = [
|
||||
"pandas[excel,output-formatting,performance]~=2.2.2",
|
||||
"psycogreen~=1.0.2",
|
||||
"psycopg2-binary~=2.9.6",
|
||||
"pycryptodome==3.23.0",
|
||||
"pycryptodome==3.19.1",
|
||||
"pydantic~=2.11.4",
|
||||
"pydantic-extra-types~=2.10.3",
|
||||
"pydantic-settings~=2.11.0",
|
||||
|
||||
@ -131,7 +131,7 @@ class BillingService:
|
||||
headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": cls.secret_key}
|
||||
|
||||
url = f"{cls.base_url}{endpoint}"
|
||||
response = httpx.request(method, url, json=json, params=params, headers=headers, follow_redirects=True)
|
||||
response = httpx.request(method, url, json=json, params=params, headers=headers)
|
||||
if method == "GET" and response.status_code != httpx.codes.OK:
|
||||
raise ValueError("Unable to retrieve billing information. Please try again later or contact support.")
|
||||
if method == "PUT":
|
||||
@ -143,9 +143,6 @@ class BillingService:
|
||||
raise ValueError("Invalid arguments.")
|
||||
if method == "POST" and response.status_code != httpx.codes.OK:
|
||||
raise ValueError(f"Unable to send request to {url}. Please try again later or contact support.")
|
||||
if method == "DELETE" and response.status_code != httpx.codes.OK:
|
||||
logger.error("billing_service: DELETE response: %s %s", response.status_code, response.text)
|
||||
raise ValueError(f"Unable to process delete request {url}. Please try again later or contact support.")
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
@ -168,7 +165,7 @@ class BillingService:
|
||||
def delete_account(cls, account_id: str):
|
||||
"""Delete account."""
|
||||
params = {"account_id": account_id}
|
||||
return cls._send_request("DELETE", "/account", params=params)
|
||||
return cls._send_request("DELETE", "/account/", params=params)
|
||||
|
||||
@classmethod
|
||||
def is_email_in_freeze(cls, email: str) -> bool:
|
||||
|
||||
@ -99,38 +99,3 @@ def mock_is_instrument_flag_enabled_true():
|
||||
"""Mock is_instrument_flag_enabled to return True."""
|
||||
with patch("core.workflow.graph_engine.layers.observability.is_instrument_flag_enabled", return_value=True):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_retrieval_node():
|
||||
"""Create a mock Knowledge Retrieval Node."""
|
||||
node = MagicMock()
|
||||
node.id = "test-retrieval-node-id"
|
||||
node.title = "Retrieval Node"
|
||||
node.execution_id = "test-retrieval-execution-id"
|
||||
node.node_type = NodeType.KNOWLEDGE_RETRIEVAL
|
||||
return node
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_result_event():
|
||||
"""Create a mock result event with NodeRunResult."""
|
||||
from datetime import datetime
|
||||
|
||||
from core.workflow.graph_events.node import NodeRunSucceededEvent
|
||||
from core.workflow.node_events.base import NodeRunResult
|
||||
|
||||
node_run_result = NodeRunResult(
|
||||
inputs={"query": "test query"},
|
||||
outputs={"result": [{"content": "test content", "metadata": {}}]},
|
||||
process_data={},
|
||||
metadata={},
|
||||
)
|
||||
|
||||
return NodeRunSucceededEvent(
|
||||
id="test-execution-id",
|
||||
node_id="test-node-id",
|
||||
node_type=NodeType.LLM,
|
||||
start_at=datetime.now(),
|
||||
node_run_result=node_run_result,
|
||||
)
|
||||
|
||||
@ -4,8 +4,7 @@ Tests for ObservabilityLayer.
|
||||
Test coverage:
|
||||
- Initialization and enable/disable logic
|
||||
- Node span lifecycle (start, end, error handling)
|
||||
- Parser integration (default, tool, LLM, and retrieval parsers)
|
||||
- Result event parameter extraction (inputs/outputs)
|
||||
- Parser integration (default and tool-specific)
|
||||
- Graph lifecycle management
|
||||
- Disabled mode behavior
|
||||
"""
|
||||
@ -135,101 +134,9 @@ class TestObservabilityLayerParserIntegration:
|
||||
assert len(spans) == 1
|
||||
attrs = spans[0].attributes
|
||||
assert attrs["node.id"] == mock_tool_node.id
|
||||
assert attrs["gen_ai.tool.name"] == mock_tool_node.title
|
||||
assert attrs["gen_ai.tool.type"] == mock_tool_node._node_data.provider_type.value
|
||||
|
||||
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True)
|
||||
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
|
||||
def test_llm_parser_used_for_llm_node(
|
||||
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node, mock_result_event
|
||||
):
|
||||
"""Test that LLM parser is used for LLM nodes and extracts LLM-specific attributes."""
|
||||
from core.workflow.node_events.base import NodeRunResult
|
||||
|
||||
mock_result_event.node_run_result = NodeRunResult(
|
||||
inputs={},
|
||||
outputs={"text": "test completion", "finish_reason": "stop"},
|
||||
process_data={
|
||||
"model_name": "gpt-4",
|
||||
"model_provider": "openai",
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
|
||||
"prompts": [{"role": "user", "text": "test prompt"}],
|
||||
},
|
||||
metadata={},
|
||||
)
|
||||
|
||||
layer = ObservabilityLayer()
|
||||
layer.on_graph_start()
|
||||
|
||||
layer.on_node_run_start(mock_llm_node)
|
||||
layer.on_node_run_end(mock_llm_node, None, mock_result_event)
|
||||
|
||||
spans = memory_span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
attrs = spans[0].attributes
|
||||
assert attrs["node.id"] == mock_llm_node.id
|
||||
assert attrs["gen_ai.request.model"] == "gpt-4"
|
||||
assert attrs["gen_ai.provider.name"] == "openai"
|
||||
assert attrs["gen_ai.usage.input_tokens"] == 10
|
||||
assert attrs["gen_ai.usage.output_tokens"] == 20
|
||||
assert attrs["gen_ai.usage.total_tokens"] == 30
|
||||
assert attrs["gen_ai.completion"] == "test completion"
|
||||
assert attrs["gen_ai.response.finish_reason"] == "stop"
|
||||
|
||||
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True)
|
||||
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
|
||||
def test_retrieval_parser_used_for_retrieval_node(
|
||||
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_retrieval_node, mock_result_event
|
||||
):
|
||||
"""Test that retrieval parser is used for retrieval nodes and extracts retrieval-specific attributes."""
|
||||
from core.workflow.node_events.base import NodeRunResult
|
||||
|
||||
mock_result_event.node_run_result = NodeRunResult(
|
||||
inputs={"query": "test query"},
|
||||
outputs={"result": [{"content": "test content", "metadata": {"score": 0.9, "document_id": "doc1"}}]},
|
||||
process_data={},
|
||||
metadata={},
|
||||
)
|
||||
|
||||
layer = ObservabilityLayer()
|
||||
layer.on_graph_start()
|
||||
|
||||
layer.on_node_run_start(mock_retrieval_node)
|
||||
layer.on_node_run_end(mock_retrieval_node, None, mock_result_event)
|
||||
|
||||
spans = memory_span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
attrs = spans[0].attributes
|
||||
assert attrs["node.id"] == mock_retrieval_node.id
|
||||
assert attrs["retrieval.query"] == "test query"
|
||||
assert "retrieval.document" in attrs
|
||||
|
||||
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True)
|
||||
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
|
||||
def test_result_event_extracts_inputs_and_outputs(
|
||||
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_start_node, mock_result_event
|
||||
):
|
||||
"""Test that result_event parameter allows parsers to extract inputs and outputs."""
|
||||
from core.workflow.node_events.base import NodeRunResult
|
||||
|
||||
mock_result_event.node_run_result = NodeRunResult(
|
||||
inputs={"input_key": "input_value"},
|
||||
outputs={"output_key": "output_value"},
|
||||
process_data={},
|
||||
metadata={},
|
||||
)
|
||||
|
||||
layer = ObservabilityLayer()
|
||||
layer.on_graph_start()
|
||||
|
||||
layer.on_node_run_start(mock_start_node)
|
||||
layer.on_node_run_end(mock_start_node, None, mock_result_event)
|
||||
|
||||
spans = memory_span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
attrs = spans[0].attributes
|
||||
assert "input.value" in attrs
|
||||
assert "output.value" in attrs
|
||||
assert attrs["tool.provider.id"] == mock_tool_node._node_data.provider_id
|
||||
assert attrs["tool.provider.type"] == mock_tool_node._node_data.provider_type.value
|
||||
assert attrs["tool.name"] == mock_tool_node._node_data.tool_name
|
||||
|
||||
|
||||
class TestObservabilityLayerGraphLifecycle:
|
||||
|
||||
@ -171,26 +171,22 @@ class TestBillingServiceSendRequest:
|
||||
"status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND]
|
||||
)
|
||||
def test_delete_request_non_200_with_valid_json(self, mock_httpx_request, mock_billing_config, status_code):
|
||||
"""Test DELETE request with non-200 status code raises ValueError.
|
||||
"""Test DELETE request with non-200 status code but valid JSON response.
|
||||
|
||||
DELETE now checks status code and raises ValueError for non-200 responses.
|
||||
DELETE doesn't check status code, so it returns the error JSON.
|
||||
"""
|
||||
# Arrange
|
||||
error_response = {"detail": "Error message"}
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = status_code
|
||||
mock_response.text = "Error message"
|
||||
mock_response.json.return_value = error_response
|
||||
mock_httpx_request.return_value = mock_response
|
||||
|
||||
# Act & Assert
|
||||
with patch("services.billing_service.logger") as mock_logger:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
BillingService._send_request("DELETE", "/test", json={"key": "value"})
|
||||
assert "Unable to process delete request" in str(exc_info.value)
|
||||
# Verify error logging
|
||||
mock_logger.error.assert_called_once()
|
||||
assert "DELETE response" in str(mock_logger.error.call_args)
|
||||
# Act
|
||||
result = BillingService._send_request("DELETE", "/test", json={"key": "value"})
|
||||
|
||||
# Assert
|
||||
assert result == error_response
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND]
|
||||
@ -214,9 +210,9 @@ class TestBillingServiceSendRequest:
|
||||
"status_code", [httpx.codes.BAD_REQUEST, httpx.codes.INTERNAL_SERVER_ERROR, httpx.codes.NOT_FOUND]
|
||||
)
|
||||
def test_delete_request_non_200_with_invalid_json(self, mock_httpx_request, mock_billing_config, status_code):
|
||||
"""Test DELETE request with non-200 status code raises ValueError before JSON parsing.
|
||||
"""Test DELETE request with non-200 status code and invalid JSON response raises exception.
|
||||
|
||||
DELETE now checks status code before calling response.json(), so ValueError is raised
|
||||
DELETE doesn't check status code, so it calls response.json() which raises JSONDecodeError
|
||||
when the response cannot be parsed as JSON (e.g., empty response).
|
||||
"""
|
||||
# Arrange
|
||||
@ -227,13 +223,8 @@ class TestBillingServiceSendRequest:
|
||||
mock_httpx_request.return_value = mock_response
|
||||
|
||||
# Act & Assert
|
||||
with patch("services.billing_service.logger") as mock_logger:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
BillingService._send_request("DELETE", "/test", json={"key": "value"})
|
||||
assert "Unable to process delete request" in str(exc_info.value)
|
||||
# Verify error logging
|
||||
mock_logger.error.assert_called_once()
|
||||
assert "DELETE response" in str(mock_logger.error.call_args)
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
BillingService._send_request("DELETE", "/test", json={"key": "value"})
|
||||
|
||||
def test_retry_on_request_error(self, mock_httpx_request, mock_billing_config):
|
||||
"""Test that _send_request retries on httpx.RequestError."""
|
||||
@ -798,7 +789,7 @@ class TestBillingServiceAccountManagement:
|
||||
|
||||
# Assert
|
||||
assert result == expected_response
|
||||
mock_send_request.assert_called_once_with("DELETE", "/account", params={"account_id": account_id})
|
||||
mock_send_request.assert_called_once_with("DELETE", "/account/", params={"account_id": account_id})
|
||||
|
||||
def test_is_email_in_freeze_true(self, mock_send_request):
|
||||
"""Test checking if email is frozen (returns True)."""
|
||||
|
||||
33
api/uv.lock
generated
33
api/uv.lock
generated
@ -1633,7 +1633,7 @@ requires-dist = [
|
||||
{ name = "pandas", extras = ["excel", "output-formatting", "performance"], specifier = "~=2.2.2" },
|
||||
{ name = "psycogreen", specifier = "~=1.0.2" },
|
||||
{ name = "psycopg2-binary", specifier = "~=2.9.6" },
|
||||
{ name = "pycryptodome", specifier = "==3.23.0" },
|
||||
{ name = "pycryptodome", specifier = "==3.19.1" },
|
||||
{ name = "pydantic", specifier = "~=2.11.4" },
|
||||
{ name = "pydantic-extra-types", specifier = "~=2.10.3" },
|
||||
{ name = "pydantic-settings", specifier = "~=2.11.0" },
|
||||
@ -4796,21 +4796,20 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pycryptodome"
|
||||
version = "3.23.0"
|
||||
version = "3.19.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/38/42a8855ff1bf568c61ca6557e2203f318fb7afeadaf2eb8ecfdbde107151/pycryptodome-3.19.1.tar.gz", hash = "sha256:8ae0dd1bcfada451c35f9e29a3e5db385caabc190f98e4a80ad02a61098fb776", size = 4782144, upload-time = "2023-12-28T06:52:40.741Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ef/4931bc30674f0de0ca0e827b58c8b0c17313a8eae2754976c610b866118b/pycryptodome-3.19.1-cp35-abi3-macosx_10_9_universal2.whl", hash = "sha256:67939a3adbe637281c611596e44500ff309d547e932c449337649921b17b6297", size = 2417027, upload-time = "2023-12-28T06:51:50.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/e6/238c53267fd8d223029c0a0d3730cb1b6594d60f62e40c4184703dc490b1/pycryptodome-3.19.1-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:11ddf6c9b52116b62223b6a9f4741bc4f62bb265392a4463282f7f34bb287180", size = 1579728, upload-time = "2023-12-28T06:51:52.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/87/7181c42c8d5ba89822a4b824830506d0aeec02959bb893614767e3279846/pycryptodome-3.19.1-cp35-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3e6f89480616781d2a7f981472d0cdb09b9da9e8196f43c1234eff45c915766", size = 2051440, upload-time = "2023-12-28T06:51:55.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/dd/332c4c0055527d17dac317ed9f9c864fc047b627d82f4b9a56c110afc6fc/pycryptodome-3.19.1-cp35-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27e1efcb68993b7ce5d1d047a46a601d41281bba9f1971e6be4aa27c69ab8065", size = 2125379, upload-time = "2023-12-28T06:51:58.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/9e/320b885ea336c218ff54ec2b276cd70ba6904e4f5a14a771ed39a2c47d59/pycryptodome-3.19.1-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c6273ca5a03b672e504995529b8bae56da0ebb691d8ef141c4aa68f60765700", size = 2153951, upload-time = "2023-12-28T06:52:01.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/54/8ae0c43d1257b41bc9d3277c3f875174fd8ad86b9567f0b8609b99c938ee/pycryptodome-3.19.1-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:b0bfe61506795877ff974f994397f0c862d037f6f1c0bfc3572195fc00833b96", size = 2044041, upload-time = "2023-12-28T06:52:03.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/93/f8450a92cc38541c3ba1f4cb4e267e15ae6d6678ca617476d52c3a3764d4/pycryptodome-3.19.1-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:f34976c5c8eb79e14c7d970fb097482835be8d410a4220f86260695ede4c3e17", size = 2182446, upload-time = "2023-12-28T06:52:05.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/cd/ed6e429fb0792ce368f66e83246264dd3a7a045b0b1e63043ed22a063ce5/pycryptodome-3.19.1-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:7c9e222d0976f68d0cf6409cfea896676ddc1d98485d601e9508f90f60e2b0a2", size = 2144914, upload-time = "2023-12-28T06:52:07.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/23/b064bd4cfbf2cc5f25afcde0e7c880df5b20798172793137ba4b62d82e72/pycryptodome-3.19.1-cp35-abi3-win32.whl", hash = "sha256:4805e053571140cb37cf153b5c72cd324bb1e3e837cbe590a19f69b6cf85fd03", size = 1713105, upload-time = "2023-12-28T06:52:09.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/e0/ded1968a5257ab34216a0f8db7433897a2337d59e6d03be113713b346ea2/pycryptodome-3.19.1-cp35-abi3-win_amd64.whl", hash = "sha256:a470237ee71a1efd63f9becebc0ad84b88ec28e6784a2047684b693f458f41b7", size = 1749222, upload-time = "2023-12-28T06:52:11.534Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5004,11 +5003,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.6.2"
|
||||
version = "6.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/bb/a44bab1ac3c54dbcf653d7b8bcdee93dddb2d3bf025a3912cacb8149a2f2/pypdf-6.6.2.tar.gz", hash = "sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016", size = 5281850, upload-time = "2026-01-26T11:57:55.964Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/f4/801632a8b62a805378b6af2b5a3fcbfd8923abf647e0ed1af846a83433b2/pypdf-6.6.0.tar.gz", hash = "sha256:4c887ef2ea38d86faded61141995a3c7d068c9d6ae8477be7ae5de8a8e16592f", size = 5281063, upload-time = "2026-01-09T11:20:11.786Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/be/549aaf1dfa4ab4aed29b09703d2fb02c4366fc1f05e880948c296c5764b9/pypdf-6.6.2-py3-none-any.whl", hash = "sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba", size = 329132, upload-time = "2026-01-26T11:57:54.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/ba/96f99276194f720e74ed99905a080f6e77810558874e8935e580331b46de/pypdf-6.6.0-py3-none-any.whl", hash = "sha256:bca9091ef6de36c7b1a81e09327c554b7ce51e88dad68f5890c2b4a4417f1fd7", size = 328963, upload-time = "2026-01-09T11:20:09.278Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -1,20 +1,10 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
import InputWithCopy from './index'
|
||||
|
||||
// Create a controllable mock for useClipboard
|
||||
const mockCopy = vi.fn()
|
||||
let mockCopied = false
|
||||
const mockReset = vi.fn()
|
||||
|
||||
vi.mock('foxact/use-clipboard', () => ({
|
||||
useClipboard: () => ({
|
||||
copy: mockCopy,
|
||||
copied: mockCopied,
|
||||
reset: mockReset,
|
||||
}),
|
||||
}))
|
||||
// Mock navigator.clipboard for foxact/use-clipboard
|
||||
const mockWriteText = vi.fn(() => Promise.resolve())
|
||||
|
||||
// Mock the i18n hook with custom translations for test assertions
|
||||
vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
@ -27,9 +17,13 @@ vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
describe('InputWithCopy component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCopy.mockClear()
|
||||
mockReset.mockClear()
|
||||
mockCopied = false
|
||||
mockWriteText.mockClear()
|
||||
// Setup navigator.clipboard mock
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('renders correctly with default props', () => {
|
||||
@ -50,27 +44,31 @@ describe('InputWithCopy component', () => {
|
||||
expect(copyButton).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls copy function with input value when copy button is clicked', () => {
|
||||
it('copies input value when copy button is clicked', async () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('test value')
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith('test value')
|
||||
})
|
||||
})
|
||||
|
||||
it('calls copy function with custom value when copyValue prop is provided', () => {
|
||||
it('copies custom value when copyValue prop is provided', async () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<InputWithCopy value="display value" onChange={mockOnChange} copyValue="custom copy value" />)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('custom copy value')
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith('custom copy value')
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onCopy callback when copy button is clicked', () => {
|
||||
it('calls onCopy callback when copy button is clicked', async () => {
|
||||
const onCopyMock = vi.fn()
|
||||
const mockOnChange = vi.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} onCopy={onCopyMock} />)
|
||||
@ -78,21 +76,25 @@ describe('InputWithCopy component', () => {
|
||||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
expect(onCopyMock).toHaveBeenCalledWith('test value')
|
||||
await waitFor(() => {
|
||||
expect(onCopyMock).toHaveBeenCalledWith('test value')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows copied state when copied is true', () => {
|
||||
mockCopied = true
|
||||
it('shows copied state after successful copy', async () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
// Hover over the button to trigger tooltip
|
||||
fireEvent.mouseEnter(copyButton)
|
||||
|
||||
// The icon should change to filled version when copied
|
||||
// We verify the component renders without error in copied state
|
||||
expect(copyButton).toBeInTheDocument()
|
||||
// Check if the tooltip shows "Copied" state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Copied')).toBeInTheDocument()
|
||||
}, { timeout: 2000 })
|
||||
})
|
||||
|
||||
it('passes through all input props correctly', () => {
|
||||
@ -115,22 +117,22 @@ describe('InputWithCopy component', () => {
|
||||
expect(input).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('handles empty value correctly', () => {
|
||||
it('handles empty value correctly', async () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<InputWithCopy value="" onChange={mockOnChange} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
const input = screen.getByDisplayValue('')
|
||||
const copyButton = screen.getByRole('button')
|
||||
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveValue('')
|
||||
expect(copyButton).toBeInTheDocument()
|
||||
|
||||
// Clicking copy button with empty value should call copy with empty string
|
||||
fireEvent.click(copyButton)
|
||||
expect(mockCopy).toHaveBeenCalledWith('')
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
|
||||
it('maintains focus on input after copy', () => {
|
||||
it('maintains focus on input after copy', async () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
|
||||
|
||||
|
||||
@ -1,426 +0,0 @@
|
||||
import type { DefaultModelResponse, Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { RerankingModeEnum } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { ensureRerankModelSelected, isReRankModelSelected } from './check-rerank-model'
|
||||
|
||||
// Test data factory
|
||||
const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createModelItem = (model: string): ModelItem => ({
|
||||
model,
|
||||
label: { en_US: model, zh_Hans: model },
|
||||
model_type: ModelTypeEnum.rerank,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
})
|
||||
|
||||
const createRerankModelList = (): Model[] => [
|
||||
{
|
||||
provider: 'openai',
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
models: [
|
||||
createModelItem('gpt-4-turbo'),
|
||||
createModelItem('gpt-3.5-turbo'),
|
||||
],
|
||||
status: ModelStatusEnum.active,
|
||||
},
|
||||
{
|
||||
provider: 'cohere',
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
|
||||
models: [
|
||||
createModelItem('rerank-english-v2.0'),
|
||||
createModelItem('rerank-multilingual-v2.0'),
|
||||
],
|
||||
status: ModelStatusEnum.active,
|
||||
},
|
||||
]
|
||||
|
||||
const createDefaultRerankModel = (): DefaultModelResponse => ({
|
||||
model: 'rerank-english-v2.0',
|
||||
model_type: ModelTypeEnum.rerank,
|
||||
provider: {
|
||||
provider: 'cohere',
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
},
|
||||
})
|
||||
|
||||
describe('check-rerank-model', () => {
|
||||
describe('isReRankModelSelected', () => {
|
||||
describe('Core Functionality', () => {
|
||||
it('should return true when reranking is disabled', () => {
|
||||
const config = createRetrievalConfig({
|
||||
reranking_enable: false,
|
||||
})
|
||||
|
||||
const result = isReRankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankModelList: createRerankModelList(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for economy indexMethod', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: true,
|
||||
})
|
||||
|
||||
const result = isReRankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankModelList: createRerankModelList(),
|
||||
indexMethod: 'economy',
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when model is selected and valid', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-english-v2.0',
|
||||
},
|
||||
})
|
||||
|
||||
const result = isReRankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankModelList: createRerankModelList(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should return false when reranking enabled but no model selected for semantic search', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = isReRankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankModelList: createRerankModelList(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when reranking enabled but no model selected for fullText search', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.fullText,
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = isReRankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankModelList: createRerankModelList(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for hybrid search without WeightedScore mode and no model selected', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.hybrid,
|
||||
reranking_enable: true,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = isReRankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankModelList: createRerankModelList(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true for hybrid search with WeightedScore mode even without model', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.hybrid,
|
||||
reranking_enable: true,
|
||||
reranking_mode: RerankingModeEnum.WeightedScore,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = isReRankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankModelList: createRerankModelList(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when provider exists but model not found', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'non-existent-model',
|
||||
},
|
||||
})
|
||||
|
||||
const result = isReRankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankModelList: createRerankModelList(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when provider not found in list', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'non-existent-provider',
|
||||
reranking_model_name: 'some-model',
|
||||
},
|
||||
})
|
||||
|
||||
const result = isReRankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankModelList: createRerankModelList(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true with empty rerankModelList when reranking disabled', () => {
|
||||
const config = createRetrievalConfig({
|
||||
reranking_enable: false,
|
||||
})
|
||||
|
||||
const result = isReRankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankModelList: [],
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when indexMethod is undefined', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: true,
|
||||
})
|
||||
|
||||
const result = isReRankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankModelList: createRerankModelList(),
|
||||
indexMethod: undefined,
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureRerankModelSelected', () => {
|
||||
describe('Core Functionality', () => {
|
||||
it('should return original config when reranking model already selected', () => {
|
||||
const config = createRetrievalConfig({
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-english-v2.0',
|
||||
},
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: createDefaultRerankModel(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result).toEqual(config)
|
||||
})
|
||||
|
||||
it('should apply default model when reranking enabled but no model selected', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: createDefaultRerankModel(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result.reranking_model).toEqual({
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-english-v2.0',
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply default model for hybrid search method', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.hybrid,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: createDefaultRerankModel(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result.reranking_model).toEqual({
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-english-v2.0',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should return original config when indexMethod is not high_quality', () => {
|
||||
const config = createRetrievalConfig({
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: createDefaultRerankModel(),
|
||||
indexMethod: 'economy',
|
||||
})
|
||||
|
||||
expect(result).toEqual(config)
|
||||
})
|
||||
|
||||
it('should return original config when rerankDefaultModel is null', () => {
|
||||
const config = createRetrievalConfig({
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: null as unknown as DefaultModelResponse,
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result).toEqual(config)
|
||||
})
|
||||
|
||||
it('should return original config when reranking disabled and not hybrid search', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: createDefaultRerankModel(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result).toEqual(config)
|
||||
})
|
||||
|
||||
it('should return original config when indexMethod is undefined', () => {
|
||||
const config = createRetrievalConfig({
|
||||
reranking_enable: true,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: createDefaultRerankModel(),
|
||||
indexMethod: undefined,
|
||||
})
|
||||
|
||||
expect(result).toEqual(config)
|
||||
})
|
||||
|
||||
it('should preserve other config properties when applying default model', () => {
|
||||
const config = createRetrievalConfig({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: true,
|
||||
top_k: 10,
|
||||
score_threshold_enabled: true,
|
||||
score_threshold: 0.8,
|
||||
})
|
||||
|
||||
const result = ensureRerankModelSelected({
|
||||
retrievalConfig: config,
|
||||
rerankDefaultModel: createDefaultRerankModel(),
|
||||
indexMethod: 'high_quality',
|
||||
})
|
||||
|
||||
expect(result.top_k).toBe(10)
|
||||
expect(result.score_threshold_enabled).toBe(true)
|
||||
expect(result.score_threshold).toBe(0.8)
|
||||
expect(result.search_method).toBe(RETRIEVE_METHOD.semantic)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,61 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ChunkingModeLabel from './chunking-mode-label'
|
||||
|
||||
describe('ChunkingModeLabel', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
|
||||
expect(screen.getByText(/general/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with Badge wrapper', () => {
|
||||
const { container } = render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
|
||||
// Badge component renders with specific styles
|
||||
expect(container.querySelector('.flex')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display general mode text when isGeneralMode is true', () => {
|
||||
render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
|
||||
expect(screen.getByText(/general/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display parent-child mode text when isGeneralMode is false', () => {
|
||||
render(<ChunkingModeLabel isGeneralMode={false} isQAMode={false} />)
|
||||
expect(screen.getByText(/parentChild/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should append QA suffix when isGeneralMode and isQAMode are both true', () => {
|
||||
render(<ChunkingModeLabel isGeneralMode={true} isQAMode={true} />)
|
||||
expect(screen.getByText(/general.*QA/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not append QA suffix when isGeneralMode is true but isQAMode is false', () => {
|
||||
render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
|
||||
const text = screen.getByText(/general/i)
|
||||
expect(text.textContent).not.toContain('QA')
|
||||
})
|
||||
|
||||
it('should not display QA suffix for parent-child mode even when isQAMode is true', () => {
|
||||
render(<ChunkingModeLabel isGeneralMode={false} isQAMode={true} />)
|
||||
expect(screen.getByText(/parentChild/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/QA/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render icon element', () => {
|
||||
const { container } = render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
|
||||
const iconElement = container.querySelector('svg')
|
||||
expect(iconElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply correct icon size classes', () => {
|
||||
const { container } = render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
|
||||
const iconElement = container.querySelector('svg')
|
||||
expect(iconElement).toHaveClass('h-3', 'w-3')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,136 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CredentialIcon } from './credential-icon'
|
||||
|
||||
describe('CredentialIcon', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<CredentialIcon name="Test" />)
|
||||
expect(screen.getByText('T')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render first letter when no avatar provided', () => {
|
||||
render(<CredentialIcon name="Alice" />)
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render image when avatarUrl is provided', () => {
|
||||
render(<CredentialIcon name="Test" avatarUrl="https://example.com/avatar.png" />)
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toBeInTheDocument()
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/avatar.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply default size of 20px', () => {
|
||||
const { container } = render(<CredentialIcon name="Test" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveStyle({ width: '20px', height: '20px' })
|
||||
})
|
||||
|
||||
it('should apply custom size', () => {
|
||||
const { container } = render(<CredentialIcon name="Test" size={40} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveStyle({ width: '40px', height: '40px' })
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<CredentialIcon name="Test" className="custom-class" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should uppercase the first letter', () => {
|
||||
render(<CredentialIcon name="bob" />)
|
||||
expect(screen.getByText('B')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render fallback when avatarUrl is "default"', () => {
|
||||
render(<CredentialIcon name="Test" avatarUrl="default" />)
|
||||
expect(screen.getByText('T')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should fallback to letter when image fails to load', () => {
|
||||
render(<CredentialIcon name="Test" avatarUrl="https://example.com/broken.png" />)
|
||||
|
||||
// Initially shows image
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toBeInTheDocument()
|
||||
|
||||
// Trigger error event
|
||||
fireEvent.error(img)
|
||||
|
||||
// Should now show letter fallback
|
||||
expect(screen.getByText('T')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle single character name', () => {
|
||||
render(<CredentialIcon name="A" />)
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle name starting with number', () => {
|
||||
render(<CredentialIcon name="123test" />)
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle name starting with special character', () => {
|
||||
render(<CredentialIcon name="@user" />)
|
||||
expect(screen.getByText('@')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should assign consistent background colors based on first letter', () => {
|
||||
// Same first letter should get same color
|
||||
const { container: container1 } = render(<CredentialIcon name="Alice" />)
|
||||
const { container: container2 } = render(<CredentialIcon name="Anna" />)
|
||||
|
||||
const wrapper1 = container1.firstChild as HTMLElement
|
||||
const wrapper2 = container2.firstChild as HTMLElement
|
||||
|
||||
// Both should have the same bg class since they start with 'A'
|
||||
const classes1 = wrapper1.className
|
||||
const classes2 = wrapper2.className
|
||||
|
||||
const bgClass1 = classes1.match(/bg-components-icon-bg-\S+/)?.[0]
|
||||
const bgClass2 = classes2.match(/bg-components-icon-bg-\S+/)?.[0]
|
||||
|
||||
expect(bgClass1).toBe(bgClass2)
|
||||
})
|
||||
|
||||
it('should apply different background colors for different letters', () => {
|
||||
// 'A' (65) % 4 = 1 → pink, 'B' (66) % 4 = 2 → indigo
|
||||
const { container: container1 } = render(<CredentialIcon name="Alice" />)
|
||||
const { container: container2 } = render(<CredentialIcon name="Bob" />)
|
||||
|
||||
const wrapper1 = container1.firstChild as HTMLElement
|
||||
const wrapper2 = container2.firstChild as HTMLElement
|
||||
|
||||
const bgClass1 = wrapper1.className.match(/bg-components-icon-bg-\S+/)?.[0]
|
||||
const bgClass2 = wrapper2.className.match(/bg-components-icon-bg-\S+/)?.[0]
|
||||
|
||||
expect(bgClass1).toBeDefined()
|
||||
expect(bgClass2).toBeDefined()
|
||||
expect(bgClass1).not.toBe(bgClass2)
|
||||
})
|
||||
|
||||
it('should handle empty avatarUrl string', () => {
|
||||
render(<CredentialIcon name="Test" avatarUrl="" />)
|
||||
expect(screen.getByText('T')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render image with correct dimensions', () => {
|
||||
render(<CredentialIcon name="Test" avatarUrl="https://example.com/avatar.png" size={32} />)
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveAttribute('width', '32')
|
||||
expect(img).toHaveAttribute('height', '32')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,115 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import DocumentFileIcon from './document-file-icon'
|
||||
|
||||
describe('DocumentFileIcon', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<DocumentFileIcon />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render FileTypeIcon component', () => {
|
||||
const { container } = render(<DocumentFileIcon extension="pdf" />)
|
||||
// FileTypeIcon renders an svg or img element
|
||||
expect(container.querySelector('svg, img')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should determine type from extension prop', () => {
|
||||
const { container } = render(<DocumentFileIcon extension="pdf" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should determine type from name when extension not provided', () => {
|
||||
const { container } = render(<DocumentFileIcon name="document.pdf" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle uppercase extension', () => {
|
||||
const { container } = render(<DocumentFileIcon extension="PDF" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle uppercase name extension', () => {
|
||||
const { container } = render(<DocumentFileIcon name="DOCUMENT.PDF" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<DocumentFileIcon extension="pdf" className="custom-icon" />)
|
||||
expect(container.querySelector('.custom-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass size prop to FileTypeIcon', () => {
|
||||
// Testing different size values
|
||||
const { container: smContainer } = render(<DocumentFileIcon extension="pdf" size="sm" />)
|
||||
const { container: lgContainer } = render(<DocumentFileIcon extension="pdf" size="lg" />)
|
||||
|
||||
expect(smContainer.firstChild).toBeInTheDocument()
|
||||
expect(lgContainer.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Type Mapping', () => {
|
||||
const testCases = [
|
||||
{ extension: 'pdf', description: 'PDF files' },
|
||||
{ extension: 'json', description: 'JSON files' },
|
||||
{ extension: 'html', description: 'HTML files' },
|
||||
{ extension: 'txt', description: 'TXT files' },
|
||||
{ extension: 'markdown', description: 'Markdown files' },
|
||||
{ extension: 'md', description: 'MD files' },
|
||||
{ extension: 'xlsx', description: 'XLSX files' },
|
||||
{ extension: 'xls', description: 'XLS files' },
|
||||
{ extension: 'csv', description: 'CSV files' },
|
||||
{ extension: 'doc', description: 'DOC files' },
|
||||
{ extension: 'docx', description: 'DOCX files' },
|
||||
]
|
||||
|
||||
testCases.forEach(({ extension, description }) => {
|
||||
it(`should handle ${description}`, () => {
|
||||
const { container } = render(<DocumentFileIcon extension={extension} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle unknown extension with default document type', () => {
|
||||
const { container } = render(<DocumentFileIcon extension="xyz" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty extension string', () => {
|
||||
const { container } = render(<DocumentFileIcon extension="" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle name without extension', () => {
|
||||
const { container } = render(<DocumentFileIcon name="document" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle name with multiple dots', () => {
|
||||
const { container } = render(<DocumentFileIcon name="my.document.file.pdf" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prioritize extension over name', () => {
|
||||
// If both are provided, extension should take precedence
|
||||
const { container } = render(<DocumentFileIcon extension="xlsx" name="document.pdf" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined extension and name', () => {
|
||||
const { container } = render(<DocumentFileIcon />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply default size of md', () => {
|
||||
const { container } = render(<DocumentFileIcon extension="pdf" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,166 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
import { useAutoDisabledDocuments } from '@/service/knowledge/use-document'
|
||||
import AutoDisabledDocument from './auto-disabled-document'
|
||||
|
||||
type AutoDisabledDocumentsResponse = { document_ids: string[] }
|
||||
|
||||
const createMockQueryResult = (
|
||||
data: AutoDisabledDocumentsResponse | undefined,
|
||||
isLoading: boolean,
|
||||
) => ({
|
||||
data,
|
||||
isLoading,
|
||||
}) as ReturnType<typeof useAutoDisabledDocuments>
|
||||
|
||||
// Mock service hooks
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockInvalidDisabledDocument = vi.fn()
|
||||
|
||||
vi.mock('@/service/knowledge/use-document', () => ({
|
||||
useAutoDisabledDocuments: vi.fn(),
|
||||
useDocumentEnable: vi.fn(() => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
})),
|
||||
useInvalidDisabledDocument: vi.fn(() => mockInvalidDisabledDocument),
|
||||
}))
|
||||
|
||||
// Mock Toast
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockUseAutoDisabledDocuments = vi.mocked(useAutoDisabledDocuments)
|
||||
|
||||
describe('AutoDisabledDocument', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMutateAsync.mockResolvedValue({})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render nothing when loading', () => {
|
||||
mockUseAutoDisabledDocuments.mockReturnValue(
|
||||
createMockQueryResult(undefined, true),
|
||||
)
|
||||
|
||||
const { container } = render(<AutoDisabledDocument datasetId="test-dataset" />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render nothing when no disabled documents', () => {
|
||||
mockUseAutoDisabledDocuments.mockReturnValue(
|
||||
createMockQueryResult({ document_ids: [] }, false),
|
||||
)
|
||||
|
||||
const { container } = render(<AutoDisabledDocument datasetId="test-dataset" />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render nothing when document_ids is undefined', () => {
|
||||
mockUseAutoDisabledDocuments.mockReturnValue(
|
||||
createMockQueryResult(undefined, false),
|
||||
)
|
||||
|
||||
const { container } = render(<AutoDisabledDocument datasetId="test-dataset" />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render StatusWithAction when disabled documents exist', () => {
|
||||
mockUseAutoDisabledDocuments.mockReturnValue(
|
||||
createMockQueryResult({ document_ids: ['doc1', 'doc2'] }, false),
|
||||
)
|
||||
|
||||
render(<AutoDisabledDocument datasetId="test-dataset" />)
|
||||
expect(screen.getByText(/enable/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass datasetId to useAutoDisabledDocuments', () => {
|
||||
mockUseAutoDisabledDocuments.mockReturnValue(
|
||||
createMockQueryResult({ document_ids: [] }, false),
|
||||
)
|
||||
|
||||
render(<AutoDisabledDocument datasetId="my-dataset-id" />)
|
||||
expect(mockUseAutoDisabledDocuments).toHaveBeenCalledWith('my-dataset-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call enableDocument when action button is clicked', async () => {
|
||||
mockUseAutoDisabledDocuments.mockReturnValue(
|
||||
createMockQueryResult({ document_ids: ['doc1', 'doc2'] }, false),
|
||||
)
|
||||
|
||||
render(<AutoDisabledDocument datasetId="test-dataset" />)
|
||||
|
||||
const actionButton = screen.getByText(/enable/i)
|
||||
fireEvent.click(actionButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
datasetId: 'test-dataset',
|
||||
documentIds: ['doc1', 'doc2'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate cache after enabling documents', async () => {
|
||||
mockUseAutoDisabledDocuments.mockReturnValue(
|
||||
createMockQueryResult({ document_ids: ['doc1'] }, false),
|
||||
)
|
||||
|
||||
render(<AutoDisabledDocument datasetId="test-dataset" />)
|
||||
|
||||
const actionButton = screen.getByText(/enable/i)
|
||||
fireEvent.click(actionButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidDisabledDocument).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show success toast after enabling documents', async () => {
|
||||
mockUseAutoDisabledDocuments.mockReturnValue(
|
||||
createMockQueryResult({ document_ids: ['doc1'] }, false),
|
||||
)
|
||||
|
||||
render(<AutoDisabledDocument datasetId="test-dataset" />)
|
||||
|
||||
const actionButton = screen.getByText(/enable/i)
|
||||
fireEvent.click(actionButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle single disabled document', () => {
|
||||
mockUseAutoDisabledDocuments.mockReturnValue(
|
||||
createMockQueryResult({ document_ids: ['doc1'] }, false),
|
||||
)
|
||||
|
||||
render(<AutoDisabledDocument datasetId="test-dataset" />)
|
||||
expect(screen.getByText(/enable/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple disabled documents', () => {
|
||||
mockUseAutoDisabledDocuments.mockReturnValue(
|
||||
createMockQueryResult({ document_ids: ['doc1', 'doc2', 'doc3', 'doc4', 'doc5'] }, false),
|
||||
)
|
||||
|
||||
render(<AutoDisabledDocument datasetId="test-dataset" />)
|
||||
expect(screen.getByText(/enable/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,280 +0,0 @@
|
||||
import type { ErrorDocsResponse } from '@/models/datasets'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { retryErrorDocs } from '@/service/datasets'
|
||||
import { useDatasetErrorDocs } from '@/service/knowledge/use-dataset'
|
||||
import RetryButton from './index-failed'
|
||||
|
||||
// Mock service hooks
|
||||
const mockRefetch = vi.fn()
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetErrorDocs: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
retryErrorDocs: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseDatasetErrorDocs = vi.mocked(useDatasetErrorDocs)
|
||||
const mockRetryErrorDocs = vi.mocked(retryErrorDocs)
|
||||
|
||||
// Helper to create mock query result
|
||||
const createMockQueryResult = (
|
||||
data: ErrorDocsResponse | undefined,
|
||||
isLoading: boolean,
|
||||
) => ({
|
||||
data,
|
||||
isLoading,
|
||||
refetch: mockRefetch,
|
||||
// Required query result properties
|
||||
error: null,
|
||||
isError: false,
|
||||
isFetched: true,
|
||||
isFetching: false,
|
||||
isSuccess: !isLoading && !!data,
|
||||
status: isLoading ? 'pending' : 'success',
|
||||
dataUpdatedAt: Date.now(),
|
||||
errorUpdatedAt: 0,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
errorUpdateCount: 0,
|
||||
isLoadingError: false,
|
||||
isPaused: false,
|
||||
isPlaceholderData: false,
|
||||
isPending: isLoading,
|
||||
isRefetchError: false,
|
||||
isRefetching: false,
|
||||
isStale: false,
|
||||
fetchStatus: 'idle',
|
||||
promise: Promise.resolve(data as ErrorDocsResponse),
|
||||
isFetchedAfterMount: true,
|
||||
isInitialLoading: false,
|
||||
}) as unknown as ReturnType<typeof useDatasetErrorDocs>
|
||||
|
||||
describe('RetryButton (IndexFailed)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRefetch.mockResolvedValue({})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render nothing when loading', () => {
|
||||
mockUseDatasetErrorDocs.mockReturnValue(
|
||||
createMockQueryResult(undefined, true),
|
||||
)
|
||||
|
||||
const { container } = render(<RetryButton datasetId="test-dataset" />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render nothing when no error documents', () => {
|
||||
mockUseDatasetErrorDocs.mockReturnValue(
|
||||
createMockQueryResult({ total: 0, data: [] }, false),
|
||||
)
|
||||
|
||||
const { container } = render(<RetryButton datasetId="test-dataset" />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render StatusWithAction when error documents exist', () => {
|
||||
mockUseDatasetErrorDocs.mockReturnValue(
|
||||
createMockQueryResult({
|
||||
total: 3,
|
||||
data: [
|
||||
{ id: 'doc1' },
|
||||
{ id: 'doc2' },
|
||||
{ id: 'doc3' },
|
||||
] as ErrorDocsResponse['data'],
|
||||
}, false),
|
||||
)
|
||||
|
||||
render(<RetryButton datasetId="test-dataset" />)
|
||||
expect(screen.getByText(/retry/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display error count in description', () => {
|
||||
mockUseDatasetErrorDocs.mockReturnValue(
|
||||
createMockQueryResult({
|
||||
total: 5,
|
||||
data: [{ id: 'doc1' }] as ErrorDocsResponse['data'],
|
||||
}, false),
|
||||
)
|
||||
|
||||
render(<RetryButton datasetId="test-dataset" />)
|
||||
expect(screen.getByText(/5/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass datasetId to useDatasetErrorDocs', () => {
|
||||
mockUseDatasetErrorDocs.mockReturnValue(
|
||||
createMockQueryResult({ total: 0, data: [] }, false),
|
||||
)
|
||||
|
||||
render(<RetryButton datasetId="my-dataset-id" />)
|
||||
expect(mockUseDatasetErrorDocs).toHaveBeenCalledWith('my-dataset-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call retryErrorDocs when retry button is clicked', async () => {
|
||||
mockUseDatasetErrorDocs.mockReturnValue(
|
||||
createMockQueryResult({
|
||||
total: 2,
|
||||
data: [{ id: 'doc1' }, { id: 'doc2' }] as ErrorDocsResponse['data'],
|
||||
}, false),
|
||||
)
|
||||
|
||||
mockRetryErrorDocs.mockResolvedValue({ result: 'success' })
|
||||
|
||||
render(<RetryButton datasetId="test-dataset" />)
|
||||
|
||||
const retryButton = screen.getByText(/retry/i)
|
||||
fireEvent.click(retryButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRetryErrorDocs).toHaveBeenCalledWith({
|
||||
datasetId: 'test-dataset',
|
||||
document_ids: ['doc1', 'doc2'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should refetch error docs after successful retry', async () => {
|
||||
mockUseDatasetErrorDocs.mockReturnValue(
|
||||
createMockQueryResult({
|
||||
total: 1,
|
||||
data: [{ id: 'doc1' }] as ErrorDocsResponse['data'],
|
||||
}, false),
|
||||
)
|
||||
|
||||
mockRetryErrorDocs.mockResolvedValue({ result: 'success' })
|
||||
|
||||
render(<RetryButton datasetId="test-dataset" />)
|
||||
|
||||
const retryButton = screen.getByText(/retry/i)
|
||||
fireEvent.click(retryButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable button while retrying', async () => {
|
||||
mockUseDatasetErrorDocs.mockReturnValue(
|
||||
createMockQueryResult({
|
||||
total: 1,
|
||||
data: [{ id: 'doc1' }] as ErrorDocsResponse['data'],
|
||||
}, false),
|
||||
)
|
||||
|
||||
// Delay the response to test loading state
|
||||
mockRetryErrorDocs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve({ result: 'success' }), 100)))
|
||||
|
||||
render(<RetryButton datasetId="test-dataset" />)
|
||||
|
||||
const retryButton = screen.getByText(/retry/i)
|
||||
fireEvent.click(retryButton)
|
||||
|
||||
// Button should show disabled styling during retry
|
||||
await waitFor(() => {
|
||||
const button = screen.getByText(/retry/i)
|
||||
expect(button).toHaveClass('cursor-not-allowed')
|
||||
expect(button).toHaveClass('text-text-disabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('State Management', () => {
|
||||
it('should transition to error state when retry fails', async () => {
|
||||
mockUseDatasetErrorDocs.mockReturnValue(
|
||||
createMockQueryResult({
|
||||
total: 1,
|
||||
data: [{ id: 'doc1' }] as ErrorDocsResponse['data'],
|
||||
}, false),
|
||||
)
|
||||
|
||||
mockRetryErrorDocs.mockResolvedValue({ result: 'fail' })
|
||||
|
||||
render(<RetryButton datasetId="test-dataset" />)
|
||||
|
||||
const retryButton = screen.getByText(/retry/i)
|
||||
fireEvent.click(retryButton)
|
||||
|
||||
await waitFor(() => {
|
||||
// Button should still be visible after failed retry
|
||||
expect(screen.getByText(/retry/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should transition to success state when total becomes 0', async () => {
|
||||
const { rerender } = render(<RetryButton datasetId="test-dataset" />)
|
||||
|
||||
// Initially has errors
|
||||
mockUseDatasetErrorDocs.mockReturnValue(
|
||||
createMockQueryResult({
|
||||
total: 1,
|
||||
data: [{ id: 'doc1' }] as ErrorDocsResponse['data'],
|
||||
}, false),
|
||||
)
|
||||
|
||||
rerender(<RetryButton datasetId="test-dataset" />)
|
||||
expect(screen.getByText(/retry/i)).toBeInTheDocument()
|
||||
|
||||
// Now no errors
|
||||
mockUseDatasetErrorDocs.mockReturnValue(
|
||||
createMockQueryResult({ total: 0, data: [] }, false),
|
||||
)
|
||||
|
||||
rerender(<RetryButton datasetId="test-dataset" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/retry/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty data array', () => {
|
||||
mockUseDatasetErrorDocs.mockReturnValue(
|
||||
createMockQueryResult({ total: 0, data: [] }, false),
|
||||
)
|
||||
|
||||
const { container } = render(<RetryButton datasetId="test-dataset" />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle undefined data by showing error state', () => {
|
||||
// When data is undefined but not loading, the component shows error state
|
||||
// because errorDocs?.total is not strictly equal to 0
|
||||
mockUseDatasetErrorDocs.mockReturnValue(
|
||||
createMockQueryResult(undefined, false),
|
||||
)
|
||||
|
||||
render(<RetryButton datasetId="test-dataset" />)
|
||||
// Component renders with undefined count
|
||||
expect(screen.getByText(/retry/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle retry with empty document list', async () => {
|
||||
mockUseDatasetErrorDocs.mockReturnValue(
|
||||
createMockQueryResult({ total: 1, data: [] }, false),
|
||||
)
|
||||
|
||||
mockRetryErrorDocs.mockResolvedValue({ result: 'success' })
|
||||
|
||||
render(<RetryButton datasetId="test-dataset" />)
|
||||
|
||||
const retryButton = screen.getByText(/retry/i)
|
||||
fireEvent.click(retryButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRetryErrorDocs).toHaveBeenCalledWith({
|
||||
datasetId: 'test-dataset',
|
||||
document_ids: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,175 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import StatusWithAction from './status-with-action'
|
||||
|
||||
describe('StatusWithAction', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<StatusWithAction description="Test description" />)
|
||||
expect(screen.getByText('Test description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description text', () => {
|
||||
render(<StatusWithAction description="This is a test message" />)
|
||||
expect(screen.getByText('This is a test message')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon based on type', () => {
|
||||
const { container } = render(<StatusWithAction type="success" description="Success" />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should default to info type when type is not provided', () => {
|
||||
const { container } = render(<StatusWithAction description="Default type" />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('text-text-accent')
|
||||
})
|
||||
|
||||
it('should render success type with correct color', () => {
|
||||
const { container } = render(<StatusWithAction type="success" description="Success" />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('text-text-success')
|
||||
})
|
||||
|
||||
it('should render error type with correct color', () => {
|
||||
const { container } = render(<StatusWithAction type="error" description="Error" />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('text-text-destructive')
|
||||
})
|
||||
|
||||
it('should render warning type with correct color', () => {
|
||||
const { container } = render(<StatusWithAction type="warning" description="Warning" />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('text-text-warning-secondary')
|
||||
})
|
||||
|
||||
it('should render info type with correct color', () => {
|
||||
const { container } = render(<StatusWithAction type="info" description="Info" />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('text-text-accent')
|
||||
})
|
||||
|
||||
it('should render action button when actionText and onAction are provided', () => {
|
||||
const onAction = vi.fn()
|
||||
render(
|
||||
<StatusWithAction
|
||||
description="Test"
|
||||
actionText="Click me"
|
||||
onAction={onAction}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render action button when onAction is not provided', () => {
|
||||
render(<StatusWithAction description="Test" actionText="Click me" />)
|
||||
expect(screen.queryByText('Click me')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider when action is present', () => {
|
||||
const { container } = render(
|
||||
<StatusWithAction
|
||||
description="Test"
|
||||
actionText="Click me"
|
||||
onAction={() => {}}
|
||||
/>,
|
||||
)
|
||||
// Divider component renders a div with specific classes
|
||||
expect(container.querySelector('.bg-divider-regular')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onAction when action button is clicked', () => {
|
||||
const onAction = vi.fn()
|
||||
render(
|
||||
<StatusWithAction
|
||||
description="Test"
|
||||
actionText="Click me"
|
||||
onAction={onAction}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Click me'))
|
||||
expect(onAction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onAction even when disabled (style only)', () => {
|
||||
// Note: disabled prop only affects styling, not actual click behavior
|
||||
const onAction = vi.fn()
|
||||
render(
|
||||
<StatusWithAction
|
||||
description="Test"
|
||||
actionText="Click me"
|
||||
onAction={onAction}
|
||||
disabled
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Click me'))
|
||||
expect(onAction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should apply disabled styles when disabled prop is true', () => {
|
||||
render(
|
||||
<StatusWithAction
|
||||
description="Test"
|
||||
actionText="Click me"
|
||||
onAction={() => {}}
|
||||
disabled
|
||||
/>,
|
||||
)
|
||||
|
||||
const actionButton = screen.getByText('Click me')
|
||||
expect(actionButton).toHaveClass('cursor-not-allowed')
|
||||
expect(actionButton).toHaveClass('text-text-disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Status Background Gradients', () => {
|
||||
it('should apply success gradient background', () => {
|
||||
const { container } = render(<StatusWithAction type="success" description="Success" />)
|
||||
const gradientDiv = container.querySelector('.opacity-40')
|
||||
expect(gradientDiv?.className).toContain('rgba(23,178,106,0.25)')
|
||||
})
|
||||
|
||||
it('should apply warning gradient background', () => {
|
||||
const { container } = render(<StatusWithAction type="warning" description="Warning" />)
|
||||
const gradientDiv = container.querySelector('.opacity-40')
|
||||
expect(gradientDiv?.className).toContain('rgba(247,144,9,0.25)')
|
||||
})
|
||||
|
||||
it('should apply error gradient background', () => {
|
||||
const { container } = render(<StatusWithAction type="error" description="Error" />)
|
||||
const gradientDiv = container.querySelector('.opacity-40')
|
||||
expect(gradientDiv?.className).toContain('rgba(240,68,56,0.25)')
|
||||
})
|
||||
|
||||
it('should apply info gradient background', () => {
|
||||
const { container } = render(<StatusWithAction type="info" description="Info" />)
|
||||
const gradientDiv = container.querySelector('.opacity-40')
|
||||
expect(gradientDiv?.className).toContain('rgba(11,165,236,0.25)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty description', () => {
|
||||
const { container } = render(<StatusWithAction description="" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long description text', () => {
|
||||
const longText = 'A'.repeat(500)
|
||||
render(<StatusWithAction description={longText} />)
|
||||
expect(screen.getByText(longText)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined actionText when onAction is provided', () => {
|
||||
render(<StatusWithAction description="Test" onAction={() => {}} />)
|
||||
// Should render without throwing
|
||||
expect(screen.getByText('Test')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,252 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ImageList from './index'
|
||||
|
||||
// Track handleImageClick calls for testing
|
||||
type FileEntity = {
|
||||
sourceUrl: string
|
||||
name: string
|
||||
mimeType?: string
|
||||
size?: number
|
||||
extension?: string
|
||||
}
|
||||
|
||||
let capturedOnClick: ((file: FileEntity) => void) | null = null
|
||||
|
||||
// Mock FileThumb to capture click handler
|
||||
vi.mock('@/app/components/base/file-thumb', () => ({
|
||||
default: ({ file, onClick }: { file: FileEntity, onClick?: (file: FileEntity) => void }) => {
|
||||
// Capture the onClick for testing
|
||||
capturedOnClick = onClick ?? null
|
||||
return (
|
||||
<div
|
||||
data-testid={`file-thumb-${file.sourceUrl}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onClick?.(file)}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
type ImagePreviewerProps = {
|
||||
images: ImageInfo[]
|
||||
initialIndex: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ImageInfo = {
|
||||
url: string
|
||||
name: string
|
||||
size: number
|
||||
}
|
||||
|
||||
// Mock ImagePreviewer since it uses createPortal
|
||||
vi.mock('../image-previewer', () => ({
|
||||
default: ({ images, initialIndex, onClose }: ImagePreviewerProps) => (
|
||||
<div data-testid="image-previewer">
|
||||
<span data-testid="preview-count">{images.length}</span>
|
||||
<span data-testid="preview-index">{initialIndex}</span>
|
||||
<button data-testid="close-preview" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockImages = (count: number) => {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
name: `image-${i + 1}.png`,
|
||||
mimeType: 'image/png',
|
||||
sourceUrl: `https://example.com/image-${i + 1}.png`,
|
||||
size: 1024 * (i + 1),
|
||||
extension: 'png',
|
||||
}))
|
||||
}
|
||||
|
||||
describe('ImageList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const images = createMockImages(3)
|
||||
const { container } = render(<ImageList images={images} size="md" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all images when count is below limit', () => {
|
||||
const images = createMockImages(5)
|
||||
render(<ImageList images={images} size="md" limit={9} />)
|
||||
// Each image renders a FileThumb component
|
||||
const thumbnails = document.querySelectorAll('[class*="cursor-pointer"]')
|
||||
expect(thumbnails.length).toBeGreaterThanOrEqual(5)
|
||||
})
|
||||
|
||||
it('should render limited images when count exceeds limit', () => {
|
||||
const images = createMockImages(15)
|
||||
render(<ImageList images={images} size="md" limit={9} />)
|
||||
// More button should be visible
|
||||
expect(screen.getByText(/\+6/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const images = createMockImages(3)
|
||||
const { container } = render(
|
||||
<ImageList images={images} size="md" className="custom-class" />,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should use default limit of 9', () => {
|
||||
const images = createMockImages(12)
|
||||
render(<ImageList images={images} size="md" />)
|
||||
// Should show "+3" for remaining images
|
||||
expect(screen.getByText(/\+3/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should respect custom limit', () => {
|
||||
const images = createMockImages(10)
|
||||
render(<ImageList images={images} size="md" limit={5} />)
|
||||
// Should show "+5" for remaining images
|
||||
expect(screen.getByText(/\+5/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle size prop sm', () => {
|
||||
const images = createMockImages(2)
|
||||
const { container } = render(<ImageList images={images} size="sm" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle size prop md', () => {
|
||||
const images = createMockImages(2)
|
||||
const { container } = render(<ImageList images={images} size="md" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should show all images when More button is clicked', () => {
|
||||
const images = createMockImages(15)
|
||||
render(<ImageList images={images} size="md" limit={9} />)
|
||||
|
||||
// Click More button
|
||||
const moreButton = screen.getByText(/\+6/)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
// More button should disappear
|
||||
expect(screen.queryByText(/\+6/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open preview when image is clicked', () => {
|
||||
const images = createMockImages(3)
|
||||
render(<ImageList images={images} size="md" />)
|
||||
|
||||
// Find and click an image thumbnail
|
||||
const thumbnails = document.querySelectorAll('[class*="cursor-pointer"]')
|
||||
if (thumbnails.length > 0) {
|
||||
fireEvent.click(thumbnails[0])
|
||||
// Preview should open
|
||||
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('should close preview when close button is clicked', () => {
|
||||
const images = createMockImages(3)
|
||||
render(<ImageList images={images} size="md" />)
|
||||
|
||||
// Open preview
|
||||
const thumbnails = document.querySelectorAll('[class*="cursor-pointer"]')
|
||||
if (thumbnails.length > 0) {
|
||||
fireEvent.click(thumbnails[0])
|
||||
|
||||
// Close preview
|
||||
const closeButton = screen.getByTestId('close-preview')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
// Preview should be closed
|
||||
expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty images array', () => {
|
||||
const { container } = render(<ImageList images={[]} size="md" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open preview when clicked image not found in list (index === -1)', () => {
|
||||
const images = createMockImages(3)
|
||||
const { rerender } = render(<ImageList images={images} size="md" />)
|
||||
|
||||
// Click first image to open preview
|
||||
const firstThumb = screen.getByTestId('file-thumb-https://example.com/image-1.png')
|
||||
fireEvent.click(firstThumb)
|
||||
|
||||
// Preview should open for valid image
|
||||
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
|
||||
|
||||
// Close preview
|
||||
fireEvent.click(screen.getByTestId('close-preview'))
|
||||
expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument()
|
||||
|
||||
// Now render with images that don't include the previously clicked one
|
||||
const newImages = createMockImages(2) // Only 2 images
|
||||
rerender(<ImageList images={newImages} size="md" />)
|
||||
|
||||
// Click on a thumbnail that exists
|
||||
const validThumb = screen.getByTestId('file-thumb-https://example.com/image-1.png')
|
||||
fireEvent.click(validThumb)
|
||||
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return early when file sourceUrl is not found in limitedImages (index === -1)', () => {
|
||||
const images = createMockImages(3)
|
||||
render(<ImageList images={images} size="md" />)
|
||||
|
||||
// Call the captured onClick with a file that has a non-matching sourceUrl
|
||||
// This triggers the index === -1 branch (line 44-45)
|
||||
if (capturedOnClick) {
|
||||
capturedOnClick({
|
||||
name: 'nonexistent.png',
|
||||
mimeType: 'image/png',
|
||||
sourceUrl: 'https://example.com/nonexistent.png', // Not in the list
|
||||
size: 1024,
|
||||
extension: 'png',
|
||||
})
|
||||
}
|
||||
|
||||
// Preview should NOT open because the file was not found in limitedImages
|
||||
expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle single image', () => {
|
||||
const images = createMockImages(1)
|
||||
const { container } = render(<ImageList images={images} size="md" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show More button when images count equals limit', () => {
|
||||
const images = createMockImages(9)
|
||||
render(<ImageList images={images} size="md" limit={9} />)
|
||||
expect(screen.queryByText(/\+/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle limit of 0', () => {
|
||||
const images = createMockImages(5)
|
||||
render(<ImageList images={images} size="md" limit={0} />)
|
||||
// Should show "+5" for all images
|
||||
expect(screen.getByText(/\+5/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle limit larger than images count', () => {
|
||||
const images = createMockImages(5)
|
||||
render(<ImageList images={images} size="md" limit={100} />)
|
||||
// Should not show More button
|
||||
expect(screen.queryByText(/\+/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,144 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import More from './more'
|
||||
|
||||
describe('More', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<More count={5} />)
|
||||
expect(screen.getByText('+5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display count with plus sign', () => {
|
||||
render(<More count={10} />)
|
||||
expect(screen.getByText('+10')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should format count as-is when less than 1000', () => {
|
||||
render(<More count={999} />)
|
||||
expect(screen.getByText('+999')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should format count with k suffix when 1000 or more', () => {
|
||||
render(<More count={1500} />)
|
||||
expect(screen.getByText('+1.5k')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should format count with M suffix when 1000000 or more', () => {
|
||||
render(<More count={2500000} />)
|
||||
expect(screen.getByText('+2.5M')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should format 1000 as 1.0k', () => {
|
||||
render(<More count={1000} />)
|
||||
expect(screen.getByText('+1.0k')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should format 1000000 as 1.0M', () => {
|
||||
render(<More count={1000000} />)
|
||||
expect(screen.getByText('+1.0M')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<More count={5} onClick={onClick} />)
|
||||
|
||||
fireEvent.click(screen.getByText('+5'))
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not throw when clicked without onClick', () => {
|
||||
render(<More count={5} />)
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByText('+5'))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should stop event propagation on click', () => {
|
||||
const parentClick = vi.fn()
|
||||
const childClick = vi.fn()
|
||||
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
<More count={5} onClick={childClick} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('+5'))
|
||||
expect(childClick).toHaveBeenCalled()
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should display +0 when count is 0', () => {
|
||||
render(<More count={0} />)
|
||||
expect(screen.getByText('+0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle count of 1', () => {
|
||||
render(<More count={1} />)
|
||||
expect(screen.getByText('+1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle boundary value 999', () => {
|
||||
render(<More count={999} />)
|
||||
expect(screen.getByText('+999')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle boundary value 999999', () => {
|
||||
render(<More count={999999} />)
|
||||
// 999999 / 1000 = 999.999 -> 1000.0k
|
||||
expect(screen.getByText('+1000.0k')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply cursor-pointer class', () => {
|
||||
const { container } = render(<More count={5} />)
|
||||
expect(container.firstChild).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatNumber branches', () => {
|
||||
it('should return "0" when num equals 0', () => {
|
||||
// This covers line 11-12: if (num === 0) return '0'
|
||||
render(<More count={0} />)
|
||||
expect(screen.getByText('+0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return num.toString() when num < 1000 and num > 0', () => {
|
||||
// This covers line 13-14: if (num < 1000) return num.toString()
|
||||
render(<More count={500} />)
|
||||
expect(screen.getByText('+500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return k format when 1000 <= num < 1000000', () => {
|
||||
// This covers line 15-16
|
||||
const { rerender } = render(<More count={5000} />)
|
||||
expect(screen.getByText('+5.0k')).toBeInTheDocument()
|
||||
|
||||
rerender(<More count={999999} />)
|
||||
expect(screen.getByText('+1000.0k')).toBeInTheDocument()
|
||||
|
||||
rerender(<More count={50000} />)
|
||||
expect(screen.getByText('+50.0k')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return M format when num >= 1000000', () => {
|
||||
// This covers line 17
|
||||
const { rerender } = render(<More count={1000000} />)
|
||||
expect(screen.getByText('+1.0M')).toBeInTheDocument()
|
||||
|
||||
rerender(<More count={5000000} />)
|
||||
expect(screen.getByText('+5.0M')).toBeInTheDocument()
|
||||
|
||||
rerender(<More count={999999999} />)
|
||||
expect(screen.getByText('+1000.0M')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,525 +0,0 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ImagePreviewer from './index'
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn()
|
||||
globalThis.fetch = mockFetch
|
||||
|
||||
// Mock URL methods
|
||||
const mockRevokeObjectURL = vi.fn()
|
||||
const mockCreateObjectURL = vi.fn(() => 'blob:mock-url')
|
||||
globalThis.URL.revokeObjectURL = mockRevokeObjectURL
|
||||
globalThis.URL.createObjectURL = mockCreateObjectURL
|
||||
|
||||
// Mock Image
|
||||
class MockImage {
|
||||
onload: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
_src = ''
|
||||
|
||||
get src() {
|
||||
return this._src
|
||||
}
|
||||
|
||||
set src(value: string) {
|
||||
this._src = value
|
||||
// Trigger onload after a microtask
|
||||
setTimeout(() => {
|
||||
if (this.onload)
|
||||
this.onload()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
naturalWidth = 800
|
||||
naturalHeight = 600
|
||||
}
|
||||
;(globalThis as unknown as { Image: typeof MockImage }).Image = MockImage
|
||||
|
||||
const createMockImages = () => [
|
||||
{ url: 'https://example.com/image1.png', name: 'image1.png', size: 1024 },
|
||||
{ url: 'https://example.com/image2.png', name: 'image2.png', size: 2048 },
|
||||
{ url: 'https://example.com/image3.png', name: 'image3.png', size: 3072 },
|
||||
]
|
||||
|
||||
describe('ImagePreviewer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Default successful fetch mock
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(new Blob(['test'], { type: 'image/png' })),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} onClose={onClose} />)
|
||||
})
|
||||
|
||||
// Should render in portal
|
||||
expect(document.body.querySelector('.image-previewer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} onClose={onClose} />)
|
||||
})
|
||||
|
||||
// Esc text should be visible
|
||||
expect(screen.getByText('Esc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading state initially', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
// Delay fetch to see loading state
|
||||
mockFetch.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} onClose={onClose} />)
|
||||
})
|
||||
|
||||
// Loading component should be visible
|
||||
expect(document.body.querySelector('.image-previewer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should start at initialIndex', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} initialIndex={1} onClose={onClose} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// Should start at second image
|
||||
expect(screen.getByText('image2.png')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should default initialIndex to 0', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} onClose={onClose} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('image1.png')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClose when close button is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} onClose={onClose} />)
|
||||
})
|
||||
|
||||
// Find and click close button (the one with RiCloseLine icon)
|
||||
const closeButton = document.querySelector('.absolute.right-6 button')
|
||||
if (closeButton) {
|
||||
fireEvent.click(closeButton)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('should navigate to next image when next button is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} onClose={onClose} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('image1.png')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click next button (right arrow)
|
||||
const buttons = document.querySelectorAll('button')
|
||||
const nextButton = Array.from(buttons).find(btn =>
|
||||
btn.className.includes('right-8'),
|
||||
)
|
||||
|
||||
if (nextButton) {
|
||||
await act(async () => {
|
||||
fireEvent.click(nextButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('image2.png')).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should navigate to previous image when prev button is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} initialIndex={1} onClose={onClose} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('image2.png')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click prev button (left arrow)
|
||||
const buttons = document.querySelectorAll('button')
|
||||
const prevButton = Array.from(buttons).find(btn =>
|
||||
btn.className.includes('left-8'),
|
||||
)
|
||||
|
||||
if (prevButton) {
|
||||
await act(async () => {
|
||||
fireEvent.click(prevButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('image1.png')).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should disable prev button at first image', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} initialIndex={0} onClose={onClose} />)
|
||||
})
|
||||
|
||||
const buttons = document.querySelectorAll('button')
|
||||
const prevButton = Array.from(buttons).find(btn =>
|
||||
btn.className.includes('left-8'),
|
||||
)
|
||||
|
||||
expect(prevButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable next button at last image', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} initialIndex={2} onClose={onClose} />)
|
||||
})
|
||||
|
||||
const buttons = document.querySelectorAll('button')
|
||||
const nextButton = Array.from(buttons).find(btn =>
|
||||
btn.className.includes('right-8'),
|
||||
)
|
||||
|
||||
expect(nextButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Image Loading', () => {
|
||||
it('should fetch images on mount', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} onClose={onClose} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error state when fetch fails', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
mockFetch.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} onClose={onClose} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load image/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show retry button on error', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
mockFetch.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} onClose={onClose} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// Retry button should be visible
|
||||
const retryButton = document.querySelector('button.rounded-full')
|
||||
expect(retryButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigation Boundary Cases', () => {
|
||||
it('should not navigate past first image when prevImage is called at index 0', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} initialIndex={0} onClose={onClose} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('image1.png')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click prev button multiple times - should stay at first image
|
||||
const buttons = document.querySelectorAll('button')
|
||||
const prevButton = Array.from(buttons).find(btn =>
|
||||
btn.className.includes('left-8'),
|
||||
)
|
||||
|
||||
if (prevButton) {
|
||||
await act(async () => {
|
||||
fireEvent.click(prevButton)
|
||||
fireEvent.click(prevButton)
|
||||
})
|
||||
|
||||
// Should still be at first image
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('image1.png')).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should not navigate past last image when nextImage is called at last index', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} initialIndex={2} onClose={onClose} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('image3.png')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click next button multiple times - should stay at last image
|
||||
const buttons = document.querySelectorAll('button')
|
||||
const nextButton = Array.from(buttons).find(btn =>
|
||||
btn.className.includes('right-8'),
|
||||
)
|
||||
|
||||
if (nextButton) {
|
||||
await act(async () => {
|
||||
fireEvent.click(nextButton)
|
||||
fireEvent.click(nextButton)
|
||||
})
|
||||
|
||||
// Should still be at last image
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('image3.png')).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Retry Functionality', () => {
|
||||
it('should retry image load when retry button is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
// First fail, then succeed
|
||||
let callCount = 0
|
||||
mockFetch.mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return Promise.reject(new Error('Network error'))
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(new Blob(['test'], { type: 'image/png' })),
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} onClose={onClose} />)
|
||||
})
|
||||
|
||||
// Wait for error state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load image/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click retry button
|
||||
const retryButton = document.querySelector('button.rounded-full')
|
||||
if (retryButton) {
|
||||
await act(async () => {
|
||||
fireEvent.click(retryButton)
|
||||
})
|
||||
|
||||
// Should refetch the image
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(4) // 3 initial + 1 retry
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should show retry button and call retryImage when clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
mockFetch.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} onClose={onClose} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to load image/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click the retry button (not the nav buttons)
|
||||
const allButtons = document.querySelectorAll('button')
|
||||
const retryButton = Array.from(allButtons).find(btn =>
|
||||
btn.className.includes('rounded-full') && !btn.className.includes('left-8') && !btn.className.includes('right-8'),
|
||||
)
|
||||
|
||||
expect(retryButton).toBeInTheDocument()
|
||||
|
||||
if (retryButton) {
|
||||
mockFetch.mockClear()
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(new Blob(['test'], { type: 'image/png' })),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(retryButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Image Cache', () => {
|
||||
it('should clean up blob URLs on unmount', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
// First render to populate cache
|
||||
const { unmount } = await act(async () => {
|
||||
const result = render(<ImagePreviewer images={images} onClose={onClose} />)
|
||||
return result
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Store the call count for verification
|
||||
const _firstCallCount = mockFetch.mock.calls.length
|
||||
|
||||
unmount()
|
||||
|
||||
// Note: The imageCache is cleared on unmount, so this test verifies
|
||||
// the cleanup behavior rather than caching across mounts
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle single image', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = [createMockImages()[0]]
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} onClose={onClose} />)
|
||||
})
|
||||
|
||||
// Both navigation buttons should be disabled
|
||||
const buttons = document.querySelectorAll('button')
|
||||
const prevButton = Array.from(buttons).find(btn =>
|
||||
btn.className.includes('left-8'),
|
||||
)
|
||||
const nextButton = Array.from(buttons).find(btn =>
|
||||
btn.className.includes('right-8'),
|
||||
)
|
||||
|
||||
expect(prevButton).toBeDisabled()
|
||||
expect(nextButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should stop event propagation on container click', async () => {
|
||||
const onClose = vi.fn()
|
||||
const parentClick = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
await act(async () => {
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
<ImagePreviewer images={images} onClose={onClose} />
|
||||
</div>,
|
||||
)
|
||||
})
|
||||
|
||||
const container = document.querySelector('.image-previewer')
|
||||
if (container) {
|
||||
fireEvent.click(container)
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('should display image dimensions when loaded', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} onClose={onClose} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// Should display dimensions (800 × 600 from MockImage)
|
||||
expect(screen.getByText(/800.*600/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display file size', async () => {
|
||||
const onClose = vi.fn()
|
||||
const images = createMockImages()
|
||||
|
||||
await act(async () => {
|
||||
render(<ImagePreviewer images={images} onClose={onClose} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
// Should display formatted file size
|
||||
expect(screen.getByText('image1.png')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,922 +0,0 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { FileEntity } from '../types'
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { FileContextProvider } from '../store'
|
||||
import { useUpload } from './use-upload'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: vi.fn(() => ({
|
||||
data: {
|
||||
image_file_batch_limit: 10,
|
||||
single_chunk_attachment_limit: 20,
|
||||
attachment_image_file_size_limit: 15,
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
type FileUploadOptions = {
|
||||
file: File
|
||||
onProgressCallback?: (progress: number) => void
|
||||
onSuccessCallback?: (res: { id: string, extension: string, mime_type: string, size: number }) => void
|
||||
onErrorCallback?: (error?: Error) => void
|
||||
}
|
||||
|
||||
const mockFileUpload = vi.fn<(options: FileUploadOptions) => void>()
|
||||
const mockGetFileUploadErrorMessage = vi.fn(() => 'Upload error')
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader/utils', () => ({
|
||||
fileUpload: (options: FileUploadOptions) => mockFileUpload(options),
|
||||
getFileUploadErrorMessage: () => mockGetFileUploadErrorMessage(),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
return ({ children }: PropsWithChildren) => (
|
||||
<FileContextProvider>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const createMockFile = (name = 'test.png', _size = 1024, type = 'image/png') => {
|
||||
return new File(['test content'], name, { type })
|
||||
}
|
||||
|
||||
// Mock FileReader
|
||||
type EventCallback = () => void
|
||||
|
||||
class MockFileReader {
|
||||
result: string | ArrayBuffer | null = null
|
||||
onload: EventCallback | null = null
|
||||
onerror: EventCallback | null = null
|
||||
private listeners: Record<string, EventCallback[]> = {}
|
||||
|
||||
addEventListener(event: string, callback: EventCallback) {
|
||||
if (!this.listeners[event])
|
||||
this.listeners[event] = []
|
||||
this.listeners[event].push(callback)
|
||||
}
|
||||
|
||||
removeEventListener(event: string, callback: EventCallback) {
|
||||
if (this.listeners[event])
|
||||
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback)
|
||||
}
|
||||
|
||||
readAsDataURL(_file: File) {
|
||||
setTimeout(() => {
|
||||
this.result = 'data:image/png;base64,mockBase64Data'
|
||||
this.listeners.load?.forEach(cb => cb())
|
||||
}, 0)
|
||||
}
|
||||
|
||||
triggerError() {
|
||||
this.listeners.error?.forEach(cb => cb())
|
||||
}
|
||||
}
|
||||
|
||||
describe('useUpload hook', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFileUpload.mockImplementation(({ onSuccessCallback }) => {
|
||||
setTimeout(() => {
|
||||
onSuccessCallback?.({ id: 'uploaded-id', extension: 'png', mime_type: 'image/png', size: 1024 })
|
||||
}, 0)
|
||||
})
|
||||
// Mock FileReader globally
|
||||
vi.stubGlobal('FileReader', MockFileReader)
|
||||
})
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize with default state', () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
expect(result.current.dragging).toBe(false)
|
||||
expect(result.current.uploaderRef).toBeDefined()
|
||||
expect(result.current.dragRef).toBeDefined()
|
||||
expect(result.current.dropRef).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return file upload config', () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
expect(result.current.fileUploadConfig).toBeDefined()
|
||||
expect(result.current.fileUploadConfig.imageFileBatchLimit).toBe(10)
|
||||
expect(result.current.fileUploadConfig.singleChunkAttachmentLimit).toBe(20)
|
||||
expect(result.current.fileUploadConfig.imageFileSizeLimit).toBe(15)
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Operations', () => {
|
||||
it('should expose selectHandle function', () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
expect(typeof result.current.selectHandle).toBe('function')
|
||||
})
|
||||
|
||||
it('should expose fileChangeHandle function', () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
expect(typeof result.current.fileChangeHandle).toBe('function')
|
||||
})
|
||||
|
||||
it('should expose handleRemoveFile function', () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
expect(typeof result.current.handleRemoveFile).toBe('function')
|
||||
})
|
||||
|
||||
it('should expose handleReUploadFile function', () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
expect(typeof result.current.handleReUploadFile).toBe('function')
|
||||
})
|
||||
|
||||
it('should expose handleLocalFileUpload function', () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
expect(typeof result.current.handleLocalFileUpload).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Validation', () => {
|
||||
it('should show error toast for invalid file type', async () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
const mockEvent = {
|
||||
target: {
|
||||
files: [createMockFile('test.exe', 1024, 'application/x-msdownload')],
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>
|
||||
|
||||
act(() => {
|
||||
result.current.fileChangeHandle(mockEvent)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not reject valid image file types', async () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
const mockFile = createMockFile('test.png', 1024, 'image/png')
|
||||
|
||||
const mockEvent = {
|
||||
target: {
|
||||
files: [mockFile],
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>
|
||||
|
||||
// File type validation should pass for png files
|
||||
// The actual upload will fail without proper FileReader mock,
|
||||
// but we're testing that type validation doesn't reject valid files
|
||||
act(() => {
|
||||
result.current.fileChangeHandle(mockEvent)
|
||||
})
|
||||
|
||||
// Should not show type error for valid image type
|
||||
type ToastCall = [{ type: string, message: string }]
|
||||
const mockNotify = vi.mocked(Toast.notify)
|
||||
const calls = mockNotify.mock.calls as ToastCall[]
|
||||
const typeErrorCalls = calls.filter(
|
||||
(call: ToastCall) => call[0].type === 'error' && call[0].message.includes('Extension'),
|
||||
)
|
||||
expect(typeErrorCalls.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drag and Drop Refs', () => {
|
||||
it('should provide dragRef', () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
expect(result.current.dragRef).toBeDefined()
|
||||
expect(result.current.dragRef.current).toBeNull()
|
||||
})
|
||||
|
||||
it('should provide dropRef', () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
expect(result.current.dropRef).toBeDefined()
|
||||
expect(result.current.dropRef.current).toBeNull()
|
||||
})
|
||||
|
||||
it('should provide uploaderRef', () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
expect(result.current.uploaderRef).toBeDefined()
|
||||
expect(result.current.uploaderRef.current).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty file list', () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
const mockEvent = {
|
||||
target: {
|
||||
files: [],
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>
|
||||
|
||||
act(() => {
|
||||
result.current.fileChangeHandle(mockEvent)
|
||||
})
|
||||
|
||||
// Should not throw and not show error
|
||||
expect(Toast.notify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle null files', () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
const mockEvent = {
|
||||
target: {
|
||||
files: null,
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>
|
||||
|
||||
act(() => {
|
||||
result.current.fileChangeHandle(mockEvent)
|
||||
})
|
||||
|
||||
// Should not throw
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('should respect batch limit from config', () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
// Config should have batch limit of 10
|
||||
expect(result.current.fileUploadConfig.imageFileBatchLimit).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Size Validation', () => {
|
||||
it('should show error for files exceeding size limit', async () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
// Create a file larger than 15MB limit (15 * 1024 * 1024 bytes)
|
||||
const largeFile = new File(['x'.repeat(16 * 1024 * 1024)], 'large.png', { type: 'image/png' })
|
||||
Object.defineProperty(largeFile, 'size', { value: 16 * 1024 * 1024 })
|
||||
|
||||
const mockEvent = {
|
||||
target: {
|
||||
files: [largeFile],
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>
|
||||
|
||||
act(() => {
|
||||
result.current.fileChangeHandle(mockEvent)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleRemoveFile', () => {
|
||||
it('should remove file from store', async () => {
|
||||
const onChange = vi.fn()
|
||||
const initialFiles: Partial<FileEntity>[] = [
|
||||
{ id: 'file1', name: 'test1.png', progress: 100 },
|
||||
{ id: 'file2', name: 'test2.png', progress: 100 },
|
||||
]
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<FileContextProvider value={initialFiles as FileEntity[]} onChange={onChange}>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useUpload(), { wrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.handleRemoveFile('file1')
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ id: 'file2', name: 'test2.png', progress: 100 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleReUploadFile', () => {
|
||||
it('should re-upload file when called with valid fileId', async () => {
|
||||
const onChange = vi.fn()
|
||||
const initialFiles: Partial<FileEntity>[] = [
|
||||
{ id: 'file1', name: 'test1.png', progress: -1, originalFile: new File(['test'], 'test1.png') },
|
||||
]
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<FileContextProvider value={initialFiles as FileEntity[]} onChange={onChange}>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useUpload(), { wrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.handleReUploadFile('file1')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFileUpload).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not re-upload when fileId is not found', () => {
|
||||
const onChange = vi.fn()
|
||||
const initialFiles: Partial<FileEntity>[] = [
|
||||
{ id: 'file1', name: 'test1.png', progress: -1, originalFile: new File(['test'], 'test1.png') },
|
||||
]
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<FileContextProvider value={initialFiles as FileEntity[]} onChange={onChange}>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useUpload(), { wrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.handleReUploadFile('nonexistent')
|
||||
})
|
||||
|
||||
// fileUpload should not be called for nonexistent file
|
||||
expect(mockFileUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle upload error during re-upload', async () => {
|
||||
mockFileUpload.mockImplementation(({ onErrorCallback }: FileUploadOptions) => {
|
||||
setTimeout(() => {
|
||||
onErrorCallback?.(new Error('Upload failed'))
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const onChange = vi.fn()
|
||||
const initialFiles: Partial<FileEntity>[] = [
|
||||
{ id: 'file1', name: 'test1.png', progress: -1, originalFile: new File(['test'], 'test1.png') },
|
||||
]
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<FileContextProvider value={initialFiles as FileEntity[]} onChange={onChange}>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useUpload(), { wrapper })
|
||||
|
||||
act(() => {
|
||||
result.current.handleReUploadFile('file1')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Upload error',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleLocalFileUpload', () => {
|
||||
it('should upload file and update progress', async () => {
|
||||
mockFileUpload.mockImplementation(({ onProgressCallback, onSuccessCallback }: FileUploadOptions) => {
|
||||
setTimeout(() => {
|
||||
onProgressCallback?.(50)
|
||||
setTimeout(() => {
|
||||
onSuccessCallback?.({ id: 'uploaded-id', extension: 'png', mime_type: 'image/png', size: 1024 })
|
||||
}, 10)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const onChange = vi.fn()
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<FileContextProvider onChange={onChange}>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useUpload(), { wrapper })
|
||||
|
||||
const mockFile = createMockFile('test.png', 1024, 'image/png')
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleLocalFileUpload(mockFile)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFileUpload).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle upload error', async () => {
|
||||
mockFileUpload.mockImplementation(({ onErrorCallback }: FileUploadOptions) => {
|
||||
setTimeout(() => {
|
||||
onErrorCallback?.(new Error('Upload failed'))
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const onChange = vi.fn()
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<FileContextProvider onChange={onChange}>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useUpload(), { wrapper })
|
||||
|
||||
const mockFile = createMockFile('test.png', 1024, 'image/png')
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleLocalFileUpload(mockFile)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Upload error',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Attachment Limit', () => {
|
||||
it('should show error when exceeding single chunk attachment limit', async () => {
|
||||
const onChange = vi.fn()
|
||||
// Pre-populate with 19 files (limit is 20)
|
||||
const initialFiles: Partial<FileEntity>[] = Array.from({ length: 19 }, (_, i) => ({
|
||||
id: `file${i}`,
|
||||
name: `test${i}.png`,
|
||||
progress: 100,
|
||||
}))
|
||||
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<FileContextProvider value={initialFiles as FileEntity[]} onChange={onChange}>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useUpload(), { wrapper })
|
||||
|
||||
// Try to add 2 more files (would exceed limit of 20)
|
||||
const mockEvent = {
|
||||
target: {
|
||||
files: [
|
||||
createMockFile('new1.png'),
|
||||
createMockFile('new2.png'),
|
||||
],
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>
|
||||
|
||||
act(() => {
|
||||
result.current.fileChangeHandle(mockEvent)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectHandle', () => {
|
||||
it('should trigger click on uploader input when called', () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
// Create a mock input element
|
||||
const mockInput = document.createElement('input')
|
||||
const clickSpy = vi.spyOn(mockInput, 'click')
|
||||
|
||||
// Manually set the ref
|
||||
Object.defineProperty(result.current.uploaderRef, 'current', {
|
||||
value: mockInput,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.selectHandle()
|
||||
})
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not throw when uploaderRef is null', () => {
|
||||
const { result } = renderHook(() => useUpload(), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.selectHandle()
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileReader Error Handling', () => {
|
||||
it('should show error toast when FileReader encounters an error', async () => {
|
||||
// Create a custom MockFileReader that triggers error
|
||||
class ErrorFileReader {
|
||||
result: string | ArrayBuffer | null = null
|
||||
private listeners: Record<string, EventCallback[]> = {}
|
||||
|
||||
addEventListener(event: string, callback: EventCallback) {
|
||||
if (!this.listeners[event])
|
||||
this.listeners[event] = []
|
||||
this.listeners[event].push(callback)
|
||||
}
|
||||
|
||||
removeEventListener(event: string, callback: EventCallback) {
|
||||
if (this.listeners[event])
|
||||
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback)
|
||||
}
|
||||
|
||||
readAsDataURL(_file: File) {
|
||||
// Trigger error instead of load
|
||||
setTimeout(() => {
|
||||
this.listeners.error?.forEach(cb => cb())
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('FileReader', ErrorFileReader)
|
||||
|
||||
const onChange = vi.fn()
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<FileContextProvider onChange={onChange}>
|
||||
{children}
|
||||
</FileContextProvider>
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useUpload(), { wrapper })
|
||||
|
||||
const mockFile = createMockFile('test.png', 1024, 'image/png')
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleLocalFileUpload(mockFile)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.any(String),
|
||||
})
|
||||
})
|
||||
|
||||
// Restore original MockFileReader
|
||||
vi.stubGlobal('FileReader', MockFileReader)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drag and Drop Functionality', () => {
|
||||
// Test component that renders the hook with actual DOM elements
|
||||
const TestComponent = ({ onStateChange }: { onStateChange?: (dragging: boolean) => void }) => {
|
||||
const { dragging, dragRef, dropRef } = useUpload()
|
||||
|
||||
// Report dragging state changes to parent
|
||||
React.useEffect(() => {
|
||||
onStateChange?.(dragging)
|
||||
}, [dragging, onStateChange])
|
||||
|
||||
return (
|
||||
<div ref={dropRef} data-testid="drop-zone">
|
||||
<div ref={dragRef} data-testid="drag-boundary">
|
||||
<span data-testid="dragging-state">{dragging ? 'dragging' : 'not-dragging'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
it('should set dragging to true on dragEnter when target is not dragRef', async () => {
|
||||
const onStateChange = vi.fn()
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<TestComponent onStateChange={onStateChange} />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
const dropZone = screen.getByTestId('drop-zone')
|
||||
|
||||
// Fire dragenter event on dropZone (not dragRef)
|
||||
await act(async () => {
|
||||
fireEvent.dragEnter(dropZone, {
|
||||
dataTransfer: { items: [] },
|
||||
})
|
||||
})
|
||||
|
||||
// Verify dragging state changed to true
|
||||
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
|
||||
})
|
||||
|
||||
it('should set dragging to false on dragLeave when target matches dragRef', async () => {
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
const dropZone = screen.getByTestId('drop-zone')
|
||||
const dragBoundary = screen.getByTestId('drag-boundary')
|
||||
|
||||
// First trigger dragenter to set dragging to true
|
||||
await act(async () => {
|
||||
fireEvent.dragEnter(dropZone, {
|
||||
dataTransfer: { items: [] },
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
|
||||
|
||||
// Then trigger dragleave on dragBoundary to set dragging to false
|
||||
await act(async () => {
|
||||
fireEvent.dragLeave(dragBoundary, {
|
||||
dataTransfer: { items: [] },
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
|
||||
})
|
||||
|
||||
it('should handle drop event with files and reset dragging state', async () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<FileContextProvider onChange={onChange}>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
const dropZone = screen.getByTestId('drop-zone')
|
||||
const mockFile = new File(['test content'], 'test.png', { type: 'image/png' })
|
||||
|
||||
// First trigger dragenter
|
||||
await act(async () => {
|
||||
fireEvent.dragEnter(dropZone, {
|
||||
dataTransfer: { items: [] },
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
|
||||
|
||||
// Then trigger drop with files
|
||||
await act(async () => {
|
||||
fireEvent.drop(dropZone, {
|
||||
dataTransfer: {
|
||||
items: [{
|
||||
webkitGetAsEntry: () => null,
|
||||
getAsFile: () => mockFile,
|
||||
}],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Dragging should be reset to false after drop
|
||||
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
|
||||
})
|
||||
|
||||
it('should return early when dataTransfer is null on drop', async () => {
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
const dropZone = screen.getByTestId('drop-zone')
|
||||
|
||||
// Fire dragenter first
|
||||
await act(async () => {
|
||||
fireEvent.dragEnter(dropZone)
|
||||
})
|
||||
|
||||
// Fire drop without dataTransfer
|
||||
await act(async () => {
|
||||
fireEvent.drop(dropZone)
|
||||
})
|
||||
|
||||
// Should still reset dragging state
|
||||
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
|
||||
})
|
||||
|
||||
it('should not trigger file upload for invalid file types on drop', async () => {
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
const dropZone = screen.getByTestId('drop-zone')
|
||||
const invalidFile = new File(['test'], 'test.exe', { type: 'application/x-msdownload' })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.drop(dropZone, {
|
||||
dataTransfer: {
|
||||
items: [{
|
||||
webkitGetAsEntry: () => null,
|
||||
getAsFile: () => invalidFile,
|
||||
}],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Should show error toast for invalid file type
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle drop with webkitGetAsEntry for file entries', async () => {
|
||||
const onChange = vi.fn()
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' })
|
||||
|
||||
render(
|
||||
<FileContextProvider onChange={onChange}>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
const dropZone = screen.getByTestId('drop-zone')
|
||||
|
||||
// Create a mock file entry that simulates webkitGetAsEntry behavior
|
||||
const mockFileEntry = {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
file: (callback: (file: File) => void) => callback(mockFile),
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.drop(dropZone, {
|
||||
dataTransfer: {
|
||||
items: [{
|
||||
webkitGetAsEntry: () => mockFileEntry,
|
||||
getAsFile: () => mockFile,
|
||||
}],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Dragging should be reset
|
||||
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drag Events', () => {
|
||||
const TestComponent = () => {
|
||||
const { dragging, dragRef, dropRef } = useUpload()
|
||||
return (
|
||||
<div ref={dropRef} data-testid="drop-zone">
|
||||
<div ref={dragRef} data-testid="drag-boundary">
|
||||
<span data-testid="dragging-state">{dragging ? 'dragging' : 'not-dragging'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
it('should handle dragEnter event and update dragging state', async () => {
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
const dropZone = screen.getByTestId('drop-zone')
|
||||
|
||||
// Initially not dragging
|
||||
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
|
||||
|
||||
// Fire dragEnter
|
||||
await act(async () => {
|
||||
fireEvent.dragEnter(dropZone, {
|
||||
dataTransfer: { items: [] },
|
||||
})
|
||||
})
|
||||
|
||||
// Should be dragging now
|
||||
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
|
||||
})
|
||||
|
||||
it('should handle dragOver event without changing state', async () => {
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
const dropZone = screen.getByTestId('drop-zone')
|
||||
|
||||
// First trigger dragenter to set dragging
|
||||
await act(async () => {
|
||||
fireEvent.dragEnter(dropZone)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
|
||||
|
||||
// dragOver should not change the dragging state
|
||||
await act(async () => {
|
||||
fireEvent.dragOver(dropZone)
|
||||
})
|
||||
|
||||
// Should still be dragging
|
||||
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
|
||||
})
|
||||
|
||||
it('should not set dragging to true when dragEnter target is dragRef', async () => {
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
const dragBoundary = screen.getByTestId('drag-boundary')
|
||||
|
||||
// Fire dragEnter directly on dragRef
|
||||
await act(async () => {
|
||||
fireEvent.dragEnter(dragBoundary)
|
||||
})
|
||||
|
||||
// Should not be dragging when target is dragRef itself
|
||||
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
|
||||
})
|
||||
|
||||
it('should not set dragging to false when dragLeave target is not dragRef', async () => {
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
const dropZone = screen.getByTestId('drop-zone')
|
||||
|
||||
// First trigger dragenter on dropZone to set dragging
|
||||
await act(async () => {
|
||||
fireEvent.dragEnter(dropZone)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
|
||||
|
||||
// dragLeave on dropZone (not dragRef) should not change dragging state
|
||||
await act(async () => {
|
||||
fireEvent.dragLeave(dropZone)
|
||||
})
|
||||
|
||||
// Should still be dragging (only dragLeave on dragRef resets)
|
||||
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,107 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { FileContextProvider } from '../store'
|
||||
import ImageInput from './image-input'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: vi.fn(() => ({
|
||||
data: {
|
||||
image_file_batch_limit: 10,
|
||||
single_chunk_attachment_limit: 20,
|
||||
attachment_image_file_size_limit: 15,
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
const renderWithProvider = (ui: React.ReactElement) => {
|
||||
return render(
|
||||
<FileContextProvider>
|
||||
{ui}
|
||||
</FileContextProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ImageInput (image-uploader-in-chunk)', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = renderWithProvider(<ImageInput />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file input element', () => {
|
||||
renderWithProvider(<ImageInput />)
|
||||
const input = document.querySelector('input[type="file"]')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have hidden file input', () => {
|
||||
renderWithProvider(<ImageInput />)
|
||||
const input = document.querySelector('input[type="file"]')
|
||||
expect(input).toHaveClass('hidden')
|
||||
})
|
||||
|
||||
it('should render upload icon', () => {
|
||||
const { container } = renderWithProvider(<ImageInput />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render browse text', () => {
|
||||
renderWithProvider(<ImageInput />)
|
||||
expect(screen.getByText(/browse/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Input Props', () => {
|
||||
it('should accept multiple files', () => {
|
||||
renderWithProvider(<ImageInput />)
|
||||
const input = document.querySelector('input[type="file"]')
|
||||
expect(input).toHaveAttribute('multiple')
|
||||
})
|
||||
|
||||
it('should have accept attribute for images', () => {
|
||||
renderWithProvider(<ImageInput />)
|
||||
const input = document.querySelector('input[type="file"]')
|
||||
expect(input).toHaveAttribute('accept')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should open file dialog when browse is clicked', () => {
|
||||
renderWithProvider(<ImageInput />)
|
||||
|
||||
const browseText = screen.getByText(/browse/i)
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const clickSpy = vi.spyOn(input, 'click')
|
||||
|
||||
fireEvent.click(browseText)
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drag and Drop', () => {
|
||||
it('should have drop zone area', () => {
|
||||
const { container } = renderWithProvider(<ImageInput />)
|
||||
// The drop zone has dashed border styling
|
||||
expect(container.querySelector('.border-dashed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply accent styles when dragging', () => {
|
||||
// This would require simulating drag events
|
||||
// Just verify the base structure exists
|
||||
const { container } = renderWithProvider(<ImageInput />)
|
||||
expect(container.querySelector('.border-components-dropzone-border')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should display file size limit from config', () => {
|
||||
renderWithProvider(<ImageInput />)
|
||||
// The tip text should contain the size limit (15 from mock)
|
||||
const tipText = document.querySelector('.system-xs-regular')
|
||||
expect(tipText).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,198 +0,0 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ImageItem from './image-item'
|
||||
|
||||
const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: 'test-id',
|
||||
name: 'test.png',
|
||||
progress: 100,
|
||||
base64Url: 'data:image/png;base64,test',
|
||||
sourceUrl: 'https://example.com/test.png',
|
||||
size: 1024,
|
||||
...overrides,
|
||||
} as FileEntity)
|
||||
|
||||
describe('ImageItem (image-uploader-in-chunk)', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const file = createMockFile()
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render image preview', () => {
|
||||
const file = createMockFile()
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
// FileImageRender component should be present
|
||||
expect(container.querySelector('.group\\/file-image')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should show delete button when showDeleteAction is true', () => {
|
||||
const file = createMockFile()
|
||||
const { container } = render(
|
||||
<ImageItem file={file} showDeleteAction onRemove={() => {}} />,
|
||||
)
|
||||
// Delete button has RiCloseLine icon
|
||||
const deleteButton = container.querySelector('button')
|
||||
expect(deleteButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show delete button when showDeleteAction is false', () => {
|
||||
const file = createMockFile()
|
||||
const { container } = render(<ImageItem file={file} showDeleteAction={false} />)
|
||||
const deleteButton = container.querySelector('button')
|
||||
expect(deleteButton).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use base64Url for image when available', () => {
|
||||
const file = createMockFile({ base64Url: 'data:image/png;base64,custom' })
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to sourceUrl when base64Url is not available', () => {
|
||||
const file = createMockFile({ base64Url: undefined })
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Progress States', () => {
|
||||
it('should show progress indicator when progress is between 0 and 99', () => {
|
||||
const file = createMockFile({ progress: 50, uploadedId: undefined })
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
// Progress circle should be visible
|
||||
expect(container.querySelector('.bg-background-overlay-alt')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show progress indicator when upload is complete', () => {
|
||||
const file = createMockFile({ progress: 100, uploadedId: 'uploaded-123' })
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
expect(container.querySelector('.bg-background-overlay-alt')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show retry button when progress is -1 (error)', () => {
|
||||
const file = createMockFile({ progress: -1 })
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
// Error state shows destructive overlay
|
||||
expect(container.querySelector('.bg-background-overlay-destructive')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onPreview when image is clicked', () => {
|
||||
const onPreview = vi.fn()
|
||||
const file = createMockFile()
|
||||
const { container } = render(<ImageItem file={file} onPreview={onPreview} />)
|
||||
|
||||
const imageContainer = container.querySelector('.group\\/file-image')
|
||||
if (imageContainer) {
|
||||
fireEvent.click(imageContainer)
|
||||
expect(onPreview).toHaveBeenCalledWith('test-id')
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onRemove when delete button is clicked', () => {
|
||||
const onRemove = vi.fn()
|
||||
const file = createMockFile()
|
||||
const { container } = render(
|
||||
<ImageItem file={file} showDeleteAction onRemove={onRemove} />,
|
||||
)
|
||||
|
||||
const deleteButton = container.querySelector('button')
|
||||
if (deleteButton) {
|
||||
fireEvent.click(deleteButton)
|
||||
expect(onRemove).toHaveBeenCalledWith('test-id')
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onReUpload when error overlay is clicked', () => {
|
||||
const onReUpload = vi.fn()
|
||||
const file = createMockFile({ progress: -1 })
|
||||
const { container } = render(<ImageItem file={file} onReUpload={onReUpload} />)
|
||||
|
||||
const errorOverlay = container.querySelector('.bg-background-overlay-destructive')
|
||||
if (errorOverlay) {
|
||||
fireEvent.click(errorOverlay)
|
||||
expect(onReUpload).toHaveBeenCalledWith('test-id')
|
||||
}
|
||||
})
|
||||
|
||||
it('should stop event propagation on delete button click', () => {
|
||||
const onRemove = vi.fn()
|
||||
const onPreview = vi.fn()
|
||||
const file = createMockFile()
|
||||
const { container } = render(
|
||||
<ImageItem file={file} showDeleteAction onRemove={onRemove} onPreview={onPreview} />,
|
||||
)
|
||||
|
||||
const deleteButton = container.querySelector('button')
|
||||
if (deleteButton) {
|
||||
fireEvent.click(deleteButton)
|
||||
expect(onRemove).toHaveBeenCalled()
|
||||
expect(onPreview).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('should stop event propagation on retry click', () => {
|
||||
const onReUpload = vi.fn()
|
||||
const onPreview = vi.fn()
|
||||
const file = createMockFile({ progress: -1 })
|
||||
const { container } = render(
|
||||
<ImageItem file={file} onReUpload={onReUpload} onPreview={onPreview} />,
|
||||
)
|
||||
|
||||
const errorOverlay = container.querySelector('.bg-background-overlay-destructive')
|
||||
if (errorOverlay) {
|
||||
fireEvent.click(errorOverlay)
|
||||
expect(onReUpload).toHaveBeenCalled()
|
||||
// onPreview should not be called due to stopPropagation
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle missing onPreview callback', () => {
|
||||
const file = createMockFile()
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
|
||||
const imageContainer = container.querySelector('.group\\/file-image')
|
||||
expect(() => {
|
||||
if (imageContainer)
|
||||
fireEvent.click(imageContainer)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle missing onRemove callback', () => {
|
||||
const file = createMockFile()
|
||||
const { container } = render(<ImageItem file={file} showDeleteAction />)
|
||||
|
||||
const deleteButton = container.querySelector('button')
|
||||
expect(() => {
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle missing onReUpload callback', () => {
|
||||
const file = createMockFile({ progress: -1 })
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
|
||||
const errorOverlay = container.querySelector('.bg-background-overlay-destructive')
|
||||
expect(() => {
|
||||
if (errorOverlay)
|
||||
fireEvent.click(errorOverlay)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle progress of 0', () => {
|
||||
const file = createMockFile({ progress: 0 })
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
// Progress overlay should be visible at 0%
|
||||
expect(container.querySelector('.bg-background-overlay-alt')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,167 +0,0 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ImageUploaderInChunkWrapper from './index'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: vi.fn(() => ({
|
||||
data: {
|
||||
image_file_batch_limit: 10,
|
||||
single_chunk_attachment_limit: 20,
|
||||
attachment_image_file_size_limit: 15,
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/common/image-previewer', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<div data-testid="image-previewer">
|
||||
<button data-testid="close-preview" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ImageUploaderInChunk', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(
|
||||
<ImageUploaderInChunkWrapper value={[]} onChange={onChange} />,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ImageInput when not disabled', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ImageUploaderInChunkWrapper value={[]} onChange={onChange} />)
|
||||
// ImageInput renders an input element
|
||||
expect(document.querySelector('input[type="file"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render ImageInput when disabled', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<ImageUploaderInChunkWrapper value={[]} onChange={onChange} disabled />)
|
||||
// ImageInput should not be present
|
||||
expect(document.querySelector('input[type="file"]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(
|
||||
<ImageUploaderInChunkWrapper
|
||||
value={[]}
|
||||
onChange={onChange}
|
||||
className="custom-class"
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should render files when value is provided', () => {
|
||||
const onChange = vi.fn()
|
||||
const files: FileEntity[] = [
|
||||
{
|
||||
id: 'file1',
|
||||
name: 'test1.png',
|
||||
extension: 'png',
|
||||
mimeType: 'image/png',
|
||||
progress: 100,
|
||||
base64Url: 'data:image/png;base64,test1',
|
||||
size: 1024,
|
||||
},
|
||||
{
|
||||
id: 'file2',
|
||||
name: 'test2.png',
|
||||
extension: 'png',
|
||||
mimeType: 'image/png',
|
||||
progress: 100,
|
||||
base64Url: 'data:image/png;base64,test2',
|
||||
size: 2048,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ImageUploaderInChunkWrapper value={files} onChange={onChange} />)
|
||||
// Each file renders an ImageItem
|
||||
const fileItems = document.querySelectorAll('.group\\/file-image')
|
||||
expect(fileItems.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should show preview when image is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
const files: FileEntity[] = [
|
||||
{
|
||||
id: 'file1',
|
||||
name: 'test.png',
|
||||
extension: 'png',
|
||||
mimeType: 'image/png',
|
||||
progress: 100,
|
||||
uploadedId: 'uploaded-1',
|
||||
base64Url: 'data:image/png;base64,test',
|
||||
size: 1024,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ImageUploaderInChunkWrapper value={files} onChange={onChange} />)
|
||||
|
||||
// Find and click the file item
|
||||
const fileItem = document.querySelector('.group\\/file-image')
|
||||
if (fileItem) {
|
||||
fireEvent.click(fileItem)
|
||||
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('should close preview when close button is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
const files: FileEntity[] = [
|
||||
{
|
||||
id: 'file1',
|
||||
name: 'test.png',
|
||||
extension: 'png',
|
||||
mimeType: 'image/png',
|
||||
progress: 100,
|
||||
uploadedId: 'uploaded-1',
|
||||
base64Url: 'data:image/png;base64,test',
|
||||
size: 1024,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ImageUploaderInChunkWrapper value={files} onChange={onChange} />)
|
||||
|
||||
// Open preview
|
||||
const fileItem = document.querySelector('.group\\/file-image')
|
||||
if (fileItem) {
|
||||
fireEvent.click(fileItem)
|
||||
|
||||
// Close preview
|
||||
const closeButton = screen.getByTestId('close-preview')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty files array', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(
|
||||
<ImageUploaderInChunkWrapper value={[]} onChange={onChange} />,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined value', () => {
|
||||
const onChange = vi.fn()
|
||||
const { container } = render(
|
||||
<ImageUploaderInChunkWrapper value={undefined} onChange={onChange} />,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,125 +0,0 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { FileContextProvider } from '../store'
|
||||
import ImageInput from './image-input'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: vi.fn(() => ({
|
||||
data: {
|
||||
image_file_batch_limit: 10,
|
||||
single_chunk_attachment_limit: 20,
|
||||
attachment_image_file_size_limit: 15,
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
const renderWithProvider = (ui: React.ReactElement, initialFiles: FileEntity[] = []) => {
|
||||
return render(
|
||||
<FileContextProvider value={initialFiles}>
|
||||
{ui}
|
||||
</FileContextProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ImageInput (image-uploader-in-retrieval-testing)', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = renderWithProvider(<ImageInput />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file input element', () => {
|
||||
renderWithProvider(<ImageInput />)
|
||||
const input = document.querySelector('input[type="file"]')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have hidden file input', () => {
|
||||
renderWithProvider(<ImageInput />)
|
||||
const input = document.querySelector('input[type="file"]')
|
||||
expect(input).toHaveClass('hidden')
|
||||
})
|
||||
|
||||
it('should render add image icon', () => {
|
||||
const { container } = renderWithProvider(<ImageInput />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show tip text when no files are uploaded', () => {
|
||||
renderWithProvider(<ImageInput />)
|
||||
// Tip text should be visible
|
||||
expect(document.querySelector('.system-sm-regular')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide tip text when files exist', () => {
|
||||
const files: FileEntity[] = [
|
||||
{
|
||||
id: 'file1',
|
||||
name: 'test.png',
|
||||
extension: 'png',
|
||||
mimeType: 'image/png',
|
||||
size: 1024,
|
||||
progress: 100,
|
||||
uploadedId: 'uploaded-1',
|
||||
},
|
||||
]
|
||||
renderWithProvider(<ImageInput />, files)
|
||||
// Tip text should not be visible
|
||||
expect(document.querySelector('.text-text-quaternary')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Input Props', () => {
|
||||
it('should accept multiple files', () => {
|
||||
renderWithProvider(<ImageInput />)
|
||||
const input = document.querySelector('input[type="file"]')
|
||||
expect(input).toHaveAttribute('multiple')
|
||||
})
|
||||
|
||||
it('should have accept attribute', () => {
|
||||
renderWithProvider(<ImageInput />)
|
||||
const input = document.querySelector('input[type="file"]')
|
||||
expect(input).toHaveAttribute('accept')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should open file dialog when icon is clicked', () => {
|
||||
renderWithProvider(<ImageInput />)
|
||||
|
||||
const clickableArea = document.querySelector('.cursor-pointer')
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const clickSpy = vi.spyOn(input, 'click')
|
||||
|
||||
if (clickableArea)
|
||||
fireEvent.click(clickableArea)
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip', () => {
|
||||
it('should have tooltip component', () => {
|
||||
const { container } = renderWithProvider(<ImageInput />)
|
||||
// Tooltip wrapper should exist
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable tooltip when no files exist', () => {
|
||||
// When files.length === 0, tooltip should be disabled
|
||||
renderWithProvider(<ImageInput />)
|
||||
// Component renders with tip text visible instead of tooltip
|
||||
expect(document.querySelector('.system-sm-regular')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render icon container with correct styling', () => {
|
||||
const { container } = renderWithProvider(<ImageInput />)
|
||||
expect(container.querySelector('.border-dashed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,149 +0,0 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ImageItem from './image-item'
|
||||
|
||||
const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: 'test-id',
|
||||
name: 'test.png',
|
||||
progress: 100,
|
||||
base64Url: 'data:image/png;base64,test',
|
||||
sourceUrl: 'https://example.com/test.png',
|
||||
size: 1024,
|
||||
...overrides,
|
||||
} as FileEntity)
|
||||
|
||||
describe('ImageItem (image-uploader-in-retrieval-testing)', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const file = createMockFile()
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with size-20 class', () => {
|
||||
const file = createMockFile()
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
expect(container.querySelector('.size-20')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should show delete button when showDeleteAction is true', () => {
|
||||
const file = createMockFile()
|
||||
const { container } = render(
|
||||
<ImageItem file={file} showDeleteAction onRemove={() => {}} />,
|
||||
)
|
||||
const deleteButton = container.querySelector('button')
|
||||
expect(deleteButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show delete button when showDeleteAction is false', () => {
|
||||
const file = createMockFile()
|
||||
const { container } = render(<ImageItem file={file} showDeleteAction={false} />)
|
||||
const deleteButton = container.querySelector('button')
|
||||
expect(deleteButton).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Progress States', () => {
|
||||
it('should show progress indicator when uploading', () => {
|
||||
const file = createMockFile({ progress: 50, uploadedId: undefined })
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
expect(container.querySelector('.bg-background-overlay-alt')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show progress indicator when upload is complete', () => {
|
||||
const file = createMockFile({ progress: 100, uploadedId: 'uploaded-123' })
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
expect(container.querySelector('.bg-background-overlay-alt')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error overlay when progress is -1', () => {
|
||||
const file = createMockFile({ progress: -1 })
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
expect(container.querySelector('.bg-background-overlay-destructive')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onPreview when clicked', () => {
|
||||
const onPreview = vi.fn()
|
||||
const file = createMockFile()
|
||||
const { container } = render(<ImageItem file={file} onPreview={onPreview} />)
|
||||
|
||||
const imageContainer = container.querySelector('.group\\/file-image')
|
||||
if (imageContainer) {
|
||||
fireEvent.click(imageContainer)
|
||||
expect(onPreview).toHaveBeenCalledWith('test-id')
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onRemove when delete button is clicked', () => {
|
||||
const onRemove = vi.fn()
|
||||
const file = createMockFile()
|
||||
const { container } = render(
|
||||
<ImageItem file={file} showDeleteAction onRemove={onRemove} />,
|
||||
)
|
||||
|
||||
const deleteButton = container.querySelector('button')
|
||||
if (deleteButton) {
|
||||
fireEvent.click(deleteButton)
|
||||
expect(onRemove).toHaveBeenCalledWith('test-id')
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onReUpload when error overlay is clicked', () => {
|
||||
const onReUpload = vi.fn()
|
||||
const file = createMockFile({ progress: -1 })
|
||||
const { container } = render(<ImageItem file={file} onReUpload={onReUpload} />)
|
||||
|
||||
const errorOverlay = container.querySelector('.bg-background-overlay-destructive')
|
||||
if (errorOverlay) {
|
||||
fireEvent.click(errorOverlay)
|
||||
expect(onReUpload).toHaveBeenCalledWith('test-id')
|
||||
}
|
||||
})
|
||||
|
||||
it('should stop propagation on delete click', () => {
|
||||
const onRemove = vi.fn()
|
||||
const onPreview = vi.fn()
|
||||
const file = createMockFile()
|
||||
const { container } = render(
|
||||
<ImageItem file={file} showDeleteAction onRemove={onRemove} onPreview={onPreview} />,
|
||||
)
|
||||
|
||||
const deleteButton = container.querySelector('button')
|
||||
if (deleteButton) {
|
||||
fireEvent.click(deleteButton)
|
||||
expect(onRemove).toHaveBeenCalled()
|
||||
expect(onPreview).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle missing callbacks', () => {
|
||||
const file = createMockFile()
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
|
||||
expect(() => {
|
||||
const imageContainer = container.querySelector('.group\\/file-image')
|
||||
if (imageContainer)
|
||||
fireEvent.click(imageContainer)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should use base64Url when available', () => {
|
||||
const file = createMockFile({ base64Url: 'data:custom' })
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to sourceUrl', () => {
|
||||
const file = createMockFile({ base64Url: undefined })
|
||||
const { container } = render(<ImageItem file={file} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,238 +0,0 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ImageUploaderInRetrievalTestingWrapper from './index'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: vi.fn(() => ({
|
||||
data: {
|
||||
image_file_batch_limit: 10,
|
||||
single_chunk_attachment_limit: 20,
|
||||
attachment_image_file_size_limit: 15,
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/common/image-previewer', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<div data-testid="image-previewer">
|
||||
<button data-testid="close-preview" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ImageUploaderInRetrievalTesting', () => {
|
||||
const defaultProps = {
|
||||
textArea: <textarea data-testid="text-area" />,
|
||||
actionButton: <button data-testid="action-button">Submit</button>,
|
||||
onChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(
|
||||
<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render textArea prop', () => {
|
||||
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />)
|
||||
expect(screen.getByTestId('text-area')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render actionButton prop', () => {
|
||||
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />)
|
||||
expect(screen.getByTestId('action-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ImageInput when showUploader is true (default)', () => {
|
||||
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />)
|
||||
expect(document.querySelector('input[type="file"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render ImageInput when showUploader is false', () => {
|
||||
render(
|
||||
<ImageUploaderInRetrievalTestingWrapper
|
||||
{...defaultProps}
|
||||
value={[]}
|
||||
showUploader={false}
|
||||
/>,
|
||||
)
|
||||
expect(document.querySelector('input[type="file"]')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(
|
||||
<ImageUploaderInRetrievalTestingWrapper
|
||||
{...defaultProps}
|
||||
value={[]}
|
||||
className="custom-class"
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should apply actionAreaClassName', () => {
|
||||
const { container } = render(
|
||||
<ImageUploaderInRetrievalTestingWrapper
|
||||
{...defaultProps}
|
||||
value={[]}
|
||||
actionAreaClassName="action-area-class"
|
||||
/>,
|
||||
)
|
||||
// The action area should have the custom class
|
||||
expect(container.querySelector('.action-area-class')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file list when files are provided', () => {
|
||||
const files: FileEntity[] = [
|
||||
{
|
||||
id: 'file1',
|
||||
name: 'test1.png',
|
||||
extension: 'png',
|
||||
mimeType: 'image/png',
|
||||
progress: 100,
|
||||
uploadedId: 'uploaded-1',
|
||||
base64Url: 'data:image/png;base64,test1',
|
||||
size: 1024,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={files} />)
|
||||
const fileItems = document.querySelectorAll('.group\\/file-image')
|
||||
expect(fileItems.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should not render file list when files are empty', () => {
|
||||
const { container } = render(
|
||||
<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />,
|
||||
)
|
||||
// File list container should not be present
|
||||
expect(container.querySelector('.bg-background-default')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render file list when showUploader is false', () => {
|
||||
const files: FileEntity[] = [
|
||||
{
|
||||
id: 'file1',
|
||||
name: 'test1.png',
|
||||
extension: 'png',
|
||||
mimeType: 'image/png',
|
||||
progress: 100,
|
||||
uploadedId: 'uploaded-1',
|
||||
base64Url: 'data:image/png;base64,test1',
|
||||
size: 1024,
|
||||
},
|
||||
]
|
||||
|
||||
const { container } = render(
|
||||
<ImageUploaderInRetrievalTestingWrapper
|
||||
{...defaultProps}
|
||||
value={files}
|
||||
showUploader={false}
|
||||
/>,
|
||||
)
|
||||
expect(container.querySelector('.bg-background-default')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should show preview when image is clicked', () => {
|
||||
const files: FileEntity[] = [
|
||||
{
|
||||
id: 'file1',
|
||||
name: 'test.png',
|
||||
extension: 'png',
|
||||
mimeType: 'image/png',
|
||||
progress: 100,
|
||||
uploadedId: 'uploaded-1',
|
||||
base64Url: 'data:image/png;base64,test',
|
||||
size: 1024,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={files} />)
|
||||
|
||||
const fileItem = document.querySelector('.group\\/file-image')
|
||||
if (fileItem) {
|
||||
fireEvent.click(fileItem)
|
||||
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('should close preview when close button is clicked', () => {
|
||||
const files: FileEntity[] = [
|
||||
{
|
||||
id: 'file1',
|
||||
name: 'test.png',
|
||||
extension: 'png',
|
||||
mimeType: 'image/png',
|
||||
progress: 100,
|
||||
uploadedId: 'uploaded-1',
|
||||
base64Url: 'data:image/png;base64,test',
|
||||
size: 1024,
|
||||
},
|
||||
]
|
||||
|
||||
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={files} />)
|
||||
|
||||
const fileItem = document.querySelector('.group\\/file-image')
|
||||
if (fileItem) {
|
||||
fireEvent.click(fileItem)
|
||||
const closeButton = screen.getByTestId('close-preview')
|
||||
fireEvent.click(closeButton)
|
||||
expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout', () => {
|
||||
it('should use justify-between when showUploader is true', () => {
|
||||
const { container } = render(
|
||||
<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />,
|
||||
)
|
||||
expect(container.querySelector('.justify-between')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use justify-end when showUploader is false', () => {
|
||||
const { container } = render(
|
||||
<ImageUploaderInRetrievalTestingWrapper
|
||||
{...defaultProps}
|
||||
value={[]}
|
||||
showUploader={false}
|
||||
/>,
|
||||
)
|
||||
expect(container.querySelector('.justify-end')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined value', () => {
|
||||
const { container } = render(
|
||||
<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={undefined} />,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple files', () => {
|
||||
const files: FileEntity[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `file${i}`,
|
||||
name: `test${i}.png`,
|
||||
extension: 'png',
|
||||
mimeType: 'image/png',
|
||||
progress: 100,
|
||||
uploadedId: `uploaded-${i}`,
|
||||
base64Url: `data:image/png;base64,test${i}`,
|
||||
size: 1024 * (i + 1),
|
||||
}))
|
||||
|
||||
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={files} />)
|
||||
const fileItems = document.querySelectorAll('.group\\/file-image')
|
||||
expect(fileItems.length).toBe(5)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,305 +0,0 @@
|
||||
import type { FileEntity } from './types'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createFileStore,
|
||||
FileContextProvider,
|
||||
useFileStore,
|
||||
useFileStoreWithSelector,
|
||||
} from './store'
|
||||
|
||||
const createMockFile = (id: string): FileEntity => ({
|
||||
id,
|
||||
name: `file-${id}.png`,
|
||||
size: 1024,
|
||||
extension: 'png',
|
||||
mimeType: 'image/png',
|
||||
progress: 0,
|
||||
})
|
||||
|
||||
describe('image-uploader store', () => {
|
||||
describe('createFileStore', () => {
|
||||
it('should create store with empty array by default', () => {
|
||||
const store = createFileStore()
|
||||
expect(store.getState().files).toEqual([])
|
||||
})
|
||||
|
||||
it('should create store with initial value', () => {
|
||||
const initialFiles = [createMockFile('1'), createMockFile('2')]
|
||||
const store = createFileStore(initialFiles)
|
||||
expect(store.getState().files).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should create copy of initial value', () => {
|
||||
const initialFiles = [createMockFile('1')]
|
||||
const store = createFileStore(initialFiles)
|
||||
store.getState().files.push(createMockFile('2'))
|
||||
expect(initialFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should update files with setFiles', () => {
|
||||
const store = createFileStore()
|
||||
const newFiles = [createMockFile('1'), createMockFile('2')]
|
||||
|
||||
act(() => {
|
||||
store.getState().setFiles(newFiles)
|
||||
})
|
||||
|
||||
expect(store.getState().files).toEqual(newFiles)
|
||||
})
|
||||
|
||||
it('should call onChange when setFiles is called', () => {
|
||||
const onChange = vi.fn()
|
||||
const store = createFileStore([], onChange)
|
||||
const newFiles = [createMockFile('1')]
|
||||
|
||||
act(() => {
|
||||
store.getState().setFiles(newFiles)
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(newFiles)
|
||||
})
|
||||
|
||||
it('should not throw when onChange is not provided', () => {
|
||||
const store = createFileStore([])
|
||||
const newFiles = [createMockFile('1')]
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
store.getState().setFiles(newFiles)
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle undefined initial value', () => {
|
||||
const store = createFileStore(undefined)
|
||||
expect(store.getState().files).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle null-like falsy value with empty array fallback', () => {
|
||||
// Test the ternary: value ? [...value] : []
|
||||
const store = createFileStore(null as unknown as FileEntity[])
|
||||
expect(store.getState().files).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle empty array as initial value', () => {
|
||||
const store = createFileStore([])
|
||||
expect(store.getState().files).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileContextProvider', () => {
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<div>Test Child</div>
|
||||
</FileContextProvider>,
|
||||
)
|
||||
expect(screen.getByText('Test Child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should provide store to children', () => {
|
||||
const TestComponent = () => {
|
||||
const store = useFileStore()
|
||||
// useFileStore returns a store that's truthy by design
|
||||
return <div data-testid="store-exists">{store !== null ? 'yes' : 'no'}</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('store-exists')).toHaveTextContent('yes')
|
||||
})
|
||||
|
||||
it('should initialize store with value prop', () => {
|
||||
const initialFiles = [createMockFile('1')]
|
||||
|
||||
const TestComponent = () => {
|
||||
const store = useFileStore()
|
||||
return <div data-testid="file-count">{store.getState().files.length}</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<FileContextProvider value={initialFiles}>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('file-count')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('should call onChange when files change', () => {
|
||||
const onChange = vi.fn()
|
||||
const newFiles = [createMockFile('1')]
|
||||
|
||||
const TestComponent = () => {
|
||||
const store = useFileStore()
|
||||
return (
|
||||
<button onClick={() => store.getState().setFiles(newFiles)}>
|
||||
Set Files
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<FileContextProvider onChange={onChange}>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button').click()
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(newFiles)
|
||||
})
|
||||
|
||||
it('should reuse existing store on re-render (storeRef.current already exists)', () => {
|
||||
const initialFiles = [createMockFile('1')]
|
||||
let renderCount = 0
|
||||
|
||||
const TestComponent = () => {
|
||||
const store = useFileStore()
|
||||
renderCount++
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="file-count">{store.getState().files.length}</span>
|
||||
<span data-testid="render-count">{renderCount}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<FileContextProvider value={initialFiles}>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('file-count')).toHaveTextContent('1')
|
||||
|
||||
// Re-render the provider - should reuse the same store
|
||||
rerender(
|
||||
<FileContextProvider value={initialFiles}>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
// Store should still have the same files (store was reused)
|
||||
expect(screen.getByTestId('file-count')).toHaveTextContent('1')
|
||||
expect(renderCount).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFileStore', () => {
|
||||
it('should return store from context', () => {
|
||||
const TestComponent = () => {
|
||||
const store = useFileStore()
|
||||
// useFileStore returns a store that's truthy by design
|
||||
return <div data-testid="result">{store !== null ? 'has store' : 'no store'}</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('result')).toHaveTextContent('has store')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFileStoreWithSelector', () => {
|
||||
it('should throw error when used outside provider', () => {
|
||||
const TestComponent = () => {
|
||||
try {
|
||||
useFileStoreWithSelector(state => state.files)
|
||||
return <div>No Error</div>
|
||||
}
|
||||
catch {
|
||||
return <div>Error</div>
|
||||
}
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
expect(screen.getByText('Error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should select files from store', () => {
|
||||
const initialFiles = [createMockFile('1'), createMockFile('2')]
|
||||
|
||||
const TestComponent = () => {
|
||||
const files = useFileStoreWithSelector(state => state.files)
|
||||
return <div data-testid="files-count">{files.length}</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<FileContextProvider value={initialFiles}>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('files-count')).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('should select setFiles function from store', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
const TestComponent = () => {
|
||||
const setFiles = useFileStoreWithSelector(state => state.setFiles)
|
||||
return (
|
||||
<button onClick={() => setFiles([createMockFile('new')])}>
|
||||
Update
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<FileContextProvider onChange={onChange}>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button').click()
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should re-render when selected state changes', () => {
|
||||
const renderCount = { current: 0 }
|
||||
|
||||
const TestComponent = () => {
|
||||
const files = useFileStoreWithSelector(state => state.files)
|
||||
const setFiles = useFileStoreWithSelector(state => state.setFiles)
|
||||
renderCount.current++
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="count">{files.length}</span>
|
||||
<button onClick={() => setFiles([...files, createMockFile('new')])}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<TestComponent />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('count')).toHaveTextContent('0')
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button').click()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('count')).toHaveTextContent('1')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,310 +0,0 @@
|
||||
import type { FileEntity } from './types'
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_IMAGE_FILE_BATCH_LIMIT,
|
||||
DEFAULT_IMAGE_FILE_SIZE_LIMIT,
|
||||
DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
} from './constants'
|
||||
import { fileIsUploaded, getFileType, getFileUploadConfig, traverseFileEntry } from './utils'
|
||||
|
||||
describe('image-uploader utils', () => {
|
||||
describe('getFileType', () => {
|
||||
it('should return file extension for a simple filename', () => {
|
||||
const file = { name: 'image.png' } as File
|
||||
expect(getFileType(file)).toBe('png')
|
||||
})
|
||||
|
||||
it('should return file extension for filename with multiple dots', () => {
|
||||
const file = { name: 'my.photo.image.jpg' } as File
|
||||
expect(getFileType(file)).toBe('jpg')
|
||||
})
|
||||
|
||||
it('should return empty string for null/undefined file', () => {
|
||||
expect(getFileType(null as unknown as File)).toBe('')
|
||||
expect(getFileType(undefined as unknown as File)).toBe('')
|
||||
})
|
||||
|
||||
it('should return filename for file without extension', () => {
|
||||
const file = { name: 'README' } as File
|
||||
expect(getFileType(file)).toBe('README')
|
||||
})
|
||||
|
||||
it('should handle various file extensions', () => {
|
||||
expect(getFileType({ name: 'doc.pdf' } as File)).toBe('pdf')
|
||||
expect(getFileType({ name: 'image.jpeg' } as File)).toBe('jpeg')
|
||||
expect(getFileType({ name: 'video.mp4' } as File)).toBe('mp4')
|
||||
expect(getFileType({ name: 'archive.tar.gz' } as File)).toBe('gz')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fileIsUploaded', () => {
|
||||
it('should return true when uploadedId is set', () => {
|
||||
const file = { uploadedId: 'some-id', progress: 50 } as Partial<FileEntity>
|
||||
expect(fileIsUploaded(file as FileEntity)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when progress is 100', () => {
|
||||
const file = { progress: 100 } as Partial<FileEntity>
|
||||
expect(fileIsUploaded(file as FileEntity)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return undefined when neither uploadedId nor 100 progress', () => {
|
||||
const file = { progress: 50 } as Partial<FileEntity>
|
||||
expect(fileIsUploaded(file as FileEntity)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined when progress is 0', () => {
|
||||
const file = { progress: 0 } as Partial<FileEntity>
|
||||
expect(fileIsUploaded(file as FileEntity)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return true when uploadedId is empty string and progress is 100', () => {
|
||||
const file = { uploadedId: '', progress: 100 } as Partial<FileEntity>
|
||||
expect(fileIsUploaded(file as FileEntity)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileUploadConfig', () => {
|
||||
it('should return default values when response is undefined', () => {
|
||||
const result = getFileUploadConfig(undefined)
|
||||
expect(result).toEqual({
|
||||
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
|
||||
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
|
||||
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return values from response when valid', () => {
|
||||
const response: Partial<FileUploadConfigResponse> = {
|
||||
image_file_batch_limit: 20,
|
||||
single_chunk_attachment_limit: 10,
|
||||
attachment_image_file_size_limit: 5,
|
||||
}
|
||||
|
||||
const result = getFileUploadConfig(response as FileUploadConfigResponse)
|
||||
expect(result).toEqual({
|
||||
imageFileSizeLimit: 5,
|
||||
imageFileBatchLimit: 20,
|
||||
singleChunkAttachmentLimit: 10,
|
||||
})
|
||||
})
|
||||
|
||||
it('should use default values when response values are 0', () => {
|
||||
const response: Partial<FileUploadConfigResponse> = {
|
||||
image_file_batch_limit: 0,
|
||||
single_chunk_attachment_limit: 0,
|
||||
attachment_image_file_size_limit: 0,
|
||||
}
|
||||
|
||||
const result = getFileUploadConfig(response as FileUploadConfigResponse)
|
||||
expect(result).toEqual({
|
||||
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
|
||||
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
|
||||
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
})
|
||||
})
|
||||
|
||||
it('should use default values when response values are negative', () => {
|
||||
const response: Partial<FileUploadConfigResponse> = {
|
||||
image_file_batch_limit: -5,
|
||||
single_chunk_attachment_limit: -10,
|
||||
attachment_image_file_size_limit: -1,
|
||||
}
|
||||
|
||||
const result = getFileUploadConfig(response as FileUploadConfigResponse)
|
||||
expect(result).toEqual({
|
||||
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
|
||||
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
|
||||
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle string values in response', () => {
|
||||
const response = {
|
||||
image_file_batch_limit: '15',
|
||||
single_chunk_attachment_limit: '8',
|
||||
attachment_image_file_size_limit: '3',
|
||||
} as unknown as FileUploadConfigResponse
|
||||
|
||||
const result = getFileUploadConfig(response)
|
||||
expect(result).toEqual({
|
||||
imageFileSizeLimit: 3,
|
||||
imageFileBatchLimit: 15,
|
||||
singleChunkAttachmentLimit: 8,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle null values in response', () => {
|
||||
const response = {
|
||||
image_file_batch_limit: null,
|
||||
single_chunk_attachment_limit: null,
|
||||
attachment_image_file_size_limit: null,
|
||||
} as unknown as FileUploadConfigResponse
|
||||
|
||||
const result = getFileUploadConfig(response)
|
||||
expect(result).toEqual({
|
||||
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
|
||||
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
|
||||
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle undefined values in response', () => {
|
||||
const response = {
|
||||
image_file_batch_limit: undefined,
|
||||
single_chunk_attachment_limit: undefined,
|
||||
attachment_image_file_size_limit: undefined,
|
||||
} as unknown as FileUploadConfigResponse
|
||||
|
||||
const result = getFileUploadConfig(response)
|
||||
expect(result).toEqual({
|
||||
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
|
||||
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
|
||||
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle partial response', () => {
|
||||
const response: Partial<FileUploadConfigResponse> = {
|
||||
image_file_batch_limit: 25,
|
||||
}
|
||||
|
||||
const result = getFileUploadConfig(response as FileUploadConfigResponse)
|
||||
expect(result.imageFileBatchLimit).toBe(25)
|
||||
expect(result.imageFileSizeLimit).toBe(DEFAULT_IMAGE_FILE_SIZE_LIMIT)
|
||||
expect(result.singleChunkAttachmentLimit).toBe(DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT)
|
||||
})
|
||||
|
||||
it('should handle non-number non-string values (object, boolean, etc) with default fallback', () => {
|
||||
// This tests the getNumberValue function's final return 0 case
|
||||
// When value is neither number nor string (e.g., object, boolean, array)
|
||||
const response = {
|
||||
image_file_batch_limit: { invalid: 'object' }, // Object - not number or string
|
||||
single_chunk_attachment_limit: true, // Boolean - not number or string
|
||||
attachment_image_file_size_limit: ['array'], // Array - not number or string
|
||||
} as unknown as FileUploadConfigResponse
|
||||
|
||||
const result = getFileUploadConfig(response)
|
||||
// All should fall back to defaults since getNumberValue returns 0 for these types
|
||||
expect(result).toEqual({
|
||||
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
|
||||
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
|
||||
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle NaN string values', () => {
|
||||
const response = {
|
||||
image_file_batch_limit: 'not-a-number',
|
||||
single_chunk_attachment_limit: '',
|
||||
attachment_image_file_size_limit: 'abc',
|
||||
} as unknown as FileUploadConfigResponse
|
||||
|
||||
const result = getFileUploadConfig(response)
|
||||
// NaN values should result in defaults (since NaN > 0 is false)
|
||||
expect(result).toEqual({
|
||||
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
|
||||
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
|
||||
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('traverseFileEntry', () => {
|
||||
type MockFile = { name: string, relativePath?: string }
|
||||
type FileCallback = (file: MockFile) => void
|
||||
type EntriesCallback = (entries: FileSystemEntry[]) => void
|
||||
|
||||
it('should resolve with file array for file entry', async () => {
|
||||
const mockFile: MockFile = { name: 'test.png' }
|
||||
const mockEntry = {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
file: (callback: FileCallback) => callback(mockFile),
|
||||
}
|
||||
|
||||
const result = await traverseFileEntry(mockEntry)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('test.png')
|
||||
expect(result[0].relativePath).toBe('test.png')
|
||||
})
|
||||
|
||||
it('should resolve with file array with prefix for nested file', async () => {
|
||||
const mockFile: MockFile = { name: 'test.png' }
|
||||
const mockEntry = {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
file: (callback: FileCallback) => callback(mockFile),
|
||||
}
|
||||
|
||||
const result = await traverseFileEntry(mockEntry, 'folder/')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].relativePath).toBe('folder/test.png')
|
||||
})
|
||||
|
||||
it('should resolve empty array for unknown entry type', async () => {
|
||||
const mockEntry = {
|
||||
isFile: false,
|
||||
isDirectory: false,
|
||||
}
|
||||
|
||||
const result = await traverseFileEntry(mockEntry)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle directory with no files', async () => {
|
||||
const mockEntry = {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name: 'empty-folder',
|
||||
createReader: () => ({
|
||||
readEntries: (callback: EntriesCallback) => callback([]),
|
||||
}),
|
||||
}
|
||||
|
||||
const result = await traverseFileEntry(mockEntry)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle directory with files', async () => {
|
||||
const mockFile1: MockFile = { name: 'file1.png' }
|
||||
const mockFile2: MockFile = { name: 'file2.png' }
|
||||
|
||||
const mockFileEntry1 = {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
file: (callback: FileCallback) => callback(mockFile1),
|
||||
}
|
||||
|
||||
const mockFileEntry2 = {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
file: (callback: FileCallback) => callback(mockFile2),
|
||||
}
|
||||
|
||||
let readCount = 0
|
||||
const mockEntry = {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name: 'folder',
|
||||
createReader: () => ({
|
||||
readEntries: (callback: EntriesCallback) => {
|
||||
if (readCount === 0) {
|
||||
readCount++
|
||||
callback([mockFileEntry1, mockFileEntry2] as unknown as FileSystemEntry[])
|
||||
}
|
||||
else {
|
||||
callback([])
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const result = await traverseFileEntry(mockEntry)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].relativePath).toBe('folder/file1.png')
|
||||
expect(result[1].relativePath).toBe('folder/file2.png')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,323 +0,0 @@
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import RetrievalParamConfig from './index'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModel: vi.fn(() => ({
|
||||
modelList: [
|
||||
{
|
||||
provider: 'cohere',
|
||||
models: [{ model: 'rerank-english-v2.0' }],
|
||||
},
|
||||
],
|
||||
})),
|
||||
useCurrentProviderAndModel: vi.fn(() => ({
|
||||
currentModel: {
|
||||
provider: 'cohere',
|
||||
model: 'rerank-english-v2.0',
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
type ModelSelectorProps = {
|
||||
onSelect: (model: { provider: string, model: string }) => void
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: ({ onSelect }: ModelSelectorProps) => (
|
||||
<button data-testid="model-selector" onClick={() => onSelect({ provider: 'cohere', model: 'rerank-english-v2.0' })}>
|
||||
Select Model
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
type WeightedScoreProps = {
|
||||
value: { value: number[] }
|
||||
onChange: (newValue: { value: number[] }) => void
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/app/configuration/dataset-config/params-config/weighted-score', () => ({
|
||||
default: ({ value, onChange }: WeightedScoreProps) => (
|
||||
<div data-testid="weighted-score">
|
||||
<input
|
||||
data-testid="weight-input"
|
||||
type="range"
|
||||
value={value.value[0]}
|
||||
onChange={e => onChange({ value: [Number(e.target.value), 1 - Number(e.target.value)] })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createDefaultConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({
|
||||
search_method: RETRIEVE_METHOD.semantic,
|
||||
reranking_enable: false,
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('RetrievalParamConfig', () => {
|
||||
const defaultOnChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.semantic}
|
||||
value={createDefaultConfig()}
|
||||
onChange={defaultOnChange}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TopKItem', () => {
|
||||
render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.semantic}
|
||||
value={createDefaultConfig()}
|
||||
onChange={defaultOnChange}
|
||||
/>,
|
||||
)
|
||||
// TopKItem contains "Top K" text
|
||||
expect(screen.getByText(/top.*k/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Semantic Search Mode', () => {
|
||||
it('should show rerank toggle for semantic search', () => {
|
||||
const { container } = render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.semantic}
|
||||
value={createDefaultConfig()}
|
||||
onChange={defaultOnChange}
|
||||
/>,
|
||||
)
|
||||
// Switch component should be present
|
||||
expect(container.querySelector('[role="switch"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show model selector when reranking is enabled', () => {
|
||||
render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.semantic}
|
||||
value={createDefaultConfig({ reranking_enable: true })}
|
||||
onChange={defaultOnChange}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show model selector when reranking is disabled', () => {
|
||||
render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.semantic}
|
||||
value={createDefaultConfig({ reranking_enable: false })}
|
||||
onChange={defaultOnChange}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FullText Search Mode', () => {
|
||||
it('should show rerank toggle for fullText search', () => {
|
||||
const { container } = render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.fullText}
|
||||
value={createDefaultConfig({ search_method: RETRIEVE_METHOD.fullText })}
|
||||
onChange={defaultOnChange}
|
||||
/>,
|
||||
)
|
||||
expect(container.querySelector('[role="switch"]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hybrid Search Mode', () => {
|
||||
it('should show reranking mode options for hybrid search', () => {
|
||||
render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.hybrid}
|
||||
value={createDefaultConfig({
|
||||
search_method: RETRIEVE_METHOD.hybrid,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
})}
|
||||
onChange={defaultOnChange}
|
||||
/>,
|
||||
)
|
||||
// Should show weighted score and reranking model options
|
||||
expect(screen.getAllByText(/weight/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show WeightedScore component when WeightedScore mode is selected', () => {
|
||||
render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.hybrid}
|
||||
value={createDefaultConfig({
|
||||
search_method: RETRIEVE_METHOD.hybrid,
|
||||
reranking_mode: RerankingModeEnum.WeightedScore,
|
||||
weights: {
|
||||
weight_type: WeightedScoreEnum.Customized,
|
||||
vector_setting: {
|
||||
vector_weight: 0.7,
|
||||
embedding_provider_name: '',
|
||||
embedding_model_name: '',
|
||||
},
|
||||
keyword_setting: {
|
||||
keyword_weight: 0.3,
|
||||
},
|
||||
},
|
||||
})}
|
||||
onChange={defaultOnChange}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('weighted-score')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show model selector when RerankingModel mode is selected', () => {
|
||||
render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.hybrid}
|
||||
value={createDefaultConfig({
|
||||
search_method: RETRIEVE_METHOD.hybrid,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
})}
|
||||
onChange={defaultOnChange}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keyword Search Mode', () => {
|
||||
it('should not show rerank toggle for keyword search', () => {
|
||||
const { container } = render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.keywordSearch}
|
||||
value={createDefaultConfig()}
|
||||
onChange={defaultOnChange}
|
||||
/>,
|
||||
)
|
||||
// Switch should not be present for economical mode
|
||||
expect(container.querySelector('[role="switch"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should still show TopKItem for keyword search', () => {
|
||||
render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.keywordSearch}
|
||||
value={createDefaultConfig()}
|
||||
onChange={defaultOnChange}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/top.*k/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange when model is selected', () => {
|
||||
render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.semantic}
|
||||
value={createDefaultConfig({ reranking_enable: true })}
|
||||
onChange={defaultOnChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const modelSelector = screen.getByTestId('model-selector')
|
||||
fireEvent.click(modelSelector)
|
||||
|
||||
expect(defaultOnChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
reranking_model: {
|
||||
reranking_provider_name: 'cohere',
|
||||
reranking_model_name: 'rerank-english-v2.0',
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multi-Modal Tip', () => {
|
||||
it('should show multi-modal tip when showMultiModalTip is true and reranking is enabled', () => {
|
||||
render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.semantic}
|
||||
value={createDefaultConfig({ reranking_enable: true })}
|
||||
onChange={defaultOnChange}
|
||||
showMultiModalTip
|
||||
/>,
|
||||
)
|
||||
// Warning icon should be present
|
||||
expect(document.querySelector('.text-text-warning-secondary')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show multi-modal tip when showMultiModalTip is false', () => {
|
||||
render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.semantic}
|
||||
value={createDefaultConfig({ reranking_enable: true })}
|
||||
onChange={defaultOnChange}
|
||||
showMultiModalTip={false}
|
||||
/>,
|
||||
)
|
||||
expect(document.querySelector('.text-text-warning-secondary')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined reranking_model', () => {
|
||||
const config = createDefaultConfig()
|
||||
const { container } = render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.semantic}
|
||||
value={config}
|
||||
onChange={defaultOnChange}
|
||||
/>,
|
||||
)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle switching from semantic to hybrid search', () => {
|
||||
const { rerender } = render(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.semantic}
|
||||
value={createDefaultConfig()}
|
||||
onChange={defaultOnChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<RetrievalParamConfig
|
||||
type={RETRIEVE_METHOD.hybrid}
|
||||
value={createDefaultConfig({
|
||||
search_method: RETRIEVE_METHOD.hybrid,
|
||||
reranking_mode: RerankingModeEnum.RerankingModel,
|
||||
})}
|
||||
onChange={defaultOnChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByText(/weight/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,154 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DSLConfirmModal from './dsl-confirm-modal'
|
||||
|
||||
// ============================================================================
|
||||
// DSLConfirmModal Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('DSLConfirmModal', () => {
|
||||
const defaultProps = {
|
||||
onCancel: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} />)
|
||||
expect(screen.getByText(/appCreateDSLErrorTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} />)
|
||||
expect(screen.getByText(/appCreateDSLErrorTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error message parts', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} />)
|
||||
expect(screen.getByText(/appCreateDSLErrorPart1/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/appCreateDSLErrorPart2/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/appCreateDSLErrorPart3/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/appCreateDSLErrorPart4/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel button', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} />)
|
||||
expect(screen.getByText(/Cancel/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render confirm button', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} />)
|
||||
expect(screen.getByText(/Confirm/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Versions Display Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Versions Display', () => {
|
||||
it('should display imported version when provided', () => {
|
||||
render(
|
||||
<DSLConfirmModal
|
||||
{...defaultProps}
|
||||
versions={{ importedVersion: '1.0.0', systemVersion: '2.0.0' }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('1.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display system version when provided', () => {
|
||||
render(
|
||||
<DSLConfirmModal
|
||||
{...defaultProps}
|
||||
versions={{ importedVersion: '1.0.0', systemVersion: '2.0.0' }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('2.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use default empty versions when not provided', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} />)
|
||||
// Should render without errors
|
||||
expect(screen.getByText(/appCreateDSLErrorTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call onCancel when cancel button is clicked', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} />)
|
||||
const cancelButton = screen.getByText(/Cancel/i)
|
||||
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onConfirm when confirm button is clicked', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} />)
|
||||
const confirmButton = screen.getByText(/Confirm/i)
|
||||
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onCancel when modal is closed', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} />)
|
||||
// Modal close is triggered by clicking backdrop or close button
|
||||
// The onClose prop is mapped to onCancel
|
||||
const cancelButton = screen.getByText(/Cancel/i)
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
expect(defaultProps.onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Button State Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Button State', () => {
|
||||
it('should enable confirm button by default', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} />)
|
||||
const confirmButton = screen.getByText(/Confirm/i)
|
||||
|
||||
expect(confirmButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable confirm button when confirmDisabled is true', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} confirmDisabled={true} />)
|
||||
const confirmButton = screen.getByText(/Confirm/i)
|
||||
|
||||
expect(confirmButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable confirm button when confirmDisabled is false', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} confirmDisabled={false} />)
|
||||
const confirmButton = screen.getByText(/Confirm/i)
|
||||
|
||||
expect(confirmButton).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have button container with proper styling', () => {
|
||||
render(<DSLConfirmModal {...defaultProps} />)
|
||||
const cancelButton = screen.getByText(/Cancel/i)
|
||||
const buttonContainer = cancelButton.parentElement
|
||||
expect(buttonContainer).toHaveClass('flex', 'items-start', 'justify-end', 'gap-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,93 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Header from './header'
|
||||
|
||||
// ============================================================================
|
||||
// Header Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Header', () => {
|
||||
const defaultProps = {
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Header {...defaultProps} />)
|
||||
expect(screen.getByText(/importFromDSL/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title', () => {
|
||||
render(<Header {...defaultProps} />)
|
||||
expect(screen.getByText(/importFromDSL/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
const { container } = render(<Header {...defaultProps} />)
|
||||
const closeButton = container.querySelector('[class*="cursor-pointer"]')
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close icon', () => {
|
||||
const { container } = render(<Header {...defaultProps} />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClose when close button is clicked', () => {
|
||||
const { container } = render(<Header {...defaultProps} />)
|
||||
const closeButton = container.querySelector('[class*="cursor-pointer"]')
|
||||
|
||||
fireEvent.click(closeButton!)
|
||||
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper container styling', () => {
|
||||
const { container } = render(<Header {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('title-2xl-semi-bold', 'relative', 'flex', 'items-center')
|
||||
})
|
||||
|
||||
it('should have close button positioned absolutely', () => {
|
||||
const { container } = render(<Header {...defaultProps} />)
|
||||
const closeButton = container.querySelector('[class*="absolute"]')
|
||||
expect(closeButton).toHaveClass('right-5', 'top-5')
|
||||
})
|
||||
|
||||
it('should have padding classes', () => {
|
||||
const { container } = render(<Header {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('pb-3', 'pl-6', 'pr-14', 'pt-6')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<Header {...defaultProps} />)
|
||||
rerender(<Header {...defaultProps} />)
|
||||
expect(screen.getByText(/importFromDSL/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,121 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
|
||||
import Tab from './index'
|
||||
|
||||
// ============================================================================
|
||||
// Tab Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Tab', () => {
|
||||
const defaultProps = {
|
||||
currentTab: CreateFromDSLModalTab.FROM_FILE,
|
||||
setCurrentTab: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Tab {...defaultProps} />)
|
||||
expect(screen.getByText(/importFromDSLFile/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file tab', () => {
|
||||
render(<Tab {...defaultProps} />)
|
||||
expect(screen.getByText(/importFromDSLFile/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render URL tab', () => {
|
||||
render(<Tab {...defaultProps} />)
|
||||
expect(screen.getByText(/importFromDSLUrl/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both tabs', () => {
|
||||
render(<Tab {...defaultProps} />)
|
||||
const tabs = screen.getAllByText(/importFromDSL/i)
|
||||
expect(tabs.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Active State Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Active State', () => {
|
||||
it('should mark file tab as active when currentTab is FROM_FILE', () => {
|
||||
const { container } = render(
|
||||
<Tab {...defaultProps} currentTab={CreateFromDSLModalTab.FROM_FILE} />,
|
||||
)
|
||||
const activeIndicators = container.querySelectorAll('[class*="bg-util-colors-blue-brand"]')
|
||||
expect(activeIndicators.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should mark URL tab as active when currentTab is FROM_URL', () => {
|
||||
const { container } = render(
|
||||
<Tab {...defaultProps} currentTab={CreateFromDSLModalTab.FROM_URL} />,
|
||||
)
|
||||
const activeIndicators = container.querySelectorAll('[class*="bg-util-colors-blue-brand"]')
|
||||
expect(activeIndicators.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call setCurrentTab with FROM_FILE when file tab is clicked', () => {
|
||||
render(<Tab {...defaultProps} currentTab={CreateFromDSLModalTab.FROM_URL} />)
|
||||
const fileTab = screen.getByText(/importFromDSLFile/i)
|
||||
|
||||
fireEvent.click(fileTab)
|
||||
|
||||
// bind() prepends the bound argument, so setCurrentTab is called with (FROM_FILE, event)
|
||||
expect(defaultProps.setCurrentTab).toHaveBeenCalledWith(
|
||||
CreateFromDSLModalTab.FROM_FILE,
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call setCurrentTab with FROM_URL when URL tab is clicked', () => {
|
||||
render(<Tab {...defaultProps} currentTab={CreateFromDSLModalTab.FROM_FILE} />)
|
||||
const urlTab = screen.getByText(/importFromDSLUrl/i)
|
||||
|
||||
fireEvent.click(urlTab)
|
||||
|
||||
// bind() prepends the bound argument, so setCurrentTab is called with (FROM_URL, event)
|
||||
expect(defaultProps.setCurrentTab).toHaveBeenCalledWith(
|
||||
CreateFromDSLModalTab.FROM_URL,
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper container styling', () => {
|
||||
const { container } = render(<Tab {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('system-md-semibold', 'flex', 'h-9', 'items-center', 'gap-x-6')
|
||||
})
|
||||
|
||||
it('should have border bottom', () => {
|
||||
const { container } = render(<Tab {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('border-b', 'border-divider-subtle')
|
||||
})
|
||||
|
||||
it('should have padding', () => {
|
||||
const { container } = render(<Tab {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('px-6')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,112 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Item from './item'
|
||||
|
||||
// ============================================================================
|
||||
// Item Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Item', () => {
|
||||
const defaultProps = {
|
||||
isActive: false,
|
||||
label: 'Tab Label',
|
||||
onClick: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Item {...defaultProps} />)
|
||||
expect(screen.getByText('Tab Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label', () => {
|
||||
render(<Item {...defaultProps} label="Custom Label" />)
|
||||
expect(screen.getByText('Custom Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render indicator when inactive', () => {
|
||||
const { container } = render(<Item {...defaultProps} isActive={false} />)
|
||||
const indicator = container.querySelector('[class*="bg-util-colors-blue-brand"]')
|
||||
expect(indicator).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render indicator when active', () => {
|
||||
const { container } = render(<Item {...defaultProps} isActive={true} />)
|
||||
const indicator = container.querySelector('[class*="bg-util-colors-blue-brand"]')
|
||||
expect(indicator).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Active State Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Active State', () => {
|
||||
it('should have tertiary text color when inactive', () => {
|
||||
const { container } = render(<Item {...defaultProps} isActive={false} />)
|
||||
const item = container.firstChild as HTMLElement
|
||||
expect(item).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should have primary text color when active', () => {
|
||||
const { container } = render(<Item {...defaultProps} isActive={true} />)
|
||||
const item = container.firstChild as HTMLElement
|
||||
expect(item).toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should show active indicator bar when active', () => {
|
||||
const { container } = render(<Item {...defaultProps} isActive={true} />)
|
||||
const indicator = container.querySelector('[class*="absolute"]')
|
||||
expect(indicator).toHaveClass('bottom-0', 'h-0.5', 'w-full')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when clicked', () => {
|
||||
render(<Item {...defaultProps} />)
|
||||
const item = screen.getByText('Tab Label')
|
||||
|
||||
fireEvent.click(item)
|
||||
|
||||
expect(defaultProps.onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should have cursor pointer', () => {
|
||||
const { container } = render(<Item {...defaultProps} />)
|
||||
const item = container.firstChild as HTMLElement
|
||||
expect(item).toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper container styling', () => {
|
||||
const { container } = render(<Item {...defaultProps} />)
|
||||
const item = container.firstChild as HTMLElement
|
||||
expect(item).toHaveClass('system-md-semibold', 'relative', 'flex', 'h-full', 'items-center')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<Item {...defaultProps} />)
|
||||
rerender(<Item {...defaultProps} />)
|
||||
expect(screen.getByText('Tab Label')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,205 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Uploader from './uploader'
|
||||
|
||||
// Mock ToastContext
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
ToastContext: {
|
||||
Provider: ({ children }: { children: React.ReactNode }) => children,
|
||||
Consumer: ({ children }: { children: (value: { notify: typeof mockNotify }) => React.ReactNode }) => children({ notify: mockNotify }),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock use-context-selector
|
||||
vi.mock('use-context-selector', () => ({
|
||||
useContext: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createMockFile = (name = 'test.pipeline', _size = 1024): File => {
|
||||
return new File(['test content'], name, { type: 'application/octet-stream' })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Uploader Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Uploader', () => {
|
||||
const defaultProps = {
|
||||
file: undefined,
|
||||
updateFile: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests - No File
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering - No File', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Uploader {...defaultProps} />)
|
||||
expect(screen.getByText(/dslUploader\.button/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render upload prompt when no file', () => {
|
||||
render(<Uploader {...defaultProps} />)
|
||||
expect(screen.getByText(/dslUploader\.button/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render browse link when no file', () => {
|
||||
render(<Uploader {...defaultProps} />)
|
||||
expect(screen.getByText(/dslUploader\.browse/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render upload icon when no file', () => {
|
||||
const { container } = render(<Uploader {...defaultProps} />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have hidden file input', () => {
|
||||
render(<Uploader {...defaultProps} />)
|
||||
const input = document.getElementById('fileUploader') as HTMLInputElement
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input.style.display).toBe('none')
|
||||
})
|
||||
|
||||
it('should accept .pipeline files', () => {
|
||||
render(<Uploader {...defaultProps} />)
|
||||
const input = document.getElementById('fileUploader') as HTMLInputElement
|
||||
expect(input.accept).toBe('.pipeline')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests - With File
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering - With File', () => {
|
||||
it('should render file name when file is provided', () => {
|
||||
const file = createMockFile('my-pipeline.pipeline')
|
||||
render(<Uploader {...defaultProps} file={file} />)
|
||||
expect(screen.getByText('my-pipeline.pipeline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render PIPELINE label when file is provided', () => {
|
||||
const file = createMockFile()
|
||||
render(<Uploader {...defaultProps} file={file} />)
|
||||
expect(screen.getByText('PIPELINE')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete button when file is provided', () => {
|
||||
const file = createMockFile()
|
||||
const { container } = render(<Uploader {...defaultProps} file={file} />)
|
||||
const deleteButton = container.querySelector('[class*="group-hover:flex"]')
|
||||
expect(deleteButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render node tree icon when file is provided', () => {
|
||||
const file = createMockFile()
|
||||
const { container } = render(<Uploader {...defaultProps} file={file} />)
|
||||
const icons = container.querySelectorAll('svg')
|
||||
expect(icons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should open file dialog when browse is clicked', () => {
|
||||
render(<Uploader {...defaultProps} />)
|
||||
const input = document.getElementById('fileUploader') as HTMLInputElement
|
||||
const clickSpy = vi.spyOn(input, 'click')
|
||||
|
||||
const browseLink = screen.getByText(/dslUploader\.browse/i)
|
||||
fireEvent.click(browseLink)
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call updateFile when file input changes', () => {
|
||||
render(<Uploader {...defaultProps} />)
|
||||
const input = document.getElementById('fileUploader') as HTMLInputElement
|
||||
const file = createMockFile()
|
||||
|
||||
Object.defineProperty(input, 'files', {
|
||||
value: [file],
|
||||
writable: true,
|
||||
})
|
||||
|
||||
fireEvent.change(input)
|
||||
|
||||
expect(defaultProps.updateFile).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should call updateFile with undefined when delete is clicked', () => {
|
||||
const file = createMockFile()
|
||||
const { container } = render(<Uploader {...defaultProps} file={file} />)
|
||||
|
||||
const deleteButton = container.querySelector('[class*="group-hover:flex"] button')
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(defaultProps.updateFile).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Custom className Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Custom className', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<Uploader {...defaultProps} className="custom-class" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should merge custom className with default', () => {
|
||||
const { container } = render(<Uploader {...defaultProps} className="custom-class" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('mt-6', 'custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper container styling', () => {
|
||||
const { container } = render(<Uploader {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('mt-6')
|
||||
})
|
||||
|
||||
it('should have dropzone styling when no file', () => {
|
||||
const { container } = render(<Uploader {...defaultProps} />)
|
||||
const dropzone = container.querySelector('[class*="border-dashed"]')
|
||||
expect(dropzone).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have file card styling when file is provided', () => {
|
||||
const file = createMockFile()
|
||||
const { container } = render(<Uploader {...defaultProps} file={file} />)
|
||||
const fileCard = container.querySelector('[class*="rounded-lg"]')
|
||||
expect(fileCard).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<Uploader {...defaultProps} />)
|
||||
rerender(<Uploader {...defaultProps} />)
|
||||
expect(screen.getByText(/dslUploader\.button/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,224 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Footer from './footer'
|
||||
|
||||
// Configurable mock for search params
|
||||
let mockSearchParams = new URLSearchParams()
|
||||
const mockReplace = vi.fn()
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ replace: mockReplace }),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}))
|
||||
|
||||
// Mock service hook
|
||||
const mockInvalidDatasetList = vi.fn()
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
}))
|
||||
|
||||
// Mock CreateFromDSLModal to capture props
|
||||
let capturedActiveTab: string | undefined
|
||||
let capturedDslUrl: string | undefined
|
||||
|
||||
vi.mock('./create-options/create-from-dsl-modal', () => ({
|
||||
default: ({ show, onClose, onSuccess, activeTab, dslUrl }: {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
activeTab?: string
|
||||
dslUrl?: string
|
||||
}) => {
|
||||
capturedActiveTab = activeTab
|
||||
capturedDslUrl = dslUrl
|
||||
return show
|
||||
? (
|
||||
<div data-testid="dsl-modal">
|
||||
<button data-testid="close-modal" onClick={onClose}>Close</button>
|
||||
<button data-testid="success-modal" onClick={onSuccess}>Success</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
},
|
||||
CreateFromDSLModalTab: {
|
||||
FROM_URL: 'FROM_URL',
|
||||
FROM_FILE: 'FROM_FILE',
|
||||
},
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Footer Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Footer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSearchParams = new URLSearchParams()
|
||||
capturedActiveTab = undefined
|
||||
capturedDslUrl = undefined
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Footer />)
|
||||
expect(screen.getByText(/importDSL/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render import button with icon', () => {
|
||||
const { container } = render(<Footer />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show modal initially', () => {
|
||||
render(<Footer />)
|
||||
expect(screen.queryByTestId('dsl-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider', () => {
|
||||
const { container } = render(<Footer />)
|
||||
const divider = container.querySelector('[class*="w-8"]')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should open modal when import button is clicked', () => {
|
||||
render(<Footer />)
|
||||
|
||||
const importButton = screen.getByText(/importDSL/i)
|
||||
fireEvent.click(importButton)
|
||||
|
||||
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close modal when onClose is called', () => {
|
||||
render(<Footer />)
|
||||
|
||||
// Open modal
|
||||
const importButton = screen.getByText(/importDSL/i)
|
||||
fireEvent.click(importButton)
|
||||
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
|
||||
|
||||
// Close modal
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
fireEvent.click(closeButton)
|
||||
expect(screen.queryByTestId('dsl-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call invalidDatasetList on success', () => {
|
||||
render(<Footer />)
|
||||
|
||||
// Open modal
|
||||
const importButton = screen.getByText(/importDSL/i)
|
||||
fireEvent.click(importButton)
|
||||
|
||||
// Trigger success
|
||||
const successButton = screen.getByTestId('success-modal')
|
||||
fireEvent.click(successButton)
|
||||
|
||||
expect(mockInvalidDatasetList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper container classes', () => {
|
||||
const { container } = render(<Footer />)
|
||||
const footerDiv = container.firstChild as HTMLElement
|
||||
expect(footerDiv).toHaveClass('absolute', 'bottom-0', 'left-0', 'right-0', 'z-10')
|
||||
})
|
||||
|
||||
it('should have backdrop blur effect', () => {
|
||||
const { container } = render(<Footer />)
|
||||
const footerDiv = container.firstChild as HTMLElement
|
||||
expect(footerDiv).toHaveClass('backdrop-blur-[6px]')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<Footer />)
|
||||
rerender(<Footer />)
|
||||
expect(screen.getByText(/importDSL/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// URL Parameter Tests (Branch Coverage)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('URL Parameter Handling', () => {
|
||||
it('should set activeTab to FROM_URL when dslUrl is present', () => {
|
||||
mockSearchParams = new URLSearchParams('remoteInstallUrl=https://example.com/dsl')
|
||||
|
||||
render(<Footer />)
|
||||
|
||||
// Open modal to trigger prop capture
|
||||
const importButton = screen.getByText(/importDSL/i)
|
||||
fireEvent.click(importButton)
|
||||
|
||||
expect(capturedActiveTab).toBe('FROM_URL')
|
||||
expect(capturedDslUrl).toBe('https://example.com/dsl')
|
||||
})
|
||||
|
||||
it('should set activeTab to undefined when dslUrl is not present', () => {
|
||||
mockSearchParams = new URLSearchParams()
|
||||
|
||||
render(<Footer />)
|
||||
|
||||
// Open modal to trigger prop capture
|
||||
const importButton = screen.getByText(/importDSL/i)
|
||||
fireEvent.click(importButton)
|
||||
|
||||
expect(capturedActiveTab).toBeUndefined()
|
||||
expect(capturedDslUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should call replace when closing modal with dslUrl present', () => {
|
||||
mockSearchParams = new URLSearchParams('remoteInstallUrl=https://example.com/dsl')
|
||||
|
||||
render(<Footer />)
|
||||
|
||||
// Open modal
|
||||
const importButton = screen.getByText(/importDSL/i)
|
||||
fireEvent.click(importButton)
|
||||
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
|
||||
|
||||
// Close modal
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets/create-from-pipeline')
|
||||
})
|
||||
|
||||
it('should not call replace when closing modal without dslUrl', () => {
|
||||
mockSearchParams = new URLSearchParams()
|
||||
|
||||
render(<Footer />)
|
||||
|
||||
// Open modal
|
||||
const importButton = screen.getByText(/importDSL/i)
|
||||
fireEvent.click(importButton)
|
||||
|
||||
// Close modal
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,71 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Header from './header'
|
||||
|
||||
// ============================================================================
|
||||
// Header Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Header', () => {
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Header />)
|
||||
expect(screen.getByText(/backToKnowledge/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render back button with link to datasets', () => {
|
||||
render(<Header />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveAttribute('href', '/datasets')
|
||||
})
|
||||
|
||||
it('should render arrow icon in button', () => {
|
||||
const { container } = render(<Header />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render button with correct styling', () => {
|
||||
render(<Header />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('rounded-full')
|
||||
})
|
||||
|
||||
it('should have replace attribute on link', () => {
|
||||
const { container } = render(<Header />)
|
||||
const link = container.querySelector('a[href="/datasets"]')
|
||||
expect(link).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper container classes', () => {
|
||||
const { container } = render(<Header />)
|
||||
const headerDiv = container.firstChild as HTMLElement
|
||||
expect(headerDiv).toHaveClass('relative', 'flex', 'px-16', 'pb-2', 'pt-5')
|
||||
})
|
||||
|
||||
it('should position link absolutely at bottom left', () => {
|
||||
const { container } = render(<Header />)
|
||||
const link = container.querySelector('a')
|
||||
expect(link).toHaveClass('absolute', 'bottom-0', 'left-5')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<Header />)
|
||||
rerender(<Header />)
|
||||
expect(screen.getByText(/backToKnowledge/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,101 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import CreateFromPipeline from './index'
|
||||
|
||||
// Mock child components to isolate testing
|
||||
vi.mock('./header', () => ({
|
||||
default: () => <div data-testid="mock-header">Header</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./list', () => ({
|
||||
default: () => <div data-testid="mock-list">List</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./footer', () => ({
|
||||
default: () => <div data-testid="mock-footer">Footer</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../../base/effect', () => ({
|
||||
default: ({ className }: { className?: string }) => (
|
||||
<div data-testid="mock-effect" className={className}>Effect</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// CreateFromPipeline Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('CreateFromPipeline', () => {
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
expect(screen.getByTestId('mock-header')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Header component', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
expect(screen.getByTestId('mock-header')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render List component', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
expect(screen.getByTestId('mock-list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Footer component', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
expect(screen.getByTestId('mock-footer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Effect component', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
expect(screen.getByTestId('mock-effect')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper container classes', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
const mainDiv = container.firstChild as HTMLElement
|
||||
expect(mainDiv).toHaveClass('relative', 'flex', 'flex-col', 'overflow-hidden', 'rounded-t-2xl')
|
||||
})
|
||||
|
||||
it('should have correct height calculation', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
const mainDiv = container.firstChild as HTMLElement
|
||||
expect(mainDiv).toHaveClass('h-[calc(100vh-56px)]')
|
||||
})
|
||||
|
||||
it('should have border and background styling', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
const mainDiv = container.firstChild as HTMLElement
|
||||
expect(mainDiv).toHaveClass('border-t', 'border-effects-highlight', 'bg-background-default-subtle')
|
||||
})
|
||||
|
||||
it('should position Effect component correctly', () => {
|
||||
render(<CreateFromPipeline />)
|
||||
const effect = screen.getByTestId('mock-effect')
|
||||
expect(effect).toHaveClass('left-8', 'top-[-34px]', 'opacity-20')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Component Order Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Component Order', () => {
|
||||
it('should render components in correct order', () => {
|
||||
const { container } = render(<CreateFromPipeline />)
|
||||
const children = Array.from(container.firstChild?.childNodes || [])
|
||||
|
||||
// Effect, Header, List, Footer
|
||||
expect(children.length).toBe(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,276 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import BuiltInPipelineList from './built-in-pipeline-list'
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./create-card', () => ({
|
||||
default: () => <div data-testid="create-card">CreateCard</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./template-card', () => ({
|
||||
default: ({ type, pipeline, showMoreOperations }: { type: string, pipeline: { name: string }, showMoreOperations?: boolean }) => (
|
||||
<div data-testid="template-card" data-type={type} data-show-more={String(showMoreOperations)}>
|
||||
{pipeline.name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Configurable locale mock
|
||||
let mockLocale = 'en-US'
|
||||
|
||||
// Mock hooks
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => mockLocale,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn((selector) => {
|
||||
const state = { systemFeatures: { enable_marketplace: true } }
|
||||
return selector(state)
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockUsePipelineTemplateList = vi.fn()
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
usePipelineTemplateList: (...args: unknown[]) => mockUsePipelineTemplateList(...args),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// BuiltInPipelineList Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('BuiltInPipelineList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLocale = 'en-US'
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: { pipeline_templates: [] },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<BuiltInPipelineList />)
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should always render CreateCard', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
render(<BuiltInPipelineList />)
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Loading State Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Loading State', () => {
|
||||
it('should not render TemplateCards when loading', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: {
|
||||
pipeline_templates: [{ name: 'Pipeline 1' }],
|
||||
},
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
render(<BuiltInPipelineList />)
|
||||
expect(screen.queryByTestId('template-card')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering with Data Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering with Data', () => {
|
||||
it('should render TemplateCard for each pipeline when not loading', () => {
|
||||
const mockPipelines = [
|
||||
{ name: 'Pipeline 1' },
|
||||
{ name: 'Pipeline 2' },
|
||||
]
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: { pipeline_templates: mockPipelines },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<BuiltInPipelineList />)
|
||||
const cards = screen.getAllByTestId('template-card')
|
||||
expect(cards).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should pass correct props to TemplateCard', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: {
|
||||
pipeline_templates: [{ name: 'Test Pipeline' }],
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<BuiltInPipelineList />)
|
||||
const card = screen.getByTestId('template-card')
|
||||
expect(card).toHaveAttribute('data-type', 'built-in')
|
||||
expect(card).toHaveAttribute('data-show-more', 'false')
|
||||
})
|
||||
|
||||
it('should render CreateCard as first element', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: {
|
||||
pipeline_templates: [{ name: 'Pipeline 1' }],
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { container } = render(<BuiltInPipelineList />)
|
||||
const grid = container.querySelector('.grid')
|
||||
const firstChild = grid?.firstChild as HTMLElement
|
||||
expect(firstChild).toHaveAttribute('data-testid', 'create-card')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// API Call Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('API Call', () => {
|
||||
it('should call usePipelineTemplateList with type built-in', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
render(<BuiltInPipelineList />)
|
||||
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'built-in' }),
|
||||
expect.any(Boolean),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have grid layout', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: { pipeline_templates: [] },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { container } = render(<BuiltInPipelineList />)
|
||||
const grid = container.querySelector('.grid')
|
||||
expect(grid).toHaveClass('grid-cols-1', 'gap-3', 'py-2')
|
||||
})
|
||||
|
||||
it('should have responsive grid columns', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: { pipeline_templates: [] },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { container } = render(<BuiltInPipelineList />)
|
||||
const grid = container.querySelector('.grid')
|
||||
expect(grid).toHaveClass('sm:grid-cols-2', 'md:grid-cols-3', 'lg:grid-cols-4')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Locale Handling Tests (Branch Coverage)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Locale Handling', () => {
|
||||
it('should use zh-Hans locale when set', () => {
|
||||
mockLocale = 'zh-Hans'
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: { pipeline_templates: [] },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<BuiltInPipelineList />)
|
||||
|
||||
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ language: 'zh-Hans' }),
|
||||
expect.any(Boolean),
|
||||
)
|
||||
})
|
||||
|
||||
it('should use ja-JP locale when set', () => {
|
||||
mockLocale = 'ja-JP'
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: { pipeline_templates: [] },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<BuiltInPipelineList />)
|
||||
|
||||
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ language: 'ja-JP' }),
|
||||
expect.any(Boolean),
|
||||
)
|
||||
})
|
||||
|
||||
it('should fallback to default language for unsupported locales', () => {
|
||||
mockLocale = 'fr-FR'
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: { pipeline_templates: [] },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<BuiltInPipelineList />)
|
||||
|
||||
// Should fall back to LanguagesSupported[0] which is 'en-US'
|
||||
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ language: 'en-US' }),
|
||||
expect.any(Boolean),
|
||||
)
|
||||
})
|
||||
|
||||
it('should fallback to default language for en-US locale', () => {
|
||||
mockLocale = 'en-US'
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: { pipeline_templates: [] },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<BuiltInPipelineList />)
|
||||
|
||||
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ language: 'en-US' }),
|
||||
expect.any(Boolean),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Empty Data Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Empty Data', () => {
|
||||
it('should handle null pipeline_templates', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: { pipeline_templates: null },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<BuiltInPipelineList />)
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('template-card')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined data', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<BuiltInPipelineList />)
|
||||
expect(screen.getByTestId('create-card')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('template-card')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,190 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import CreateCard from './create-card'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
// Mock amplitude tracking
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock Toast
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
const mockCreateEmptyDataset = vi.fn()
|
||||
const mockInvalidDatasetList = vi.fn()
|
||||
|
||||
vi.mock('@/service/knowledge/use-create-dataset', () => ({
|
||||
useCreatePipelineDataset: () => ({
|
||||
mutateAsync: mockCreateEmptyDataset,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// CreateCard Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('CreateCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<CreateCard />)
|
||||
expect(screen.getByText(/createFromScratch\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title and description', () => {
|
||||
render(<CreateCard />)
|
||||
expect(screen.getByText(/createFromScratch\.title/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/createFromScratch\.description/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render add icon', () => {
|
||||
const { container } = render(<CreateCard />)
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call createEmptyDataset when clicked', async () => {
|
||||
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess({ id: 'new-dataset-id' })
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<CreateCard />)
|
||||
|
||||
const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(card!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should navigate to pipeline page on success', async () => {
|
||||
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess({ id: 'test-dataset-123' })
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<CreateCard />)
|
||||
|
||||
const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(card!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-123/pipeline')
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate dataset list on success', async () => {
|
||||
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess({ id: 'test-id' })
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<CreateCard />)
|
||||
|
||||
const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(card!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidDatasetList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle error callback', async () => {
|
||||
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onError(new Error('Create failed'))
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<CreateCard />)
|
||||
|
||||
const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(card!)
|
||||
|
||||
// Should not throw and should handle error gracefully
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not navigate when data is undefined', async () => {
|
||||
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess(undefined)
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<CreateCard />)
|
||||
|
||||
const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(card!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateEmptyDataset).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(mockPush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper card styling', () => {
|
||||
const { container } = render(<CreateCard />)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('relative', 'flex', 'cursor-pointer', 'flex-col', 'rounded-xl')
|
||||
})
|
||||
|
||||
it('should have fixed height', () => {
|
||||
const { container } = render(<CreateCard />)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('h-[132px]')
|
||||
})
|
||||
|
||||
it('should have shadow and border', () => {
|
||||
const { container } = render(<CreateCard />)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('border-[0.5px]', 'shadow-xs')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<CreateCard />)
|
||||
rerender(<CreateCard />)
|
||||
expect(screen.getByText(/createFromScratch\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,151 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import CustomizedList from './customized-list'
|
||||
|
||||
// Mock TemplateCard
|
||||
vi.mock('./template-card', () => ({
|
||||
default: ({ type, pipeline }: { type: string, pipeline: { name: string } }) => (
|
||||
<div data-testid="template-card" data-type={type}>
|
||||
{pipeline.name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock usePipelineTemplateList hook
|
||||
const mockUsePipelineTemplateList = vi.fn()
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
usePipelineTemplateList: (...args: unknown[]) => mockUsePipelineTemplateList(...args),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// CustomizedList Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('CustomizedList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Loading State Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Loading State', () => {
|
||||
it('should return null when loading', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
const { container } = render(<CustomizedList />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Empty State Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Empty State', () => {
|
||||
it('should return null when list is empty', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: { pipeline_templates: [] },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { container } = render(<CustomizedList />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when data is undefined', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { container } = render(<CustomizedList />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering with Data Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering with Data', () => {
|
||||
it('should render title when list has items', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: {
|
||||
pipeline_templates: [
|
||||
{ name: 'Pipeline 1' },
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<CustomizedList />)
|
||||
expect(screen.getByText(/customized/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TemplateCard for each pipeline', () => {
|
||||
const mockPipelines = [
|
||||
{ name: 'Pipeline 1' },
|
||||
{ name: 'Pipeline 2' },
|
||||
{ name: 'Pipeline 3' },
|
||||
]
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: { pipeline_templates: mockPipelines },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<CustomizedList />)
|
||||
const cards = screen.getAllByTestId('template-card')
|
||||
expect(cards).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should pass correct props to TemplateCard', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: {
|
||||
pipeline_templates: [{ name: 'Test Pipeline' }],
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<CustomizedList />)
|
||||
const card = screen.getByTestId('template-card')
|
||||
expect(card).toHaveAttribute('data-type', 'customized')
|
||||
expect(card).toHaveTextContent('Test Pipeline')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// API Call Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('API Call', () => {
|
||||
it('should call usePipelineTemplateList with type customized', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
render(<CustomizedList />)
|
||||
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith({ type: 'customized' })
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have grid layout for cards', () => {
|
||||
mockUsePipelineTemplateList.mockReturnValue({
|
||||
data: {
|
||||
pipeline_templates: [{ name: 'Pipeline 1' }],
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { container } = render(<CustomizedList />)
|
||||
const grid = container.querySelector('.grid')
|
||||
expect(grid).toHaveClass('grid-cols-1', 'gap-3', 'py-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,70 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import List from './index'
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./built-in-pipeline-list', () => ({
|
||||
default: () => <div data-testid="built-in-list">BuiltInPipelineList</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./customized-list', () => ({
|
||||
default: () => <div data-testid="customized-list">CustomizedList</div>,
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// List Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('List', () => {
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('built-in-list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render BuiltInPipelineList component', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('built-in-list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CustomizedList component', () => {
|
||||
render(<List />)
|
||||
expect(screen.getByTestId('customized-list')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper container classes', () => {
|
||||
const { container } = render(<List />)
|
||||
const listDiv = container.firstChild as HTMLElement
|
||||
expect(listDiv).toHaveClass('grow', 'overflow-y-auto', 'px-16', 'pb-[60px]', 'pt-1')
|
||||
})
|
||||
|
||||
it('should have gap between items', () => {
|
||||
const { container } = render(<List />)
|
||||
const listDiv = container.firstChild as HTMLElement
|
||||
expect(listDiv).toHaveClass('gap-y-1')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Component Order Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Component Order', () => {
|
||||
it('should render BuiltInPipelineList before CustomizedList', () => {
|
||||
const { container } = render(<List />)
|
||||
const children = Array.from(container.firstChild?.childNodes || [])
|
||||
|
||||
expect(children.length).toBe(2)
|
||||
expect((children[0] as HTMLElement).getAttribute('data-testid')).toBe('built-in-list')
|
||||
expect((children[1] as HTMLElement).getAttribute('data-testid')).toBe('customized-list')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,154 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Actions from './actions'
|
||||
|
||||
// ============================================================================
|
||||
// Actions Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Actions', () => {
|
||||
const defaultProps = {
|
||||
onApplyTemplate: vi.fn(),
|
||||
handleShowTemplateDetails: vi.fn(),
|
||||
showMoreOperations: true,
|
||||
openEditModal: vi.fn(),
|
||||
handleExportDSL: vi.fn(),
|
||||
handleDelete: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Actions {...defaultProps} />)
|
||||
expect(screen.getByText(/operations\.choose/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render choose button', () => {
|
||||
render(<Actions {...defaultProps} />)
|
||||
expect(screen.getByText(/operations\.choose/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render details button', () => {
|
||||
render(<Actions {...defaultProps} />)
|
||||
expect(screen.getByText(/operations\.details/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render add icon', () => {
|
||||
const { container } = render(<Actions {...defaultProps} />)
|
||||
const icons = container.querySelectorAll('svg')
|
||||
expect(icons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render arrow icon for details', () => {
|
||||
const { container } = render(<Actions {...defaultProps} />)
|
||||
const icons = container.querySelectorAll('svg')
|
||||
expect(icons.length).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// More Operations Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('More Operations', () => {
|
||||
it('should render more operations button when showMoreOperations is true', () => {
|
||||
const { container } = render(<Actions {...defaultProps} showMoreOperations={true} />)
|
||||
// CustomPopover should be rendered with more button
|
||||
const moreButton = container.querySelector('[class*="rounded-lg"]')
|
||||
expect(moreButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render more operations button when showMoreOperations is false', () => {
|
||||
render(<Actions {...defaultProps} showMoreOperations={false} />)
|
||||
// Should only have choose and details buttons
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call onApplyTemplate when choose button is clicked', () => {
|
||||
render(<Actions {...defaultProps} />)
|
||||
|
||||
const chooseButton = screen.getByText(/operations\.choose/i).closest('button')
|
||||
fireEvent.click(chooseButton!)
|
||||
|
||||
expect(defaultProps.onApplyTemplate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleShowTemplateDetails when details button is clicked', () => {
|
||||
render(<Actions {...defaultProps} />)
|
||||
|
||||
const detailsButton = screen.getByText(/operations\.details/i).closest('button')
|
||||
fireEvent.click(detailsButton!)
|
||||
|
||||
expect(defaultProps.handleShowTemplateDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Button Variants Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Button Variants', () => {
|
||||
it('should have primary variant for choose button', () => {
|
||||
render(<Actions {...defaultProps} />)
|
||||
const chooseButton = screen.getByText(/operations\.choose/i).closest('button')
|
||||
expect(chooseButton).toHaveClass('btn-primary')
|
||||
})
|
||||
|
||||
it('should have secondary variant for details button', () => {
|
||||
render(<Actions {...defaultProps} />)
|
||||
const detailsButton = screen.getByText(/operations\.details/i).closest('button')
|
||||
expect(detailsButton).toHaveClass('btn-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have absolute positioning', () => {
|
||||
const { container } = render(<Actions {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('absolute', 'bottom-0', 'left-0')
|
||||
})
|
||||
|
||||
it('should be hidden by default', () => {
|
||||
const { container } = render(<Actions {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('hidden')
|
||||
})
|
||||
|
||||
it('should show on group hover', () => {
|
||||
const { container } = render(<Actions {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('group-hover:flex')
|
||||
})
|
||||
|
||||
it('should have proper z-index', () => {
|
||||
const { container } = render(<Actions {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('z-10')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<Actions {...defaultProps} />)
|
||||
rerender(<Actions {...defaultProps} />)
|
||||
expect(screen.getByText(/operations\.choose/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,199 +0,0 @@
|
||||
import type { IconInfo } from '@/models/datasets'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
|
||||
import Content from './content'
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createIconInfo = (overrides: Partial<IconInfo> = {}): IconInfo => ({
|
||||
icon_type: 'emoji',
|
||||
icon: '📊',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createImageIconInfo = (overrides: Partial<IconInfo> = {}): IconInfo => ({
|
||||
icon_type: 'image',
|
||||
icon: 'file-id-123',
|
||||
icon_background: '',
|
||||
icon_url: 'https://example.com/icon.png',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Content Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Content', () => {
|
||||
const defaultProps = {
|
||||
name: 'Test Pipeline',
|
||||
description: 'This is a test pipeline description',
|
||||
iconInfo: createIconInfo(),
|
||||
chunkStructure: 'text' as ChunkingMode,
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Content {...defaultProps} />)
|
||||
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render name', () => {
|
||||
render(<Content {...defaultProps} />)
|
||||
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description', () => {
|
||||
render(<Content {...defaultProps} />)
|
||||
expect(screen.getByText('This is a test pipeline description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render chunking mode text', () => {
|
||||
render(<Content {...defaultProps} />)
|
||||
// The translation key should be rendered
|
||||
expect(screen.getByText(/chunkingMode/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have title attribute for truncation', () => {
|
||||
render(<Content {...defaultProps} />)
|
||||
const nameElement = screen.getByText('Test Pipeline')
|
||||
expect(nameElement).toHaveAttribute('title', 'Test Pipeline')
|
||||
})
|
||||
|
||||
it('should have title attribute on description', () => {
|
||||
render(<Content {...defaultProps} />)
|
||||
const descElement = screen.getByText('This is a test pipeline description')
|
||||
expect(descElement).toHaveAttribute('title', 'This is a test pipeline description')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Icon Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Icon Rendering', () => {
|
||||
it('should render emoji icon correctly', () => {
|
||||
const { container } = render(<Content {...defaultProps} />)
|
||||
// AppIcon component should be rendered
|
||||
const iconContainer = container.querySelector('[class*="shrink-0"]')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render image icon correctly', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
iconInfo: createImageIconInfo(),
|
||||
}
|
||||
const { container } = render(<Content {...props} />)
|
||||
const iconContainer = container.querySelector('[class*="shrink-0"]')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render chunk structure icon', () => {
|
||||
const { container } = render(<Content {...defaultProps} />)
|
||||
// Icon should be rendered in the corner
|
||||
const icons = container.querySelectorAll('svg')
|
||||
expect(icons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Chunk Structure Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Chunk Structure', () => {
|
||||
it('should handle text chunk structure', () => {
|
||||
render(<Content {...defaultProps} chunkStructure={ChunkingMode.text} />)
|
||||
expect(screen.getByText(/chunkingMode/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle parent-child chunk structure', () => {
|
||||
render(<Content {...defaultProps} chunkStructure={ChunkingMode.parentChild} />)
|
||||
expect(screen.getByText(/chunkingMode/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle qa chunk structure', () => {
|
||||
render(<Content {...defaultProps} chunkStructure={ChunkingMode.qa} />)
|
||||
expect(screen.getByText(/chunkingMode/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to General icon for unknown chunk structure', () => {
|
||||
const { container } = render(
|
||||
<Content {...defaultProps} chunkStructure={'unknown' as ChunkingMode} />,
|
||||
)
|
||||
const icons = container.querySelectorAll('svg')
|
||||
expect(icons.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper header layout', () => {
|
||||
const { container } = render(<Content {...defaultProps} />)
|
||||
const header = container.querySelector('[class*="gap-x-3"]')
|
||||
expect(header).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have truncate class on name', () => {
|
||||
render(<Content {...defaultProps} />)
|
||||
const nameElement = screen.getByText('Test Pipeline')
|
||||
expect(nameElement).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('should have line-clamp on description', () => {
|
||||
render(<Content {...defaultProps} />)
|
||||
const descElement = screen.getByText('This is a test pipeline description')
|
||||
expect(descElement).toHaveClass('line-clamp-3')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty name', () => {
|
||||
render(<Content {...defaultProps} name="" />)
|
||||
const { container } = render(<Content {...defaultProps} name="" />)
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty description', () => {
|
||||
render(<Content {...defaultProps} description="" />)
|
||||
const { container } = render(<Content {...defaultProps} description="" />)
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle long name', () => {
|
||||
const longName = 'A'.repeat(100)
|
||||
render(<Content {...defaultProps} name={longName} />)
|
||||
const nameElement = screen.getByText(longName)
|
||||
expect(nameElement).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('should handle long description', () => {
|
||||
const longDesc = 'A'.repeat(500)
|
||||
render(<Content {...defaultProps} description={longDesc} />)
|
||||
const descElement = screen.getByText(longDesc)
|
||||
expect(descElement).toHaveClass('line-clamp-3')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<Content {...defaultProps} />)
|
||||
rerender(<Content {...defaultProps} />)
|
||||
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,182 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import ChunkStructureCard from './chunk-structure-card'
|
||||
import { EffectColor } from './types'
|
||||
|
||||
// ============================================================================
|
||||
// ChunkStructureCard Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('ChunkStructureCard', () => {
|
||||
const defaultProps = {
|
||||
icon: <span data-testid="test-icon">Icon</span>,
|
||||
title: 'General',
|
||||
description: 'General chunk structure description',
|
||||
effectColor: EffectColor.indigo,
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<ChunkStructureCard {...defaultProps} />)
|
||||
expect(screen.getByText('General')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title', () => {
|
||||
render(<ChunkStructureCard {...defaultProps} />)
|
||||
expect(screen.getByText('General')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description', () => {
|
||||
render(<ChunkStructureCard {...defaultProps} />)
|
||||
expect(screen.getByText('General chunk structure description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon', () => {
|
||||
render(<ChunkStructureCard {...defaultProps} />)
|
||||
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render description when empty', () => {
|
||||
render(<ChunkStructureCard {...defaultProps} description="" />)
|
||||
expect(screen.getByText('General')).toBeInTheDocument()
|
||||
expect(screen.queryByText('General chunk structure description')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render description when undefined', () => {
|
||||
const { description: _, ...propsWithoutDesc } = defaultProps
|
||||
render(<ChunkStructureCard {...propsWithoutDesc} />)
|
||||
expect(screen.getByText('General')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Effect Colors Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Effect Colors', () => {
|
||||
it('should apply indigo effect color', () => {
|
||||
const { container } = render(
|
||||
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.indigo} />,
|
||||
)
|
||||
const effectElement = container.querySelector('[class*="blur-"]')
|
||||
expect(effectElement).toHaveClass('bg-util-colors-indigo-indigo-600')
|
||||
})
|
||||
|
||||
it('should apply blueLight effect color', () => {
|
||||
const { container } = render(
|
||||
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.blueLight} />,
|
||||
)
|
||||
const effectElement = container.querySelector('[class*="blur-"]')
|
||||
expect(effectElement).toHaveClass('bg-util-colors-blue-light-blue-light-500')
|
||||
})
|
||||
|
||||
it('should apply green effect color', () => {
|
||||
const { container } = render(
|
||||
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.green} />,
|
||||
)
|
||||
const effectElement = container.querySelector('[class*="blur-"]')
|
||||
expect(effectElement).toHaveClass('bg-util-colors-teal-teal-600')
|
||||
})
|
||||
|
||||
it('should handle none effect color', () => {
|
||||
const { container } = render(
|
||||
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.none} />,
|
||||
)
|
||||
const effectElement = container.querySelector('[class*="blur-"]')
|
||||
expect(effectElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Icon Background Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Icon Background', () => {
|
||||
it('should apply indigo icon background', () => {
|
||||
const { container } = render(
|
||||
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.indigo} />,
|
||||
)
|
||||
const iconBg = container.querySelector('[class*="bg-components-icon-bg"]')
|
||||
expect(iconBg).toHaveClass('bg-components-icon-bg-indigo-solid')
|
||||
})
|
||||
|
||||
it('should apply blue light icon background', () => {
|
||||
const { container } = render(
|
||||
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.blueLight} />,
|
||||
)
|
||||
const iconBg = container.querySelector('[class*="bg-components-icon-bg"]')
|
||||
expect(iconBg).toHaveClass('bg-components-icon-bg-blue-light-solid')
|
||||
})
|
||||
|
||||
it('should apply green icon background', () => {
|
||||
const { container } = render(
|
||||
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.green} />,
|
||||
)
|
||||
const iconBg = container.querySelector('[class*="bg-components-icon-bg"]')
|
||||
expect(iconBg).toHaveClass('bg-components-icon-bg-teal-solid')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Custom className Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Custom className', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(
|
||||
<ChunkStructureCard {...defaultProps} className="custom-class" />,
|
||||
)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should merge custom className with default classes', () => {
|
||||
const { container } = render(
|
||||
<ChunkStructureCard {...defaultProps} className="custom-class" />,
|
||||
)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('relative', 'flex', 'custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper card styling', () => {
|
||||
const { container } = render(<ChunkStructureCard {...defaultProps} />)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('relative', 'flex', 'overflow-hidden', 'rounded-xl')
|
||||
})
|
||||
|
||||
it('should have border styling', () => {
|
||||
const { container } = render(<ChunkStructureCard {...defaultProps} />)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('border-[0.5px]', 'border-components-panel-border-subtle')
|
||||
})
|
||||
|
||||
it('should have shadow styling', () => {
|
||||
const { container } = render(<ChunkStructureCard {...defaultProps} />)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('shadow-xs')
|
||||
})
|
||||
|
||||
it('should have blur effect element', () => {
|
||||
const { container } = render(<ChunkStructureCard {...defaultProps} />)
|
||||
const blurElement = container.querySelector('[class*="blur-"]')
|
||||
expect(blurElement).toHaveClass('absolute', '-left-1', '-top-1', 'size-14', 'rounded-full')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<ChunkStructureCard {...defaultProps} />)
|
||||
rerender(<ChunkStructureCard {...defaultProps} />)
|
||||
expect(screen.getByText('General')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,138 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import { useChunkStructureConfig } from './hooks'
|
||||
import { EffectColor } from './types'
|
||||
|
||||
// ============================================================================
|
||||
// useChunkStructureConfig Hook Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('useChunkStructureConfig', () => {
|
||||
// --------------------------------------------------------------------------
|
||||
// Return Value Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Return Value', () => {
|
||||
it('should return config object', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current).toBeDefined()
|
||||
expect(typeof result.current).toBe('object')
|
||||
})
|
||||
|
||||
it('should have config for text chunking mode', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current[ChunkingMode.text]).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have config for parent-child chunking mode', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current[ChunkingMode.parentChild]).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have config for qa chunking mode', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current[ChunkingMode.qa]).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Text/General Config Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Text/General Config', () => {
|
||||
it('should have title for text mode', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current[ChunkingMode.text].title).toBe('General')
|
||||
})
|
||||
|
||||
it('should have description for text mode', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current[ChunkingMode.text].description).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have icon for text mode', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current[ChunkingMode.text].icon).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have indigo effect color for text mode', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current[ChunkingMode.text].effectColor).toBe(EffectColor.indigo)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Parent-Child Config Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Parent-Child Config', () => {
|
||||
it('should have title for parent-child mode', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current[ChunkingMode.parentChild].title).toBe('Parent-Child')
|
||||
})
|
||||
|
||||
it('should have description for parent-child mode', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current[ChunkingMode.parentChild].description).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have icon for parent-child mode', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current[ChunkingMode.parentChild].icon).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have blueLight effect color for parent-child mode', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current[ChunkingMode.parentChild].effectColor).toBe(EffectColor.blueLight)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Q&A Config Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Q&A Config', () => {
|
||||
it('should have title for qa mode', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current[ChunkingMode.qa].title).toBe('Q&A')
|
||||
})
|
||||
|
||||
it('should have description for qa mode', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current[ChunkingMode.qa].description).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have icon for qa mode', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current[ChunkingMode.qa].icon).toBeDefined()
|
||||
})
|
||||
|
||||
it('should have green effect color for qa mode', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
expect(result.current[ChunkingMode.qa].effectColor).toBe(EffectColor.green)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Option Structure Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Option Structure', () => {
|
||||
it('should have all required fields in each option', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
|
||||
Object.values(result.current).forEach((option) => {
|
||||
expect(option).toHaveProperty('icon')
|
||||
expect(option).toHaveProperty('title')
|
||||
expect(option).toHaveProperty('description')
|
||||
expect(option).toHaveProperty('effectColor')
|
||||
})
|
||||
})
|
||||
|
||||
it('should cover all ChunkingMode values', () => {
|
||||
const { result } = renderHook(() => useChunkStructureConfig())
|
||||
const modes = Object.values(ChunkingMode)
|
||||
|
||||
modes.forEach((mode) => {
|
||||
expect(result.current[mode]).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,360 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Details from './index'
|
||||
|
||||
// Mock WorkflowPreview
|
||||
vi.mock('@/app/components/workflow/workflow-preview', () => ({
|
||||
default: ({ className }: { className?: string }) => (
|
||||
<div data-testid="workflow-preview" className={className}>
|
||||
WorkflowPreview
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock service hook
|
||||
const mockUsePipelineTemplateById = vi.fn()
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
usePipelineTemplateById: (...args: unknown[]) => mockUsePipelineTemplateById(...args),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createPipelineTemplateInfo = (overrides = {}) => ({
|
||||
name: 'Test Pipeline',
|
||||
description: 'This is a test pipeline',
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '📊',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
created_by: 'Test User',
|
||||
chunk_structure: 'text',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
export_data: '',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createImageIconPipelineInfo = () => ({
|
||||
...createPipelineTemplateInfo(),
|
||||
icon_info: {
|
||||
icon_type: 'image',
|
||||
icon: 'file-id-123',
|
||||
icon_background: '',
|
||||
icon_url: 'https://example.com/icon.png',
|
||||
},
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Details Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Details', () => {
|
||||
const defaultProps = {
|
||||
id: 'pipeline-1',
|
||||
type: 'customized' as const,
|
||||
onApplyTemplate: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Loading State Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Loading State', () => {
|
||||
it('should show loading when data is not available', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: null,
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
// Loading component should be rendered
|
||||
expect(screen.queryByText('Test Pipeline')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when data is available', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render pipeline name', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render pipeline description', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
expect(screen.getByText('This is a test pipeline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render created by when available', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
expect(screen.getByText(/details\.createdBy/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render created by when not available', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo({ created_by: '' }),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
expect(screen.queryByText(/details\.createdBy/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render use template button', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
expect(screen.getByText(/operations\.useTemplate/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render structure section', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
expect(screen.getByText(/details\.structure/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
const { container } = render(<Details {...defaultProps} />)
|
||||
const closeButton = container.querySelector('button[type="button"]')
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workflow preview', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tooltip for structure', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
// Tooltip component should be present
|
||||
expect(screen.getByText(/details\.structure/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClose when close button is clicked', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
const { container } = render(<Details {...defaultProps} />)
|
||||
const closeButton = container.querySelector('button[type="button"]')
|
||||
fireEvent.click(closeButton!)
|
||||
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onApplyTemplate when use template button is clicked', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
const useButton = screen.getByText(/operations\.useTemplate/i).closest('button')
|
||||
fireEvent.click(useButton!)
|
||||
|
||||
expect(defaultProps.onApplyTemplate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Icon Types Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Icon Types', () => {
|
||||
it('should handle emoji icon type', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle image icon type', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createImageIconPipelineInfo(),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have default icon when data is null', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: null,
|
||||
})
|
||||
|
||||
// When data is null, component shows loading state
|
||||
// The default icon is only used in useMemo when pipelineTemplateInfo is null
|
||||
render(<Details {...defaultProps} />)
|
||||
|
||||
// Should not crash and should render (loading state)
|
||||
expect(screen.queryByText('Test Pipeline')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// API Call Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('API Call', () => {
|
||||
it('should call usePipelineTemplateById with correct params', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
|
||||
expect(mockUsePipelineTemplateById).toHaveBeenCalledWith(
|
||||
{ template_id: 'pipeline-1', type: 'customized' },
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('should call usePipelineTemplateById with built-in type', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} type="built-in" />)
|
||||
|
||||
expect(mockUsePipelineTemplateById).toHaveBeenCalledWith(
|
||||
{ template_id: 'pipeline-1', type: 'built-in' },
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Chunk Structure Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Chunk Structure', () => {
|
||||
it('should render chunk structure card for text mode', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo({ chunk_structure: 'text' }),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
expect(screen.getByText(/details\.structure/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render chunk structure card for parent-child mode', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo({ chunk_structure: 'hierarchical' }),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
expect(screen.getByText(/details\.structure/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render chunk structure card for qa mode', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo({ chunk_structure: 'qa' }),
|
||||
})
|
||||
|
||||
render(<Details {...defaultProps} />)
|
||||
expect(screen.getByText(/details\.structure/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper container styling', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
const { container } = render(<Details {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('flex', 'h-full')
|
||||
})
|
||||
|
||||
it('should have fixed width sidebar', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
const { container } = render(<Details {...defaultProps} />)
|
||||
const sidebar = container.querySelector('[class*="w-[360px]"]')
|
||||
expect(sidebar).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have workflow preview container with grow class', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
const { container } = render(<Details {...defaultProps} />)
|
||||
const previewContainer = container.querySelector('[class*="grow"]')
|
||||
expect(previewContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
mockUsePipelineTemplateById.mockReturnValue({
|
||||
data: createPipelineTemplateInfo(),
|
||||
})
|
||||
|
||||
const { rerender } = render(<Details {...defaultProps} />)
|
||||
rerender(<Details {...defaultProps} />)
|
||||
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,665 +0,0 @@
|
||||
import type { PipelineTemplate } from '@/models/pipeline'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import EditPipelineInfo from './edit-pipeline-info'
|
||||
|
||||
// Mock service hooks
|
||||
const mockUpdatePipeline = vi.fn()
|
||||
const mockInvalidCustomizedTemplateList = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useUpdateTemplateInfo: () => ({
|
||||
mutateAsync: mockUpdatePipeline,
|
||||
}),
|
||||
useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
|
||||
}))
|
||||
|
||||
// Mock Toast
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock AppIconPicker to capture interactions
|
||||
let _mockOnSelect: ((icon: { type: 'emoji' | 'image', icon?: string, background?: string, fileId?: string, url?: string }) => void) | undefined
|
||||
let _mockOnClose: (() => void) | undefined
|
||||
|
||||
vi.mock('@/app/components/base/app-icon-picker', () => ({
|
||||
default: ({ onSelect, onClose }: {
|
||||
onSelect: (icon: { type: 'emoji' | 'image', icon?: string, background?: string, fileId?: string, url?: string }) => void
|
||||
onClose: () => void
|
||||
}) => {
|
||||
_mockOnSelect = onSelect
|
||||
_mockOnClose = onClose
|
||||
return (
|
||||
<div data-testid="app-icon-picker">
|
||||
<button data-testid="select-emoji" onClick={() => onSelect({ type: 'emoji', icon: '🎯', background: '#FFEAD5' })}>
|
||||
Select Emoji
|
||||
</button>
|
||||
<button data-testid="select-image" onClick={() => onSelect({ type: 'image', fileId: 'new-file-id', url: 'https://new-icon.com/icon.png' })}>
|
||||
Select Image
|
||||
</button>
|
||||
<button data-testid="close-picker" onClick={onClose}>
|
||||
Close Picker
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createPipelineTemplate = (overrides: Partial<PipelineTemplate> = {}): PipelineTemplate => ({
|
||||
id: 'pipeline-1',
|
||||
name: 'Test Pipeline',
|
||||
description: 'Test pipeline description',
|
||||
icon: {
|
||||
icon_type: 'emoji',
|
||||
icon: '📊',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
chunk_structure: ChunkingMode.text,
|
||||
position: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createImagePipelineTemplate = (): PipelineTemplate => ({
|
||||
id: 'pipeline-2',
|
||||
name: 'Image Pipeline',
|
||||
description: 'Pipeline with image icon',
|
||||
icon: {
|
||||
icon_type: 'image',
|
||||
icon: 'file-id-123',
|
||||
icon_background: '',
|
||||
icon_url: 'https://example.com/icon.png',
|
||||
},
|
||||
chunk_structure: ChunkingMode.text,
|
||||
position: 1,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// EditPipelineInfo Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('EditPipelineInfo', () => {
|
||||
const defaultProps = {
|
||||
onClose: vi.fn(),
|
||||
pipeline: createPipelineTemplate(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
_mockOnSelect = undefined
|
||||
_mockOnClose = undefined
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
expect(screen.getByText(/editPipelineInfo/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title', () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
expect(screen.getByText(/editPipelineInfo/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
const { container } = render(<EditPipelineInfo {...defaultProps} />)
|
||||
const closeButton = container.querySelector('button[type="button"]')
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render name input with initial value', () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
const input = screen.getByDisplayValue('Test Pipeline')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description textarea with initial value', () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
const textarea = screen.getByDisplayValue('Test pipeline description')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render save and cancel buttons', () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render name and icon label', () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
expect(screen.getByText(/pipelineNameAndIcon/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description label', () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
expect(screen.getByText(/knowledgeDescription/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClose when close button is clicked', () => {
|
||||
const { container } = render(<EditPipelineInfo {...defaultProps} />)
|
||||
|
||||
const closeButton = container.querySelector('button[type="button"]')
|
||||
fireEvent.click(closeButton!)
|
||||
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when cancel button is clicked', () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
|
||||
const cancelButton = screen.getByText(/operation\.cancel/i)
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should update name when input changes', () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
|
||||
const input = screen.getByDisplayValue('Test Pipeline')
|
||||
fireEvent.change(input, { target: { value: 'New Pipeline Name' } })
|
||||
|
||||
expect(screen.getByDisplayValue('New Pipeline Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update description when textarea changes', () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
|
||||
const textarea = screen.getByDisplayValue('Test pipeline description')
|
||||
fireEvent.change(textarea, { target: { value: 'New description' } })
|
||||
|
||||
expect(screen.getByDisplayValue('New description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call updatePipeline when save is clicked with valid name', async () => {
|
||||
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePipeline).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate template list on successful save', async () => {
|
||||
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidCustomizedTemplateList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onClose on successful save', async () => {
|
||||
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(defaultProps.onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Validation Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Validation', () => {
|
||||
it('should show error toast when name is empty', async () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
|
||||
const input = screen.getByDisplayValue('Test Pipeline')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Please enter a name for the Knowledge Base.',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call updatePipeline when name is empty', async () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
|
||||
const input = screen.getByDisplayValue('Test Pipeline')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePipeline).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Icon Types Tests (Branch Coverage for lines 29-30, 36-37)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Icon Types', () => {
|
||||
it('should initialize with emoji icon type when pipeline has emoji icon', () => {
|
||||
const { container } = render(<EditPipelineInfo {...defaultProps} />)
|
||||
// Should render component with emoji icon
|
||||
expect(container.querySelector('[class*="cursor-pointer"]')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Test Pipeline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should initialize with image icon type when pipeline has image icon', async () => {
|
||||
const imagePipeline = createImagePipelineTemplate()
|
||||
// Verify test data has image icon type - this ensures the factory returns correct data
|
||||
expect(imagePipeline.icon.icon_type).toBe('image')
|
||||
expect(imagePipeline.icon.icon).toBe('file-id-123')
|
||||
expect(imagePipeline.icon.icon_url).toBe('https://example.com/icon.png')
|
||||
|
||||
const props = {
|
||||
onClose: vi.fn(),
|
||||
pipeline: imagePipeline,
|
||||
}
|
||||
const { container } = render(<EditPipelineInfo {...props} />)
|
||||
// Component should initialize with image icon state
|
||||
expect(screen.getByDisplayValue('Image Pipeline')).toBeInTheDocument()
|
||||
expect(container.querySelector('[class*="cursor-pointer"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly with image icon and then update', () => {
|
||||
// This test exercises both the initialization and update paths for image icon
|
||||
const imagePipeline = createImagePipelineTemplate()
|
||||
const props = {
|
||||
...defaultProps,
|
||||
pipeline: imagePipeline,
|
||||
}
|
||||
const { container } = render(<EditPipelineInfo {...props} />)
|
||||
|
||||
// Verify component rendered with image pipeline
|
||||
expect(screen.getByDisplayValue('Image Pipeline')).toBeInTheDocument()
|
||||
|
||||
// Open icon picker
|
||||
const appIcon = container.querySelector('[class*="cursor-pointer"]')
|
||||
fireEvent.click(appIcon!)
|
||||
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save correct icon_info when starting with image icon type', async () => {
|
||||
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
const props = {
|
||||
...defaultProps,
|
||||
pipeline: createImagePipelineTemplate(),
|
||||
}
|
||||
render(<EditPipelineInfo {...props} />)
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePipeline).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
icon_info: expect.objectContaining({
|
||||
icon_type: 'image',
|
||||
icon: 'file-id-123',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should save correct icon_info when starting with emoji icon type', async () => {
|
||||
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePipeline).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
icon_info: expect.objectContaining({
|
||||
icon_type: 'emoji',
|
||||
icon: '📊',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should revert to initial image icon when picker is closed without selection', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
pipeline: createImagePipelineTemplate(),
|
||||
}
|
||||
const { container } = render(<EditPipelineInfo {...props} />)
|
||||
|
||||
// Open picker
|
||||
const appIcon = container.querySelector('[class*="cursor-pointer"]')
|
||||
fireEvent.click(appIcon!)
|
||||
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
|
||||
|
||||
// Close without selection - should revert to original image icon
|
||||
const closeButton = screen.getByTestId('close-picker')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch from image icon to emoji icon when selected', async () => {
|
||||
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
const props = {
|
||||
...defaultProps,
|
||||
pipeline: createImagePipelineTemplate(),
|
||||
}
|
||||
const { container } = render(<EditPipelineInfo {...props} />)
|
||||
|
||||
// Open picker and select emoji
|
||||
const appIcon = container.querySelector('[class*="cursor-pointer"]')
|
||||
fireEvent.click(appIcon!)
|
||||
const selectEmojiButton = screen.getByTestId('select-emoji')
|
||||
fireEvent.click(selectEmojiButton)
|
||||
|
||||
// Save
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePipeline).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
icon_info: expect.objectContaining({
|
||||
icon_type: 'emoji',
|
||||
icon: '🎯',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch from emoji icon to image icon when selected', async () => {
|
||||
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
const { container } = render(<EditPipelineInfo {...defaultProps} />)
|
||||
|
||||
// Open picker and select image
|
||||
const appIcon = container.querySelector('[class*="cursor-pointer"]')
|
||||
fireEvent.click(appIcon!)
|
||||
const selectImageButton = screen.getByTestId('select-image')
|
||||
fireEvent.click(selectImageButton)
|
||||
|
||||
// Save
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePipeline).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
icon_info: expect.objectContaining({
|
||||
icon_type: 'image',
|
||||
icon: 'new-file-id',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// AppIconPicker Tests (Branch Coverage)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('AppIconPicker', () => {
|
||||
it('should not show picker initially', () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open picker when icon is clicked', () => {
|
||||
const { container } = render(<EditPipelineInfo {...defaultProps} />)
|
||||
const appIcon = container.querySelector('[class*="cursor-pointer"]')
|
||||
fireEvent.click(appIcon!)
|
||||
|
||||
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close picker and update icon when emoji is selected', () => {
|
||||
const { container } = render(<EditPipelineInfo {...defaultProps} />)
|
||||
const appIcon = container.querySelector('[class*="cursor-pointer"]')
|
||||
fireEvent.click(appIcon!)
|
||||
|
||||
const selectEmojiButton = screen.getByTestId('select-emoji')
|
||||
fireEvent.click(selectEmojiButton)
|
||||
|
||||
// Picker should close
|
||||
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close picker and update icon when image is selected', () => {
|
||||
const { container } = render(<EditPipelineInfo {...defaultProps} />)
|
||||
const appIcon = container.querySelector('[class*="cursor-pointer"]')
|
||||
fireEvent.click(appIcon!)
|
||||
|
||||
const selectImageButton = screen.getByTestId('select-image')
|
||||
fireEvent.click(selectImageButton)
|
||||
|
||||
// Picker should close
|
||||
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should revert icon when picker is closed without selection', () => {
|
||||
const { container } = render(<EditPipelineInfo {...defaultProps} />)
|
||||
const appIcon = container.querySelector('[class*="cursor-pointer"]')
|
||||
fireEvent.click(appIcon!)
|
||||
|
||||
const closeButton = screen.getByTestId('close-picker')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
// Picker should close
|
||||
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save with new emoji icon selection', async () => {
|
||||
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
const { container } = render(<EditPipelineInfo {...defaultProps} />)
|
||||
|
||||
// Open picker and select new emoji
|
||||
const appIcon = container.querySelector('[class*="cursor-pointer"]')
|
||||
fireEvent.click(appIcon!)
|
||||
const selectEmojiButton = screen.getByTestId('select-emoji')
|
||||
fireEvent.click(selectEmojiButton)
|
||||
|
||||
// Save
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePipeline).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
icon_info: expect.objectContaining({
|
||||
icon_type: 'emoji',
|
||||
icon: '🎯',
|
||||
icon_background: '#FFEAD5',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should save with new image icon selection', async () => {
|
||||
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
const { container } = render(<EditPipelineInfo {...defaultProps} />)
|
||||
|
||||
// Open picker and select new image
|
||||
const appIcon = container.querySelector('[class*="cursor-pointer"]')
|
||||
fireEvent.click(appIcon!)
|
||||
const selectImageButton = screen.getByTestId('select-image')
|
||||
fireEvent.click(selectImageButton)
|
||||
|
||||
// Save
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePipeline).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
icon_info: expect.objectContaining({
|
||||
icon_type: 'image',
|
||||
icon: 'new-file-id',
|
||||
icon_url: 'https://new-icon.com/icon.png',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Save Request Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Save Request', () => {
|
||||
it('should send correct request with emoji icon', async () => {
|
||||
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePipeline).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
template_id: 'pipeline-1',
|
||||
name: 'Test Pipeline',
|
||||
description: 'Test pipeline description',
|
||||
icon_info: expect.objectContaining({
|
||||
icon_type: 'emoji',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should send correct request with image icon', async () => {
|
||||
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
const props = {
|
||||
...defaultProps,
|
||||
pipeline: createImagePipelineTemplate(),
|
||||
}
|
||||
render(<EditPipelineInfo {...props} />)
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePipeline).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
template_id: 'pipeline-2',
|
||||
icon_info: expect.objectContaining({
|
||||
icon_type: 'image',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper container styling', () => {
|
||||
const { container } = render(<EditPipelineInfo {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('relative', 'flex', 'flex-col')
|
||||
})
|
||||
|
||||
it('should have close button in header', () => {
|
||||
const { container } = render(<EditPipelineInfo {...defaultProps} />)
|
||||
const closeButton = container.querySelector('button.absolute')
|
||||
expect(closeButton).toHaveClass('right-5', 'top-5')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<EditPipelineInfo {...defaultProps} />)
|
||||
rerender(<EditPipelineInfo {...defaultProps} />)
|
||||
expect(screen.getByText(/editPipelineInfo/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,722 +0,0 @@
|
||||
import type { PipelineTemplate } from '@/models/pipeline'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import TemplateCard from './index'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
// Mock amplitude tracking
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock Toast
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock downloadFile utility
|
||||
vi.mock('@/utils/format', () => ({
|
||||
downloadFile: vi.fn(),
|
||||
}))
|
||||
|
||||
// Capture Confirm callbacks
|
||||
let _capturedOnConfirm: (() => void) | undefined
|
||||
let _capturedOnCancel: (() => void) | undefined
|
||||
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ isShow, onConfirm, onCancel, title, content }: {
|
||||
isShow: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
title: string
|
||||
content: string
|
||||
}) => {
|
||||
_capturedOnConfirm = onConfirm
|
||||
_capturedOnCancel = onCancel
|
||||
return isShow
|
||||
? (
|
||||
<div data-testid="confirm-dialog">
|
||||
<div data-testid="confirm-title">{title}</div>
|
||||
<div data-testid="confirm-content">{content}</div>
|
||||
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
|
||||
<button data-testid="confirm-submit" onClick={onConfirm}>Confirm</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
},
|
||||
}))
|
||||
|
||||
// Capture Actions callbacks
|
||||
let _capturedHandleDelete: (() => void) | undefined
|
||||
let _capturedHandleExportDSL: (() => void) | undefined
|
||||
let _capturedOpenEditModal: (() => void) | undefined
|
||||
|
||||
vi.mock('./actions', () => ({
|
||||
default: ({ onApplyTemplate, handleShowTemplateDetails, showMoreOperations, openEditModal, handleExportDSL, handleDelete }: {
|
||||
onApplyTemplate: () => void
|
||||
handleShowTemplateDetails: () => void
|
||||
showMoreOperations: boolean
|
||||
openEditModal: () => void
|
||||
handleExportDSL: () => void
|
||||
handleDelete: () => void
|
||||
}) => {
|
||||
_capturedHandleDelete = handleDelete
|
||||
_capturedHandleExportDSL = handleExportDSL
|
||||
_capturedOpenEditModal = openEditModal
|
||||
return (
|
||||
<div data-testid="actions">
|
||||
<button data-testid="action-choose" onClick={onApplyTemplate}>operations.choose</button>
|
||||
<button data-testid="action-details" onClick={handleShowTemplateDetails}>operations.details</button>
|
||||
{showMoreOperations && (
|
||||
<>
|
||||
<button data-testid="action-edit" onClick={openEditModal}>Edit</button>
|
||||
<button data-testid="action-export" onClick={handleExportDSL}>Export</button>
|
||||
<button data-testid="action-delete" onClick={handleDelete}>Delete</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock EditPipelineInfo component
|
||||
vi.mock('./edit-pipeline-info', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<div data-testid="edit-pipeline-info">
|
||||
<button data-testid="edit-close" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Details component
|
||||
vi.mock('./details', () => ({
|
||||
default: ({ onClose, onApplyTemplate }: { onClose: () => void, onApplyTemplate: () => void }) => (
|
||||
<div data-testid="details-component">
|
||||
<button data-testid="details-close" onClick={onClose}>Close</button>
|
||||
<button data-testid="details-apply" onClick={onApplyTemplate}>Apply</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock service hooks
|
||||
const mockCreateDataset = vi.fn()
|
||||
const mockInvalidDatasetList = vi.fn()
|
||||
const mockGetPipelineTemplateInfo = vi.fn()
|
||||
const mockDeletePipeline = vi.fn()
|
||||
const mockExportPipelineDSL = vi.fn()
|
||||
const mockInvalidCustomizedTemplateList = vi.fn()
|
||||
const mockHandleCheckPluginDependencies = vi.fn()
|
||||
|
||||
// Configurable isPending for export
|
||||
let mockIsExporting = false
|
||||
|
||||
vi.mock('@/service/knowledge/use-create-dataset', () => ({
|
||||
useCreatePipelineDatasetFromCustomized: () => ({
|
||||
mutateAsync: mockCreateDataset,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useInvalidDatasetList: () => mockInvalidDatasetList,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
usePipelineTemplateById: () => ({
|
||||
refetch: mockGetPipelineTemplateInfo,
|
||||
}),
|
||||
useDeleteTemplate: () => ({
|
||||
mutateAsync: mockDeletePipeline,
|
||||
}),
|
||||
useExportTemplateDSL: () => ({
|
||||
mutateAsync: mockExportPipelineDSL,
|
||||
get isPending() { return mockIsExporting },
|
||||
}),
|
||||
useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
|
||||
}))
|
||||
|
||||
// Mock plugin dependencies hook
|
||||
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
|
||||
usePluginDependencies: () => ({
|
||||
handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
|
||||
}),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createPipelineTemplate = (overrides: Partial<PipelineTemplate> = {}): PipelineTemplate => ({
|
||||
id: 'pipeline-1',
|
||||
name: 'Test Pipeline',
|
||||
description: 'Test pipeline description',
|
||||
icon: {
|
||||
icon_type: 'emoji',
|
||||
icon: '📊',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
},
|
||||
chunk_structure: ChunkingMode.text,
|
||||
position: 1,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// TemplateCard Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('TemplateCard', () => {
|
||||
const defaultProps = {
|
||||
pipeline: createPipelineTemplate(),
|
||||
showMoreOperations: true,
|
||||
type: 'customized' as const,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsExporting = false
|
||||
_capturedOnConfirm = undefined
|
||||
_capturedOnCancel = undefined
|
||||
_capturedHandleDelete = undefined
|
||||
_capturedHandleExportDSL = undefined
|
||||
_capturedOpenEditModal = undefined
|
||||
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
|
||||
mockGetPipelineTemplateInfo.mockResolvedValue({
|
||||
data: {
|
||||
export_data: 'yaml_content_here',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render pipeline name', () => {
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render pipeline description', () => {
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
expect(screen.getByText('Test pipeline description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Content component', () => {
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test pipeline description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Actions component', () => {
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
expect(screen.getByTestId('actions')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('action-choose')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('action-details')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Use Template Flow Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Use Template Flow', () => {
|
||||
it('should show error when template info fetch fails', async () => {
|
||||
mockGetPipelineTemplateInfo.mockResolvedValue({ data: null })
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const chooseButton = screen.getByTestId('action-choose')
|
||||
fireEvent.click(chooseButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should create dataset when template is applied', async () => {
|
||||
mockCreateDataset.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const chooseButton = screen.getByTestId('action-choose')
|
||||
fireEvent.click(chooseButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateDataset).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should navigate to pipeline page on successful creation', async () => {
|
||||
mockCreateDataset.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const chooseButton = screen.getByTestId('action-choose')
|
||||
fireEvent.click(chooseButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/new-dataset-123/pipeline')
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate dataset list on successful creation', async () => {
|
||||
mockCreateDataset.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const chooseButton = screen.getByTestId('action-choose')
|
||||
fireEvent.click(chooseButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidDatasetList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show success toast on successful creation', async () => {
|
||||
mockCreateDataset.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const chooseButton = screen.getByTestId('action-choose')
|
||||
fireEvent.click(chooseButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast on creation failure', async () => {
|
||||
mockCreateDataset.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onError(new Error('Creation failed'))
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const chooseButton = screen.getByTestId('action-choose')
|
||||
fireEvent.click(chooseButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Details Modal Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Details Modal', () => {
|
||||
it('should open details modal when details button is clicked', async () => {
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const detailsButton = screen.getByTestId('action-details')
|
||||
fireEvent.click(detailsButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('details-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close details modal when close is triggered', async () => {
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const detailsButton = screen.getByTestId('action-details')
|
||||
fireEvent.click(detailsButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('details-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const closeButton = screen.getByTestId('details-close')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('details-component')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should trigger use template from details modal', async () => {
|
||||
mockCreateDataset.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const detailsButton = screen.getByTestId('action-details')
|
||||
fireEvent.click(detailsButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('details-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const applyButton = screen.getByTestId('details-apply')
|
||||
fireEvent.click(applyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateDataset).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Pipeline ID Branch Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Pipeline ID Branch', () => {
|
||||
it('should call handleCheckPluginDependencies when pipeline_id is present', async () => {
|
||||
mockCreateDataset.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const chooseButton = screen.getByTestId('action-choose')
|
||||
fireEvent.click(chooseButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('pipe-123', true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call handleCheckPluginDependencies when pipeline_id is falsy', async () => {
|
||||
mockCreateDataset.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: '' })
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const chooseButton = screen.getByTestId('action-choose')
|
||||
fireEvent.click(chooseButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/new-dataset-123/pipeline')
|
||||
})
|
||||
expect(mockHandleCheckPluginDependencies).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call handleCheckPluginDependencies when pipeline_id is null', async () => {
|
||||
mockCreateDataset.mockImplementation((_data, callbacks) => {
|
||||
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: null })
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const chooseButton = screen.getByTestId('action-choose')
|
||||
fireEvent.click(chooseButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/new-dataset-123/pipeline')
|
||||
})
|
||||
expect(mockHandleCheckPluginDependencies).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Export DSL Tests (Branch Coverage)
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Export DSL', () => {
|
||||
it('should not export when already exporting', async () => {
|
||||
mockIsExporting = true
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const exportButton = screen.getByTestId('action-export')
|
||||
fireEvent.click(exportButton)
|
||||
|
||||
// Export should not be called due to isExporting check
|
||||
expect(mockExportPipelineDSL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call exportPipelineDSL on export action', async () => {
|
||||
mockExportPipelineDSL.mockImplementation((_id, callbacks) => {
|
||||
callbacks.onSuccess({ data: 'yaml_content' })
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const exportButton = screen.getByTestId('action-export')
|
||||
fireEvent.click(exportButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportPipelineDSL).toHaveBeenCalledWith('pipeline-1', expect.any(Object))
|
||||
})
|
||||
})
|
||||
|
||||
it('should show success toast on export success', async () => {
|
||||
mockExportPipelineDSL.mockImplementation((_id, callbacks) => {
|
||||
callbacks.onSuccess({ data: 'yaml_content' })
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const exportButton = screen.getByTestId('action-export')
|
||||
fireEvent.click(exportButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast on export failure', async () => {
|
||||
mockExportPipelineDSL.mockImplementation((_id, callbacks) => {
|
||||
callbacks.onError(new Error('Export failed'))
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const exportButton = screen.getByTestId('action-export')
|
||||
fireEvent.click(exportButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: expect.any(String),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call downloadFile on successful export', async () => {
|
||||
const { downloadFile } = await import('@/utils/format')
|
||||
mockExportPipelineDSL.mockImplementation((_id, callbacks) => {
|
||||
callbacks.onSuccess({ data: 'yaml_content' })
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const exportButton = screen.getByTestId('action-export')
|
||||
fireEvent.click(exportButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(downloadFile).toHaveBeenCalledWith(expect.objectContaining({
|
||||
fileName: 'Test Pipeline.pipeline',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Delete Flow Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Delete Flow', () => {
|
||||
it('should show confirm dialog when delete is clicked', async () => {
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const deleteButton = screen.getByTestId('action-delete')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close confirm dialog when cancel is clicked (onCancelDelete)', async () => {
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const deleteButton = screen.getByTestId('action-delete')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const cancelButton = screen.getByTestId('confirm-cancel')
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call deletePipeline when confirm is clicked (onConfirmDelete)', async () => {
|
||||
mockDeletePipeline.mockImplementation((_id, callbacks) => {
|
||||
callbacks.onSuccess()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const deleteButton = screen.getByTestId('action-delete')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByTestId('confirm-submit')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeletePipeline).toHaveBeenCalledWith('pipeline-1', expect.any(Object))
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate template list on successful delete', async () => {
|
||||
mockDeletePipeline.mockImplementation((_id, callbacks) => {
|
||||
callbacks.onSuccess()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const deleteButton = screen.getByTestId('action-delete')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByTestId('confirm-submit')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvalidCustomizedTemplateList).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close confirm dialog after successful delete', async () => {
|
||||
mockDeletePipeline.mockImplementation((_id, callbacks) => {
|
||||
callbacks.onSuccess()
|
||||
return Promise.resolve()
|
||||
})
|
||||
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const deleteButton = screen.getByTestId('action-delete')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const confirmButton = screen.getByTestId('confirm-submit')
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edit Modal Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edit Modal', () => {
|
||||
it('should open edit modal when edit button is clicked', async () => {
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const editButton = screen.getByTestId('action-edit')
|
||||
fireEvent.click(editButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-pipeline-info')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close edit modal when close is triggered', async () => {
|
||||
render(<TemplateCard {...defaultProps} />)
|
||||
const editButton = screen.getByTestId('action-edit')
|
||||
fireEvent.click(editButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-pipeline-info')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const closeButton = screen.getByTestId('edit-close')
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('edit-pipeline-info')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Props Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Props', () => {
|
||||
it('should show more operations when showMoreOperations is true', () => {
|
||||
render(<TemplateCard {...defaultProps} showMoreOperations={true} />)
|
||||
expect(screen.getByTestId('action-edit')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('action-export')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('action-delete')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide more operations when showMoreOperations is false', () => {
|
||||
render(<TemplateCard {...defaultProps} showMoreOperations={false} />)
|
||||
expect(screen.queryByTestId('action-edit')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('action-export')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('action-delete')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should default showMoreOperations to true', () => {
|
||||
const { pipeline, type } = defaultProps
|
||||
render(<TemplateCard pipeline={pipeline} type={type} />)
|
||||
expect(screen.getByTestId('action-edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle built-in type', () => {
|
||||
render(<TemplateCard {...defaultProps} type="built-in" />)
|
||||
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle customized type', () => {
|
||||
render(<TemplateCard {...defaultProps} type="customized" />)
|
||||
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have proper card styling', () => {
|
||||
const { container } = render(<TemplateCard {...defaultProps} />)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('group', 'relative', 'flex', 'cursor-pointer', 'flex-col', 'rounded-xl')
|
||||
})
|
||||
|
||||
it('should have fixed height', () => {
|
||||
const { container } = render(<TemplateCard {...defaultProps} />)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('h-[132px]')
|
||||
})
|
||||
|
||||
it('should have shadow and border', () => {
|
||||
const { container } = render(<TemplateCard {...defaultProps} />)
|
||||
const card = container.firstChild as HTMLElement
|
||||
expect(card).toHaveClass('border-[0.5px]', 'shadow-xs')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<TemplateCard {...defaultProps} />)
|
||||
rerender(<TemplateCard {...defaultProps} />)
|
||||
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,144 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Operations from './operations'
|
||||
|
||||
// ============================================================================
|
||||
// Operations Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Operations', () => {
|
||||
const defaultProps = {
|
||||
openEditModal: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onExport: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
expect(screen.getByText(/editInfo/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all operation buttons', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
expect(screen.getByText(/editInfo/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/exportPipeline/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/delete/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have proper container styling', () => {
|
||||
const { container } = render(<Operations {...defaultProps} />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('relative', 'flex', 'flex-col', 'rounded-xl')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call openEditModal when edit is clicked', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
|
||||
const editButton = screen.getByText(/editInfo/i).closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(editButton!)
|
||||
|
||||
expect(defaultProps.openEditModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onExport when export is clicked', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
|
||||
const exportButton = screen.getByText(/exportPipeline/i).closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(exportButton!)
|
||||
|
||||
expect(defaultProps.onExport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onDelete when delete is clicked', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
|
||||
const deleteButton = screen.getByText(/delete/i).closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(deleteButton!)
|
||||
|
||||
expect(defaultProps.onDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should stop propagation on edit click', () => {
|
||||
const stopPropagation = vi.fn()
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
render(<Operations {...defaultProps} />)
|
||||
|
||||
const editButton = screen.getByText(/editInfo/i).closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(editButton!, {
|
||||
stopPropagation,
|
||||
preventDefault,
|
||||
})
|
||||
|
||||
expect(defaultProps.openEditModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop propagation on export click', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
|
||||
const exportButton = screen.getByText(/exportPipeline/i).closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(exportButton!)
|
||||
|
||||
expect(defaultProps.onExport).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop propagation on delete click', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
|
||||
const deleteButton = screen.getByText(/delete/i).closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(deleteButton!)
|
||||
|
||||
expect(defaultProps.onDelete).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Layout Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Layout', () => {
|
||||
it('should have divider between sections', () => {
|
||||
const { container } = render(<Operations {...defaultProps} />)
|
||||
const divider = container.querySelector('[class*="bg-divider"]')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have hover states on buttons', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
|
||||
const editButton = screen.getByText(/editInfo/i).closest('div[class*="cursor-pointer"]')
|
||||
expect(editButton).toHaveClass('hover:bg-state-base-hover')
|
||||
})
|
||||
|
||||
it('should have destructive hover state on delete button', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
|
||||
const deleteButton = screen.getByText(/delete/i).closest('div[class*="cursor-pointer"]')
|
||||
expect(deleteButton).toHaveClass('hover:bg-state-destructive-hover')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<Operations {...defaultProps} />)
|
||||
rerender(<Operations {...defaultProps} />)
|
||||
expect(screen.getByText(/editInfo/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,407 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ============================================================================
|
||||
// Component Imports (after mocks)
|
||||
// ============================================================================
|
||||
|
||||
import UrlInput from './url-input'
|
||||
|
||||
// ============================================================================
|
||||
// Mock Setup
|
||||
// ============================================================================
|
||||
|
||||
// Mock useDocLink hook
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: vi.fn(() => () => 'https://docs.example.com'),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// UrlInput Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('UrlInput', () => {
|
||||
const mockOnRun = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render input with placeholder from docLink', () => {
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('placeholder', 'https://docs.example.com')
|
||||
})
|
||||
|
||||
it('should render button with run text when not running', () => {
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveTextContent(/run/i)
|
||||
})
|
||||
|
||||
it('should render button without run text when running', () => {
|
||||
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
|
||||
const button = screen.getByRole('button')
|
||||
// Button should not have "run" text when running (shows loading state instead)
|
||||
expect(button).not.toHaveTextContent(/run/i)
|
||||
})
|
||||
|
||||
it('should show loading state on button when running', () => {
|
||||
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
|
||||
// Button should show loading text when running
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveTextContent(/loading/i)
|
||||
})
|
||||
|
||||
it('should not show loading state on button when not running', () => {
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).not.toHaveTextContent(/loading/i)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should update input value when user types', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
expect(input).toHaveValue('https://example.com')
|
||||
})
|
||||
|
||||
it('should call onRun with url when button is clicked and not running', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockOnRun).toHaveBeenCalledWith('https://example.com')
|
||||
expect(mockOnRun).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should NOT call onRun when button is clicked and isRunning is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
// Use fireEvent since userEvent might not work well with disabled-like states
|
||||
fireEvent.change(input, { target: { value: 'https://example.com' } })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// onRun should NOT be called because isRunning is true
|
||||
expect(mockOnRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onRun with empty string when button clicked with empty input', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockOnRun).toHaveBeenCalledWith('')
|
||||
expect(mockOnRun).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle multiple button clicks when not running', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'https://test.com')
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
await user.click(button)
|
||||
|
||||
expect(mockOnRun).toHaveBeenCalledTimes(2)
|
||||
expect(mockOnRun).toHaveBeenCalledWith('https://test.com')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Props Variations Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Props Variations', () => {
|
||||
it('should update button state when isRunning changes from false to true', () => {
|
||||
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveTextContent(/run/i)
|
||||
|
||||
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
|
||||
|
||||
// When running, button shows loading state instead of "run" text
|
||||
expect(button).not.toHaveTextContent(/run/i)
|
||||
})
|
||||
|
||||
it('should update button state when isRunning changes from true to false', () => {
|
||||
const { rerender } = render(<UrlInput isRunning={true} onRun={mockOnRun} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
// When running, button shows loading state instead of "run" text
|
||||
expect(button).not.toHaveTextContent(/run/i)
|
||||
|
||||
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
expect(button).toHaveTextContent(/run/i)
|
||||
})
|
||||
|
||||
it('should preserve input value when isRunning prop changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'https://preserved.com')
|
||||
|
||||
expect(input).toHaveValue('https://preserved.com')
|
||||
|
||||
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
|
||||
|
||||
expect(input).toHaveValue('https://preserved.com')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle special characters in url', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const specialUrl = 'https://example.com/path?query=test¶m=value#anchor'
|
||||
await user.type(input, specialUrl)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockOnRun).toHaveBeenCalledWith(specialUrl)
|
||||
})
|
||||
|
||||
it('should handle unicode characters in url', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const unicodeUrl = 'https://example.com/路径/文件'
|
||||
await user.type(input, unicodeUrl)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockOnRun).toHaveBeenCalledWith(unicodeUrl)
|
||||
})
|
||||
|
||||
it('should handle very long url', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
const longUrl = `https://example.com/${'a'.repeat(500)}`
|
||||
|
||||
// Use fireEvent for long text to avoid timeout
|
||||
fireEvent.change(input, { target: { value: longUrl } })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockOnRun).toHaveBeenCalledWith(longUrl)
|
||||
})
|
||||
|
||||
it('should handle whitespace in url', async () => {
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: ' https://example.com ' } })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(mockOnRun).toHaveBeenCalledWith(' https://example.com ')
|
||||
})
|
||||
|
||||
it('should handle rapid input changes', async () => {
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'a' } })
|
||||
fireEvent.change(input, { target: { value: 'ab' } })
|
||||
fireEvent.change(input, { target: { value: 'abc' } })
|
||||
fireEvent.change(input, { target: { value: 'https://final.com' } })
|
||||
|
||||
expect(input).toHaveValue('https://final.com')
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(mockOnRun).toHaveBeenCalledWith('https://final.com')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// handleOnRun Branch Coverage Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('handleOnRun Branch Coverage', () => {
|
||||
it('should return early when isRunning is true (branch: isRunning = true)', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'https://test.com' } })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// The early return should prevent onRun from being called
|
||||
expect(mockOnRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onRun when isRunning is false (branch: isRunning = false)', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'https://test.com' } })
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// onRun should be called when isRunning is false
|
||||
expect(mockOnRun).toHaveBeenCalledWith('https://test.com')
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Button Text Branch Coverage Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Button Text Branch Coverage', () => {
|
||||
it('should display run text when isRunning is false (branch: !isRunning = true)', () => {
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
// When !isRunning is true, button shows the translated "run" text
|
||||
expect(button).toHaveTextContent(/run/i)
|
||||
})
|
||||
|
||||
it('should not display run text when isRunning is true (branch: !isRunning = false)', () => {
|
||||
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
// When !isRunning is false, button shows empty string '' (loading state shows spinner)
|
||||
expect(button).not.toHaveTextContent(/run/i)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use useCallback for handleUrlChange', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'test')
|
||||
|
||||
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
// Input should maintain value after rerender
|
||||
expect(input).toHaveValue('test')
|
||||
})
|
||||
|
||||
it('should use useCallback for handleOnRun', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
expect(mockOnRun).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Integration Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Integration', () => {
|
||||
it('should complete full workflow: type url -> click run -> verify callback', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
// Type URL
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'https://mywebsite.com')
|
||||
|
||||
// Click run
|
||||
const button = screen.getByRole('button')
|
||||
await user.click(button)
|
||||
|
||||
// Verify callback
|
||||
expect(mockOnRun).toHaveBeenCalledWith('https://mywebsite.com')
|
||||
})
|
||||
|
||||
it('should show correct states during running workflow', () => {
|
||||
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
|
||||
// Initial state: not running
|
||||
expect(screen.getByRole('button')).toHaveTextContent(/run/i)
|
||||
|
||||
// Simulate running state
|
||||
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
|
||||
expect(screen.getByRole('button')).not.toHaveTextContent(/run/i)
|
||||
|
||||
// Simulate finished state
|
||||
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
|
||||
expect(screen.getByRole('button')).toHaveTextContent(/run/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,701 +0,0 @@
|
||||
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ============================================================================
|
||||
// Component Import (after mocks)
|
||||
// ============================================================================
|
||||
|
||||
import FireCrawl from './index'
|
||||
|
||||
// ============================================================================
|
||||
// Mock Setup - Only mock API calls and context
|
||||
// ============================================================================
|
||||
|
||||
// Mock API service
|
||||
const mockCreateFirecrawlTask = vi.fn()
|
||||
const mockCheckFirecrawlTaskStatus = vi.fn()
|
||||
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
createFirecrawlTask: (...args: unknown[]) => mockCreateFirecrawlTask(...args),
|
||||
checkFirecrawlTaskStatus: (...args: unknown[]) => mockCheckFirecrawlTaskStatus(...args),
|
||||
}))
|
||||
|
||||
// Mock modal context
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContextSelector: vi.fn(() => mockSetShowAccountSettingModal),
|
||||
}))
|
||||
|
||||
// Mock sleep utility to speed up tests
|
||||
vi.mock('@/utils', () => ({
|
||||
sleep: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
// Mock useDocLink hook for UrlInput placeholder
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: vi.fn(() => () => 'https://docs.example.com'),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factory
|
||||
// ============================================================================
|
||||
|
||||
const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
|
||||
crawl_sub_pages: true,
|
||||
limit: 10,
|
||||
max_depth: 2,
|
||||
excludes: '',
|
||||
includes: '',
|
||||
only_main_content: false,
|
||||
use_sitemap: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockCrawlResultItem = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
|
||||
title: 'Test Page',
|
||||
markdown: '# Test Content',
|
||||
description: 'Test page description',
|
||||
source_url: 'https://example.com/page',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// FireCrawl Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('FireCrawl', () => {
|
||||
const mockOnPreview = vi.fn()
|
||||
const mockOnCheckedCrawlResultChange = vi.fn()
|
||||
const mockOnJobIdChange = vi.fn()
|
||||
const mockOnCrawlOptionsChange = vi.fn()
|
||||
|
||||
const defaultProps = {
|
||||
onPreview: mockOnPreview,
|
||||
checkedCrawlResult: [] as CrawlResultItem[],
|
||||
onCheckedCrawlResultChange: mockOnCheckedCrawlResultChange,
|
||||
onJobIdChange: mockOnJobIdChange,
|
||||
crawlOptions: createMockCrawlOptions(),
|
||||
onCrawlOptionsChange: mockOnCrawlOptionsChange,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateFirecrawlTask.mockReset()
|
||||
mockCheckFirecrawlTaskStatus.mockReset()
|
||||
})
|
||||
|
||||
// Helper to get URL input (first textbox with specific placeholder)
|
||||
const getUrlInput = () => {
|
||||
return screen.getByPlaceholderText('https://docs.example.com')
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/firecrawlTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Header component with correct props', () => {
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/firecrawlTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/configureFirecrawl/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/firecrawlDoc/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render UrlInput component', () => {
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
expect(getUrlInput()).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Options component', () => {
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/limit/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render crawling or result components initially', () => {
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
// Crawling and result components should not be visible in init state
|
||||
expect(screen.queryByText(/crawling/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Configuration Button Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Configuration Button', () => {
|
||||
it('should call setShowAccountSettingModal when configure button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const configButton = screen.getByText(/configureFirecrawl/i)
|
||||
await user.click(configButton)
|
||||
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
|
||||
payload: 'data-source',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// URL Validation Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('URL Validation', () => {
|
||||
it('should show error toast when URL is empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
// Should not call API when validation fails
|
||||
expect(mockCreateFirecrawlTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error toast when URL does not start with http:// or https://', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'invalid-url.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
// Should not call API when validation fails
|
||||
expect(mockCreateFirecrawlTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error toast when limit is empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
const propsWithEmptyLimit = {
|
||||
...defaultProps,
|
||||
crawlOptions: createMockCrawlOptions({ limit: '' as unknown as number }),
|
||||
}
|
||||
render(<FireCrawl {...propsWithEmptyLimit} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
// Should not call API when validation fails
|
||||
expect(mockCreateFirecrawlTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error toast when limit is null', async () => {
|
||||
const user = userEvent.setup()
|
||||
const propsWithNullLimit = {
|
||||
...defaultProps,
|
||||
crawlOptions: createMockCrawlOptions({ limit: null as unknown as number }),
|
||||
}
|
||||
render(<FireCrawl {...propsWithNullLimit} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
expect(mockCreateFirecrawlTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should accept valid http:// URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job-id' })
|
||||
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
|
||||
status: 'completed',
|
||||
data: [],
|
||||
total: 0,
|
||||
current: 0,
|
||||
time_consuming: 1,
|
||||
})
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'http://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateFirecrawlTask).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should accept valid https:// URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job-id' })
|
||||
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
|
||||
status: 'completed',
|
||||
data: [],
|
||||
total: 0,
|
||||
current: 0,
|
||||
time_consuming: 1,
|
||||
})
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateFirecrawlTask).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Crawl Execution Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Crawl Execution', () => {
|
||||
it('should call createFirecrawlTask with correct parameters', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job-id' })
|
||||
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
|
||||
status: 'completed',
|
||||
data: [],
|
||||
total: 0,
|
||||
current: 0,
|
||||
time_consuming: 1,
|
||||
})
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateFirecrawlTask).toHaveBeenCalledWith({
|
||||
url: 'https://example.com',
|
||||
options: expect.objectContaining({
|
||||
crawl_sub_pages: true,
|
||||
limit: 10,
|
||||
max_depth: 2,
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onJobIdChange with job_id from API response', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'my-job-123' })
|
||||
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
|
||||
status: 'completed',
|
||||
data: [],
|
||||
total: 0,
|
||||
current: 0,
|
||||
time_consuming: 1,
|
||||
})
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnJobIdChange).toHaveBeenCalledWith('my-job-123')
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove empty max_depth from crawlOptions before sending to API', async () => {
|
||||
const user = userEvent.setup()
|
||||
const propsWithEmptyMaxDepth = {
|
||||
...defaultProps,
|
||||
crawlOptions: createMockCrawlOptions({ max_depth: '' as unknown as number }),
|
||||
}
|
||||
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job-id' })
|
||||
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
|
||||
status: 'completed',
|
||||
data: [],
|
||||
total: 0,
|
||||
current: 0,
|
||||
time_consuming: 1,
|
||||
})
|
||||
|
||||
render(<FireCrawl {...propsWithEmptyMaxDepth} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateFirecrawlTask).toHaveBeenCalledWith({
|
||||
url: 'https://example.com',
|
||||
options: expect.not.objectContaining({
|
||||
max_depth: '',
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should show loading state while running', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateFirecrawlTask.mockImplementation(() => new Promise(() => {})) // Never resolves
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
// Button should show loading state (no longer show "run" text)
|
||||
await waitFor(() => {
|
||||
expect(runButton).not.toHaveTextContent(/run/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Crawl Status Polling Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Crawl Status Polling', () => {
|
||||
it('should handle completed status', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockResults = [createMockCrawlResultItem()]
|
||||
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
|
||||
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
|
||||
status: 'completed',
|
||||
data: mockResults,
|
||||
total: 1,
|
||||
current: 1,
|
||||
time_consuming: 2.5,
|
||||
})
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith(mockResults)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle error status from API', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
|
||||
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
|
||||
status: 'error',
|
||||
message: 'Crawl failed',
|
||||
data: [],
|
||||
})
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/exceptionErrorTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle missing status as error', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
|
||||
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
|
||||
status: undefined,
|
||||
message: 'No status',
|
||||
data: [],
|
||||
})
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/exceptionErrorTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should poll again when status is pending', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
|
||||
mockCheckFirecrawlTaskStatus
|
||||
.mockResolvedValueOnce({
|
||||
status: 'pending',
|
||||
data: [{ title: 'Page 1', markdown: 'content', source_url: 'https://example.com/1' }],
|
||||
total: 5,
|
||||
current: 1,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 'completed',
|
||||
data: [{ title: 'Page 1', markdown: 'content', source_url: 'https://example.com/1' }],
|
||||
total: 5,
|
||||
current: 5,
|
||||
time_consuming: 3,
|
||||
})
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCheckFirecrawlTaskStatus).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update progress during crawling', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
|
||||
mockCheckFirecrawlTaskStatus
|
||||
.mockResolvedValueOnce({
|
||||
status: 'pending',
|
||||
data: [{ title: 'Page 1', markdown: 'content', source_url: 'https://example.com/1' }],
|
||||
total: 10,
|
||||
current: 3,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 'completed',
|
||||
data: [{ title: 'Page 1', markdown: 'content', source_url: 'https://example.com/1' }],
|
||||
total: 10,
|
||||
current: 10,
|
||||
time_consuming: 5,
|
||||
})
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Error Handling Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Error Handling', () => {
|
||||
it('should handle API exception during task creation', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateFirecrawlTask.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/exceptionErrorTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle API exception during status check', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
|
||||
mockCheckFirecrawlTaskStatus.mockRejectedValueOnce({
|
||||
json: () => Promise.resolve({ message: 'Status check failed' }),
|
||||
})
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/exceptionErrorTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display error message from API', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
|
||||
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
|
||||
status: 'error',
|
||||
message: 'Custom error message',
|
||||
data: [],
|
||||
})
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Custom error message')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display unknown error when no error message provided', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
|
||||
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
|
||||
status: 'error',
|
||||
message: undefined,
|
||||
data: [],
|
||||
})
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/unknownError/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Options Change Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Options Change', () => {
|
||||
it('should call onCrawlOptionsChange when options change', () => {
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
// Find and change limit input
|
||||
const limitInput = screen.getByDisplayValue('10')
|
||||
fireEvent.change(limitInput, { target: { value: '20' } })
|
||||
|
||||
expect(mockOnCrawlOptionsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: 20 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onCrawlOptionsChange when checkbox changes', () => {
|
||||
const { container } = render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
// Use data-testid to find checkboxes since they are custom div elements
|
||||
const checkboxes = container.querySelectorAll('[data-testid^="checkbox-"]')
|
||||
fireEvent.click(checkboxes[0]) // crawl_sub_pages
|
||||
|
||||
expect(mockOnCrawlOptionsChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ crawl_sub_pages: false }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Crawled Result Display Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Crawled Result Display', () => {
|
||||
it('should display CrawledResult when crawl is finished successfully', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockResults = [
|
||||
createMockCrawlResultItem({ title: 'Result Page 1' }),
|
||||
createMockCrawlResultItem({ title: 'Result Page 2' }),
|
||||
]
|
||||
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
|
||||
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
|
||||
status: 'completed',
|
||||
data: mockResults,
|
||||
total: 2,
|
||||
current: 2,
|
||||
time_consuming: 1.5,
|
||||
})
|
||||
|
||||
render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Result Page 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Result Page 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should limit total to crawlOptions.limit', async () => {
|
||||
const user = userEvent.setup()
|
||||
const propsWithLimit5 = {
|
||||
...defaultProps,
|
||||
crawlOptions: createMockCrawlOptions({ limit: 5 }),
|
||||
}
|
||||
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
|
||||
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
|
||||
status: 'completed',
|
||||
data: [],
|
||||
total: 100, // API returns more than limit
|
||||
current: 5,
|
||||
time_consuming: 1,
|
||||
})
|
||||
|
||||
render(<FireCrawl {...propsWithLimit5} />)
|
||||
|
||||
const input = getUrlInput()
|
||||
await user.type(input, 'https://example.com')
|
||||
|
||||
const runButton = screen.getByRole('button', { name: /run/i })
|
||||
await user.click(runButton)
|
||||
|
||||
await waitFor(() => {
|
||||
// Total should be capped to limit (5)
|
||||
expect(mockCheckFirecrawlTaskStatus).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const { rerender } = render(<FireCrawl {...defaultProps} />)
|
||||
|
||||
rerender(<FireCrawl {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/firecrawlTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,405 +0,0 @@
|
||||
import type { CrawlOptions } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Options from './options'
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factory
|
||||
// ============================================================================
|
||||
|
||||
const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
|
||||
crawl_sub_pages: true,
|
||||
limit: 10,
|
||||
max_depth: 2,
|
||||
excludes: '',
|
||||
includes: '',
|
||||
only_main_content: false,
|
||||
use_sitemap: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Options Component Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Options', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Helper to get checkboxes by test id pattern
|
||||
const getCheckboxes = (container: HTMLElement) => {
|
||||
return container.querySelectorAll('[data-testid^="checkbox-"]')
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Rendering Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
// Check that key elements are rendered
|
||||
expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/limit/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/maxDepth/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render all form fields', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
// Checkboxes
|
||||
expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/extractOnlyMainContent/i)).toBeInTheDocument()
|
||||
|
||||
// Text/Number fields
|
||||
expect(screen.getByText(/limit/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/maxDepth/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/excludePaths/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/includeOnlyPaths/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with custom className', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
const { container } = render(
|
||||
<Options payload={payload} onChange={mockOnChange} className="custom-class" />,
|
||||
)
|
||||
|
||||
const rootElement = container.firstChild as HTMLElement
|
||||
expect(rootElement).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should render limit field with required indicator', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
// Limit field should have required indicator (*)
|
||||
const requiredIndicator = screen.getByText('*')
|
||||
expect(requiredIndicator).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder for excludes field', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const excludesInput = screen.getByPlaceholderText('blog/*, /about/*')
|
||||
expect(excludesInput).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render placeholder for includes field', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const includesInput = screen.getByPlaceholderText('articles/*')
|
||||
expect(includesInput).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render two checkboxes', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
expect(checkboxes.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Props Display Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Props Display', () => {
|
||||
it('should display crawl_sub_pages checkbox with check icon when true', () => {
|
||||
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
// First checkbox should have check icon when checked
|
||||
expect(checkboxes[0].querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display crawl_sub_pages checkbox without check icon when false', () => {
|
||||
const payload = createMockCrawlOptions({ crawl_sub_pages: false })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
// First checkbox should not have check icon when unchecked
|
||||
expect(checkboxes[0].querySelector('svg')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display only_main_content checkbox with check icon when true', () => {
|
||||
const payload = createMockCrawlOptions({ only_main_content: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
// Second checkbox should have check icon when checked
|
||||
expect(checkboxes[1].querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display only_main_content checkbox without check icon when false', () => {
|
||||
const payload = createMockCrawlOptions({ only_main_content: false })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
// Second checkbox should not have check icon when unchecked
|
||||
expect(checkboxes[1].querySelector('svg')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display limit value in input', () => {
|
||||
const payload = createMockCrawlOptions({ limit: 25 })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const limitInput = screen.getByDisplayValue('25')
|
||||
expect(limitInput).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display max_depth value in input', () => {
|
||||
const payload = createMockCrawlOptions({ max_depth: 5 })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const maxDepthInput = screen.getByDisplayValue('5')
|
||||
expect(maxDepthInput).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display excludes value in input', () => {
|
||||
const payload = createMockCrawlOptions({ excludes: 'test/*' })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const excludesInput = screen.getByDisplayValue('test/*')
|
||||
expect(excludesInput).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display includes value in input', () => {
|
||||
const payload = createMockCrawlOptions({ includes: 'docs/*' })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const includesInput = screen.getByDisplayValue('docs/*')
|
||||
expect(includesInput).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// User Interactions Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => {
|
||||
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
fireEvent.click(checkboxes[0])
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
crawl_sub_pages: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange with updated only_main_content when checkbox is clicked', () => {
|
||||
const payload = createMockCrawlOptions({ only_main_content: false })
|
||||
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const checkboxes = getCheckboxes(container)
|
||||
fireEvent.click(checkboxes[1])
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
only_main_content: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange with updated limit when input changes', () => {
|
||||
const payload = createMockCrawlOptions({ limit: 10 })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const limitInput = screen.getByDisplayValue('10')
|
||||
fireEvent.change(limitInput, { target: { value: '50' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
limit: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange with updated max_depth when input changes', () => {
|
||||
const payload = createMockCrawlOptions({ max_depth: 2 })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const maxDepthInput = screen.getByDisplayValue('2')
|
||||
fireEvent.change(maxDepthInput, { target: { value: '10' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
max_depth: 10,
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange with updated excludes when input changes', () => {
|
||||
const payload = createMockCrawlOptions({ excludes: '' })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const excludesInput = screen.getByPlaceholderText('blog/*, /about/*')
|
||||
fireEvent.change(excludesInput, { target: { value: 'admin/*' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
excludes: 'admin/*',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onChange with updated includes when input changes', () => {
|
||||
const payload = createMockCrawlOptions({ includes: '' })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const includesInput = screen.getByPlaceholderText('articles/*')
|
||||
fireEvent.change(includesInput, { target: { value: 'public/*' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
...payload,
|
||||
includes: 'public/*',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Edge Cases Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty string values', () => {
|
||||
const payload = createMockCrawlOptions({
|
||||
limit: '',
|
||||
max_depth: '',
|
||||
excludes: '',
|
||||
includes: '',
|
||||
} as unknown as CrawlOptions)
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
// Component should render without crashing
|
||||
expect(screen.getByText(/limit/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zero values', () => {
|
||||
const payload = createMockCrawlOptions({
|
||||
limit: 0,
|
||||
max_depth: 0,
|
||||
})
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
// Zero values should be displayed
|
||||
const zeroInputs = screen.getAllByDisplayValue('0')
|
||||
expect(zeroInputs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should handle large numbers', () => {
|
||||
const payload = createMockCrawlOptions({
|
||||
limit: 9999,
|
||||
max_depth: 100,
|
||||
})
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByDisplayValue('9999')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('100')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special characters in text fields', () => {
|
||||
const payload = createMockCrawlOptions({
|
||||
excludes: 'path/*/file?query=1¶m=2',
|
||||
includes: 'docs/**/*.md',
|
||||
})
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByDisplayValue('path/*/file?query=1¶m=2')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('docs/**/*.md')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve other payload fields when updating one field', () => {
|
||||
const payload = createMockCrawlOptions({
|
||||
crawl_sub_pages: true,
|
||||
limit: 10,
|
||||
max_depth: 2,
|
||||
excludes: 'test/*',
|
||||
includes: 'docs/*',
|
||||
only_main_content: true,
|
||||
})
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const limitInput = screen.getByDisplayValue('10')
|
||||
fireEvent.change(limitInput, { target: { value: '20' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
crawl_sub_pages: true,
|
||||
limit: 20,
|
||||
max_depth: 2,
|
||||
excludes: 'test/*',
|
||||
includes: 'docs/*',
|
||||
only_main_content: true,
|
||||
use_sitemap: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// handleChange Callback Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('handleChange Callback', () => {
|
||||
it('should create a new callback for each key', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
// Change limit
|
||||
const limitInput = screen.getByDisplayValue('10')
|
||||
fireEvent.change(limitInput, { target: { value: '15' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ limit: 15 }),
|
||||
)
|
||||
|
||||
// Change max_depth
|
||||
const maxDepthInput = screen.getByDisplayValue('2')
|
||||
fireEvent.change(maxDepthInput, { target: { value: '5' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ max_depth: 5 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle multiple rapid changes', () => {
|
||||
const payload = createMockCrawlOptions({ limit: 10 })
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
const limitInput = screen.getByDisplayValue('10')
|
||||
fireEvent.change(limitInput, { target: { value: '11' } })
|
||||
fireEvent.change(limitInput, { target: { value: '12' } })
|
||||
fireEvent.change(limitInput, { target: { value: '13' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Memoization Tests
|
||||
// --------------------------------------------------------------------------
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized with React.memo', () => {
|
||||
const payload = createMockCrawlOptions()
|
||||
const { rerender } = render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
rerender(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByText(/limit/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should re-render when payload changes', () => {
|
||||
const payload1 = createMockCrawlOptions({ limit: 10 })
|
||||
const payload2 = createMockCrawlOptions({ limit: 20 })
|
||||
|
||||
const { rerender } = render(<Options payload={payload1} onChange={mockOnChange} />)
|
||||
expect(screen.getByDisplayValue('10')).toBeInTheDocument()
|
||||
|
||||
rerender(<Options payload={payload2} onChange={mockOnChange} />)
|
||||
expect(screen.getByDisplayValue('20')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -70,11 +70,6 @@ const createDefaultProps = (overrides: Partial<Parameters<typeof JinaReader>[0]>
|
||||
describe('JinaReader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -163,7 +158,7 @@ describe('JinaReader', () => {
|
||||
describe('Props', () => {
|
||||
it('should call onCrawlOptionsChange when options change', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
const user = userEvent.setup()
|
||||
const onCrawlOptionsChange = vi.fn()
|
||||
const props = createDefaultProps({ onCrawlOptionsChange })
|
||||
|
||||
@ -242,10 +237,9 @@ describe('JinaReader', () => {
|
||||
// Arrange
|
||||
const mockCreateTask = createJinaReaderTask as Mock
|
||||
let resolvePromise: () => void
|
||||
const taskPromise = new Promise((resolve) => {
|
||||
mockCreateTask.mockImplementation(() => new Promise((resolve) => {
|
||||
resolvePromise = () => resolve({ data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' } })
|
||||
})
|
||||
mockCreateTask.mockImplementation(() => taskPromise)
|
||||
}))
|
||||
|
||||
const props = createDefaultProps()
|
||||
|
||||
@ -263,11 +257,8 @@ describe('JinaReader', () => {
|
||||
expect(screen.getByText(/totalPageScraped/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Cleanup - resolve the promise and wait for component to finish
|
||||
// Cleanup - resolve the promise
|
||||
resolvePromise!()
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should transition to finished state after successful crawl', async () => {
|
||||
@ -403,11 +394,7 @@ describe('JinaReader', () => {
|
||||
it('should update controlFoldOptions when step changes', async () => {
|
||||
// Arrange
|
||||
const mockCreateTask = createJinaReaderTask as Mock
|
||||
let resolvePromise: () => void
|
||||
const taskPromise = new Promise((resolve) => {
|
||||
resolvePromise = () => resolve({ data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' } })
|
||||
})
|
||||
mockCreateTask.mockImplementation(() => taskPromise)
|
||||
mockCreateTask.mockImplementation(() => new Promise((_resolve) => { /* pending */ }))
|
||||
|
||||
const props = createDefaultProps()
|
||||
|
||||
@ -425,12 +412,6 @@ describe('JinaReader', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/totalPageScraped/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Cleanup - resolve the promise
|
||||
resolvePromise!()
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1092,13 +1073,9 @@ describe('JinaReader', () => {
|
||||
// Arrange
|
||||
const mockCreateTask = createJinaReaderTask as Mock
|
||||
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
|
||||
let resolveCheckStatus: () => void
|
||||
const checkStatusPromise = new Promise((resolve) => {
|
||||
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
|
||||
})
|
||||
|
||||
mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-current-job' })
|
||||
mockCheckStatus.mockImplementation(() => checkStatusPromise)
|
||||
mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ }))
|
||||
|
||||
const props = createDefaultProps({
|
||||
crawlOptions: createDefaultCrawlOptions({ limit: 10 }),
|
||||
@ -1114,25 +1091,15 @@ describe('JinaReader', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Cleanup - resolve the promise
|
||||
resolveCheckStatus!()
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show 0/0 progress when limit is zero string', async () => {
|
||||
// Arrange
|
||||
const mockCreateTask = createJinaReaderTask as Mock
|
||||
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
|
||||
let resolveCheckStatus: () => void
|
||||
const checkStatusPromise = new Promise((resolve) => {
|
||||
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
|
||||
})
|
||||
|
||||
mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-total-job' })
|
||||
mockCheckStatus.mockImplementation(() => checkStatusPromise)
|
||||
mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ }))
|
||||
|
||||
const props = createDefaultProps({
|
||||
crawlOptions: createDefaultCrawlOptions({ limit: '0' }),
|
||||
@ -1148,12 +1115,6 @@ describe('JinaReader', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/totalPageScraped.*0\/0/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Cleanup - resolve the promise
|
||||
resolveCheckStatus!()
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should complete successfully when result data is undefined', async () => {
|
||||
@ -1189,13 +1150,9 @@ describe('JinaReader', () => {
|
||||
// Arrange
|
||||
const mockCreateTask = createJinaReaderTask as Mock
|
||||
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
|
||||
let resolveCheckStatus: () => void
|
||||
const checkStatusPromise = new Promise((resolve) => {
|
||||
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
|
||||
})
|
||||
|
||||
mockCreateTask.mockResolvedValueOnce({ job_id: 'no-total-job' })
|
||||
mockCheckStatus.mockImplementation(() => checkStatusPromise)
|
||||
mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ }))
|
||||
|
||||
const props = createDefaultProps({
|
||||
crawlOptions: createDefaultCrawlOptions({ limit: 15 }),
|
||||
@ -1211,22 +1168,12 @@ describe('JinaReader', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/totalPageScraped.*0\/15/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Cleanup - resolve the promise
|
||||
resolveCheckStatus!()
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should fallback to limit when crawlResult has zero total', async () => {
|
||||
// Arrange
|
||||
const mockCreateTask = createJinaReaderTask as Mock
|
||||
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
|
||||
let resolveCheckStatus: () => void
|
||||
const checkStatusPromise = new Promise((resolve) => {
|
||||
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
|
||||
})
|
||||
|
||||
mockCreateTask.mockResolvedValueOnce({ job_id: 'both-zero-job' })
|
||||
mockCheckStatus
|
||||
@ -1236,7 +1183,7 @@ describe('JinaReader', () => {
|
||||
total: 0,
|
||||
data: [],
|
||||
})
|
||||
.mockImplementationOnce(() => checkStatusPromise)
|
||||
.mockImplementationOnce(() => new Promise(() => { /* never resolves */ }))
|
||||
|
||||
const props = createDefaultProps({
|
||||
crawlOptions: createDefaultCrawlOptions({ limit: 5 }),
|
||||
@ -1252,12 +1199,6 @@ describe('JinaReader', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/totalPageScraped/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Cleanup - resolve the promise
|
||||
resolveCheckStatus!()
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should construct result item from direct data response', async () => {
|
||||
@ -1496,13 +1437,9 @@ describe('JinaReader', () => {
|
||||
// Arrange
|
||||
const mockCreateTask = createJinaReaderTask as Mock
|
||||
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
|
||||
let resolveCheckStatus: () => void
|
||||
const checkStatusPromise = new Promise((resolve) => {
|
||||
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
|
||||
})
|
||||
|
||||
mockCreateTask.mockResolvedValueOnce({ job_id: 'progress-job' })
|
||||
mockCheckStatus.mockImplementation(() => checkStatusPromise)
|
||||
mockCheckStatus.mockImplementation(() => new Promise((_resolve) => { /* pending */ })) // Never resolves
|
||||
|
||||
const props = createDefaultProps({
|
||||
crawlOptions: createDefaultCrawlOptions({ limit: 10 }),
|
||||
@ -1518,12 +1455,6 @@ describe('JinaReader', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Cleanup - resolve the promise
|
||||
resolveCheckStatus!()
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should display time consumed after crawl completion', async () => {
|
||||
|
||||
@ -1,214 +0,0 @@
|
||||
import type { SortType } from '@/service/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import DocumentsHeader from './documents-header'
|
||||
|
||||
// Mock the context hooks
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
|
||||
}))
|
||||
|
||||
// Mock child components that require API calls
|
||||
vi.mock('@/app/components/datasets/common/document-status-with-action/auto-disabled-document', () => ({
|
||||
default: () => <div data-testid="auto-disabled-document">AutoDisabledDocument</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/common/document-status-with-action/index-failed', () => ({
|
||||
default: () => <div data-testid="index-failed">IndexFailed</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<div data-testid="metadata-drawer">
|
||||
<button onClick={onClose}>Close</button>
|
||||
MetadataDrawer
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('DocumentsHeader', () => {
|
||||
const defaultProps = {
|
||||
datasetId: 'dataset-123',
|
||||
dataSourceType: DataSourceType.FILE,
|
||||
embeddingAvailable: true,
|
||||
isFreePlan: false,
|
||||
statusFilterValue: 'all',
|
||||
sortValue: 'created_at' as SortType,
|
||||
inputValue: '',
|
||||
onStatusFilterChange: vi.fn(),
|
||||
onStatusFilterClear: vi.fn(),
|
||||
onSortChange: vi.fn(),
|
||||
onInputChange: vi.fn(),
|
||||
isShowEditMetadataModal: false,
|
||||
showEditMetadataModal: vi.fn(),
|
||||
hideEditMetadataModal: vi.fn(),
|
||||
datasetMetaData: [],
|
||||
builtInMetaData: [],
|
||||
builtInEnabled: true,
|
||||
onAddMetaData: vi.fn(),
|
||||
onRenameMetaData: vi.fn(),
|
||||
onDeleteMetaData: vi.fn(),
|
||||
onBuiltInEnabledChange: vi.fn(),
|
||||
onAddDocument: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<DocumentsHeader {...defaultProps} />)
|
||||
expect(screen.getByText(/list\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title', () => {
|
||||
render(<DocumentsHeader {...defaultProps} />)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(/list\.title/i)
|
||||
})
|
||||
|
||||
it('should render description text', () => {
|
||||
render(<DocumentsHeader {...defaultProps} />)
|
||||
expect(screen.getByText(/list\.desc/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render learn more link', () => {
|
||||
render(<DocumentsHeader {...defaultProps} />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link).toHaveTextContent(/list\.learnMore/i)
|
||||
expect(link).toHaveAttribute('href', expect.stringContaining('use-dify/knowledge'))
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('should render filter input', () => {
|
||||
render(<DocumentsHeader {...defaultProps} />)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AutoDisabledDocument', () => {
|
||||
it('should show AutoDisabledDocument when not free plan', () => {
|
||||
render(<DocumentsHeader {...defaultProps} isFreePlan={false} />)
|
||||
expect(screen.getByTestId('auto-disabled-document')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show AutoDisabledDocument when on free plan', () => {
|
||||
render(<DocumentsHeader {...defaultProps} isFreePlan={true} />)
|
||||
expect(screen.queryByTestId('auto-disabled-document')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('IndexFailed', () => {
|
||||
it('should always show IndexFailed component', () => {
|
||||
render(<DocumentsHeader {...defaultProps} />)
|
||||
expect(screen.getByTestId('index-failed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Embedding Availability', () => {
|
||||
it('should show metadata button when embedding is available', () => {
|
||||
render(<DocumentsHeader {...defaultProps} embeddingAvailable={true} />)
|
||||
expect(screen.getByText(/metadata\.metadata/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show add document button when embedding is available', () => {
|
||||
render(<DocumentsHeader {...defaultProps} embeddingAvailable={true} />)
|
||||
expect(screen.getByText(/list\.addFile/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show warning when embedding is not available', () => {
|
||||
render(<DocumentsHeader {...defaultProps} embeddingAvailable={false} />)
|
||||
expect(screen.queryByText(/metadata\.metadata/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/list\.addFile/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add Button Text', () => {
|
||||
it('should show "Add File" for FILE data source', () => {
|
||||
render(<DocumentsHeader {...defaultProps} dataSourceType={DataSourceType.FILE} />)
|
||||
expect(screen.getByText(/list\.addFile/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Add Pages" for NOTION data source', () => {
|
||||
render(<DocumentsHeader {...defaultProps} dataSourceType={DataSourceType.NOTION} />)
|
||||
expect(screen.getByText(/list\.addPages/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show "Add Url" for WEB data source', () => {
|
||||
render(<DocumentsHeader {...defaultProps} dataSourceType={DataSourceType.WEB} />)
|
||||
expect(screen.getByText(/list\.addUrl/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Metadata Modal', () => {
|
||||
it('should show metadata drawer when isShowEditMetadataModal is true', () => {
|
||||
render(<DocumentsHeader {...defaultProps} isShowEditMetadataModal={true} />)
|
||||
expect(screen.getByTestId('metadata-drawer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show metadata drawer when isShowEditMetadataModal is false', () => {
|
||||
render(<DocumentsHeader {...defaultProps} isShowEditMetadataModal={false} />)
|
||||
expect(screen.queryByTestId('metadata-drawer')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call showEditMetadataModal when metadata button is clicked', () => {
|
||||
const showEditMetadataModal = vi.fn()
|
||||
render(<DocumentsHeader {...defaultProps} showEditMetadataModal={showEditMetadataModal} />)
|
||||
|
||||
const metadataButton = screen.getByText(/metadata\.metadata/i)
|
||||
fireEvent.click(metadataButton)
|
||||
|
||||
expect(showEditMetadataModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onAddDocument when add button is clicked', () => {
|
||||
const onAddDocument = vi.fn()
|
||||
render(<DocumentsHeader {...defaultProps} onAddDocument={onAddDocument} />)
|
||||
|
||||
const addButton = screen.getByText(/list\.addFile/i)
|
||||
fireEvent.click(addButton)
|
||||
|
||||
expect(onAddDocument).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onInputChange when typing in search input', () => {
|
||||
const onInputChange = vi.fn()
|
||||
render(<DocumentsHeader {...defaultProps} onInputChange={onInputChange} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'search query' } })
|
||||
|
||||
expect(onInputChange).toHaveBeenCalledWith('search query')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined dataSourceType', () => {
|
||||
render(<DocumentsHeader {...defaultProps} dataSourceType={undefined} />)
|
||||
// Should default to "Add File" text
|
||||
expect(screen.getByText(/list\.addFile/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty metadata arrays', () => {
|
||||
render(
|
||||
<DocumentsHeader
|
||||
{...defaultProps}
|
||||
isShowEditMetadataModal={true}
|
||||
datasetMetaData={[]}
|
||||
builtInMetaData={[]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('metadata-drawer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with descending sort order', () => {
|
||||
render(<DocumentsHeader {...defaultProps} sortValue="-created_at" />)
|
||||
// Component should still render correctly
|
||||
expect(screen.getByText(/list\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,95 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import EmptyElement from './empty-element'
|
||||
|
||||
describe('EmptyElement', () => {
|
||||
const defaultProps = {
|
||||
canAdd: true,
|
||||
onClick: vi.fn(),
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<EmptyElement {...defaultProps} />)
|
||||
expect(screen.getByText(/list\.empty\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render title text', () => {
|
||||
render(<EmptyElement {...defaultProps} />)
|
||||
expect(screen.getByText(/list\.empty\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tip text for upload type', () => {
|
||||
render(<EmptyElement {...defaultProps} type="upload" />)
|
||||
expect(screen.getByText(/list\.empty\.upload\.tip/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tip text for sync type', () => {
|
||||
render(<EmptyElement {...defaultProps} type="sync" />)
|
||||
expect(screen.getByText(/list\.empty\.sync\.tip/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should use upload type by default', () => {
|
||||
render(<EmptyElement {...defaultProps} />)
|
||||
expect(screen.getByText(/list\.empty\.upload\.tip/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render FolderPlusIcon for upload type', () => {
|
||||
const { container } = render(<EmptyElement {...defaultProps} type="upload" />)
|
||||
// FolderPlusIcon has specific SVG attributes
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render NotionIcon for sync type', () => {
|
||||
const { container } = render(<EmptyElement {...defaultProps} type="sync" />)
|
||||
// NotionIcon has clipPath
|
||||
const clipPath = container.querySelector('clipPath')
|
||||
expect(clipPath).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add Button', () => {
|
||||
it('should show add button when canAdd is true and type is upload', () => {
|
||||
render(<EmptyElement {...defaultProps} canAdd={true} type="upload" />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
expect(screen.getByText(/list\.addFile/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show add button when canAdd is false', () => {
|
||||
render(<EmptyElement {...defaultProps} canAdd={false} type="upload" />)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show add button for sync type', () => {
|
||||
render(<EmptyElement {...defaultProps} canAdd={true} type="sync" />)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show add button for sync type even when canAdd is true', () => {
|
||||
render(<EmptyElement canAdd={true} onClick={vi.fn()} type="sync" />)
|
||||
expect(screen.queryByText(/list\.addFile/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when add button is clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<EmptyElement canAdd={true} onClick={handleClick} type="upload" />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle default canAdd value (true)', () => {
|
||||
render(<EmptyElement onClick={vi.fn()} canAdd={true} />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,81 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FolderPlusIcon, NotionIcon, ThreeDotsIcon } from './icons'
|
||||
|
||||
describe('Icons', () => {
|
||||
describe('FolderPlusIcon', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<FolderPlusIcon />)
|
||||
const svg = document.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct dimensions', () => {
|
||||
render(<FolderPlusIcon />)
|
||||
const svg = document.querySelector('svg')
|
||||
expect(svg).toHaveAttribute('width', '20')
|
||||
expect(svg).toHaveAttribute('height', '20')
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
render(<FolderPlusIcon className="custom-class" />)
|
||||
const svg = document.querySelector('svg')
|
||||
expect(svg).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should have empty className by default', () => {
|
||||
render(<FolderPlusIcon />)
|
||||
const svg = document.querySelector('svg')
|
||||
expect(svg).toHaveAttribute('class', '')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ThreeDotsIcon', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<ThreeDotsIcon />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct dimensions', () => {
|
||||
const { container } = render(<ThreeDotsIcon />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveAttribute('width', '16')
|
||||
expect(svg).toHaveAttribute('height', '16')
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<ThreeDotsIcon className="custom-class" />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('NotionIcon', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<NotionIcon />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct dimensions', () => {
|
||||
const { container } = render(<NotionIcon />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveAttribute('width', '20')
|
||||
expect(svg).toHaveAttribute('height', '20')
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<NotionIcon className="custom-class" />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should contain clipPath definition', () => {
|
||||
const { container } = render(<NotionIcon />)
|
||||
const clipPath = container.querySelector('clipPath')
|
||||
expect(clipPath).toBeInTheDocument()
|
||||
expect(clipPath).toHaveAttribute('id', 'clip0_2164_11263')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,381 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import Operations from './operations'
|
||||
|
||||
// Mock services
|
||||
vi.mock('@/service/knowledge/use-document', () => ({
|
||||
useDocumentArchive: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useDocumentUnArchive: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useDocumentEnable: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useDocumentDisable: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useDocumentDelete: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useDocumentDownload: () => ({ mutateAsync: vi.fn().mockResolvedValue({ url: 'https://example.com/download' }), isPending: false }),
|
||||
useSyncDocument: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useSyncWebsite: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useDocumentPause: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
useDocumentResume: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
||||
}))
|
||||
|
||||
// Mock utils
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock router
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Operations', () => {
|
||||
const defaultDetail = {
|
||||
name: 'Test Document',
|
||||
enabled: true,
|
||||
archived: false,
|
||||
id: 'doc-123',
|
||||
data_source_type: DataSourceType.FILE,
|
||||
doc_form: 'text',
|
||||
display_status: 'available',
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
embeddingAvailable: true,
|
||||
detail: defaultDetail,
|
||||
datasetId: 'dataset-456',
|
||||
onUpdate: vi.fn(),
|
||||
scene: 'list' as const,
|
||||
className: '',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
// Should render at least the container
|
||||
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render switch in list scene', () => {
|
||||
const { container } = render(<Operations {...defaultProps} scene="list" />)
|
||||
// Switch component should be rendered
|
||||
const switchEl = container.querySelector('[role="switch"]')
|
||||
expect(switchEl).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render settings button when embedding is available', () => {
|
||||
const { container } = render(<Operations {...defaultProps} />)
|
||||
// Settings button has RiEqualizer2Line icon inside
|
||||
const settingsButton = container.querySelector('button.mr-2.cursor-pointer')
|
||||
expect(settingsButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Switch Behavior', () => {
|
||||
it('should render enabled switch when document is enabled', () => {
|
||||
const { container } = render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, enabled: true, archived: false }}
|
||||
/>,
|
||||
)
|
||||
const switchEl = container.querySelector('[role="switch"]')
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('should render disabled switch when document is disabled', () => {
|
||||
const { container } = render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, enabled: false, archived: false }}
|
||||
/>,
|
||||
)
|
||||
const switchEl = container.querySelector('[role="switch"]')
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('should show tooltip and disable switch when document is archived', () => {
|
||||
const { container } = render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, archived: true }}
|
||||
/>,
|
||||
)
|
||||
const switchEl = container.querySelector('[role="switch"]')
|
||||
// Archived documents have visually disabled switch (CSS-based)
|
||||
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Embedding Not Available', () => {
|
||||
it('should show disabled switch when embedding not available in list scene', () => {
|
||||
const { container } = render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
embeddingAvailable={false}
|
||||
scene="list"
|
||||
/>,
|
||||
)
|
||||
const switchEl = container.querySelector('[role="switch"]')
|
||||
// Switch is visually disabled (CSS-based)
|
||||
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
|
||||
})
|
||||
|
||||
it('should not show settings or popover when embedding not available', () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
embeddingAvailable={false}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: /settings/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('More Actions Popover', () => {
|
||||
it('should show rename option for non-archived documents', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, archived: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click on the more actions button
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
expect(moreButton).toBeInTheDocument()
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.table\.rename/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show download option for FILE type documents', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, data_source_type: DataSourceType.FILE }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.download/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show sync option for notion documents', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.sync/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show sync option for web documents', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, data_source_type: DataSourceType.WEB }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.sync/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show archive option for non-archived documents', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, archived: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.archive/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show unarchive option for archived documents', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, archived: true }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.unarchive/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show delete option', async () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.delete/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show pause option when status is indexing', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, display_status: 'indexing', archived: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.pause/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show resume option when status is paused', async () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{ ...defaultDetail, display_status: 'paused', archived: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.action\.resume/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete Confirmation Modal', () => {
|
||||
it('should show delete confirmation modal when delete is clicked', async () => {
|
||||
render(<Operations {...defaultProps} />)
|
||||
|
||||
const moreButton = document.querySelector('[class*="commonIcon"]')
|
||||
if (moreButton)
|
||||
fireEvent.click(moreButton)
|
||||
|
||||
await waitFor(() => {
|
||||
const deleteButton = screen.getByText(/list\.action\.delete/i)
|
||||
fireEvent.click(deleteButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/list\.delete\.title/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/list\.delete\.content/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Scene Variations', () => {
|
||||
it('should render correctly in detail scene', () => {
|
||||
render(<Operations {...defaultProps} scene="detail" />)
|
||||
// Settings button should still be visible
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should apply different styles in detail scene', () => {
|
||||
const { container } = render(<Operations {...defaultProps} scene="detail" />)
|
||||
// The component should render without the list-specific styles
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined detail properties', () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
detail={{
|
||||
name: '',
|
||||
enabled: false,
|
||||
archived: false,
|
||||
id: '',
|
||||
data_source_type: '',
|
||||
doc_form: '',
|
||||
display_status: undefined,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
// Should not crash
|
||||
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should stop event propagation on click', () => {
|
||||
const parentHandler = vi.fn()
|
||||
render(
|
||||
<div onClick={parentHandler}>
|
||||
<Operations {...defaultProps} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const container = document.querySelector('.flex.items-center')
|
||||
if (container)
|
||||
fireEvent.click(container)
|
||||
|
||||
// Parent handler should not be called due to stopPropagation
|
||||
expect(parentHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle custom className', () => {
|
||||
render(<Operations {...defaultProps} className="custom-class" />)
|
||||
// Component should render with the custom class
|
||||
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selected IDs Handling', () => {
|
||||
it('should pass selectedIds to operations', () => {
|
||||
render(
|
||||
<Operations
|
||||
{...defaultProps}
|
||||
selectedIds={['doc-123', 'doc-456']}
|
||||
onSelectedIdChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// Component should render correctly with selectedIds
|
||||
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,183 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
// Import after mock
|
||||
import { renameDocumentName } from '@/service/datasets'
|
||||
|
||||
import RenameModal from './rename-modal'
|
||||
|
||||
// Mock the service
|
||||
vi.mock('@/service/datasets', () => ({
|
||||
renameDocumentName: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockRenameDocumentName = vi.mocked(renameDocumentName)
|
||||
|
||||
describe('RenameModal', () => {
|
||||
const defaultProps = {
|
||||
datasetId: 'dataset-123',
|
||||
documentId: 'doc-456',
|
||||
name: 'Original Document',
|
||||
onClose: vi.fn(),
|
||||
onSaved: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
expect(screen.getByText(/list\.table\.rename/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render modal title', () => {
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
expect(screen.getByText(/list\.table\.rename/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render name label', () => {
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
expect(screen.getByText(/list\.table\.name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render input with initial name', () => {
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('Original Document')
|
||||
})
|
||||
|
||||
it('should render cancel button', () => {
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render save button', () => {
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display the provided name in input', () => {
|
||||
render(<RenameModal {...defaultProps} name="Custom Name" />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('Custom Name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should update input value when typing', () => {
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'New Name' } })
|
||||
|
||||
expect(input).toHaveValue('New Name')
|
||||
})
|
||||
|
||||
it('should call onClose when cancel button is clicked', () => {
|
||||
const handleClose = vi.fn()
|
||||
render(<RenameModal {...defaultProps} onClose={handleClose} />)
|
||||
|
||||
const cancelButton = screen.getByText(/operation\.cancel/i)
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call renameDocumentName with correct params when save is clicked', async () => {
|
||||
mockRenameDocumentName.mockResolvedValueOnce({ result: 'success' })
|
||||
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'New Document Name' } })
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRenameDocumentName).toHaveBeenCalledWith({
|
||||
datasetId: 'dataset-123',
|
||||
documentId: 'doc-456',
|
||||
name: 'New Document Name',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onSaved and onClose on successful save', async () => {
|
||||
mockRenameDocumentName.mockResolvedValueOnce({ result: 'success' })
|
||||
const handleSaved = vi.fn()
|
||||
const handleClose = vi.fn()
|
||||
|
||||
render(<RenameModal {...defaultProps} onSaved={handleSaved} onClose={handleClose} />)
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleSaved).toHaveBeenCalledTimes(1)
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should show loading state while saving', async () => {
|
||||
// Create a promise that we can resolve manually
|
||||
let resolvePromise: (value: { result: 'success' | 'fail' }) => void
|
||||
const pendingPromise = new Promise<{ result: 'success' | 'fail' }>((resolve) => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
mockRenameDocumentName.mockReturnValueOnce(pendingPromise)
|
||||
|
||||
render(<RenameModal {...defaultProps} />)
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
// The button should be in loading state
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(btn => btn.textContent?.includes('operation.save'))
|
||||
expect(saveBtn).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Resolve the promise to clean up
|
||||
resolvePromise!({ result: 'success' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle API error gracefully', async () => {
|
||||
const error = new Error('API Error')
|
||||
mockRenameDocumentName.mockRejectedValueOnce(error)
|
||||
const handleSaved = vi.fn()
|
||||
const handleClose = vi.fn()
|
||||
|
||||
render(<RenameModal {...defaultProps} onSaved={handleSaved} onClose={handleClose} />)
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
// onSaved and onClose should not be called on error
|
||||
expect(handleSaved).not.toHaveBeenCalled()
|
||||
expect(handleClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty name', () => {
|
||||
render(<RenameModal {...defaultProps} name="" />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle name with special characters', () => {
|
||||
render(<RenameModal {...defaultProps} name="Document <with> 'special' chars" />)
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveValue('Document <with> \'special\' chars')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,279 +0,0 @@
|
||||
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import type { NotionPage } from '@/models/common'
|
||||
import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse, FileItem } from '@/models/datasets'
|
||||
import type { OnlineDriveFile } from '@/models/pipeline'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import { StepOnePreview, StepTwoPreview } from './preview-panel'
|
||||
|
||||
// Mock context hooks (底层依赖)
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: vi.fn((selector: (state: unknown) => unknown) => {
|
||||
const mockState = {
|
||||
dataset: {
|
||||
id: 'mock-dataset-id',
|
||||
doc_form: 'text_model',
|
||||
pipeline_id: 'mock-pipeline-id',
|
||||
},
|
||||
}
|
||||
return selector(mockState)
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock API hooks (底层依赖)
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFilePreview: vi.fn(() => ({
|
||||
data: { content: 'Mock file content for testing' },
|
||||
isFetching: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
usePreviewOnlineDocument: vi.fn(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ content: 'Mock document content' }),
|
||||
isPending: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock data source store
|
||||
vi.mock('../data-source/store', () => ({
|
||||
useDataSourceStore: vi.fn(() => ({
|
||||
getState: () => ({ currentCredentialId: 'mock-credential-id' }),
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('StepOnePreview', () => {
|
||||
const mockDatasource: Datasource = {
|
||||
nodeId: 'test-node-id',
|
||||
nodeData: { type: 'data-source' } as unknown as DataSourceNodeType,
|
||||
}
|
||||
|
||||
const mockLocalFile: CustomFile = {
|
||||
id: 'file-1',
|
||||
name: 'test-file.txt',
|
||||
type: 'text/plain',
|
||||
size: 1024,
|
||||
progress: 100,
|
||||
extension: 'txt',
|
||||
} as unknown as CustomFile
|
||||
|
||||
const mockWebsite: CrawlResultItem = {
|
||||
source_url: 'https://example.com',
|
||||
title: 'Example Site',
|
||||
markdown: 'Mock markdown content',
|
||||
} as CrawlResultItem
|
||||
|
||||
const defaultProps = {
|
||||
datasource: mockDatasource,
|
||||
currentLocalFile: undefined,
|
||||
currentDocument: undefined,
|
||||
currentWebsite: undefined,
|
||||
hidePreviewLocalFile: vi.fn(),
|
||||
hidePreviewOnlineDocument: vi.fn(),
|
||||
hideWebsitePreview: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<StepOnePreview {...defaultProps} />)
|
||||
expect(container.querySelector('.h-full')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render container with correct structure', () => {
|
||||
const { container } = render(<StepOnePreview {...defaultProps} />)
|
||||
expect(container.querySelector('.flex.h-full.flex-col')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Conditional Rendering - FilePreview', () => {
|
||||
it('should render FilePreview when currentLocalFile is provided', () => {
|
||||
render(<StepOnePreview {...defaultProps} currentLocalFile={mockLocalFile} />)
|
||||
// FilePreview renders a preview header with file name
|
||||
expect(screen.getByText(/test-file/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render FilePreview when currentLocalFile is undefined', () => {
|
||||
const { container } = render(<StepOnePreview {...defaultProps} currentLocalFile={undefined} />)
|
||||
// Container should still render but without file preview content
|
||||
expect(container.querySelector('.h-full')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Conditional Rendering - WebsitePreview', () => {
|
||||
it('should render WebsitePreview when currentWebsite is provided', () => {
|
||||
render(<StepOnePreview {...defaultProps} currentWebsite={mockWebsite} />)
|
||||
// WebsitePreview displays the website title and URL
|
||||
expect(screen.getByText('Example Site')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render WebsitePreview when currentWebsite is undefined', () => {
|
||||
const { container } = render(<StepOnePreview {...defaultProps} currentWebsite={undefined} />)
|
||||
expect(container.querySelector('.h-full')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call hideWebsitePreview when close button is clicked', () => {
|
||||
const hideWebsitePreview = vi.fn()
|
||||
render(
|
||||
<StepOnePreview
|
||||
{...defaultProps}
|
||||
currentWebsite={mockWebsite}
|
||||
hideWebsitePreview={hideWebsitePreview}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find and click the close button (RiCloseLine icon)
|
||||
const closeButton = screen.getByRole('button')
|
||||
closeButton.click()
|
||||
|
||||
expect(hideWebsitePreview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle website with long markdown content', () => {
|
||||
const longWebsite: CrawlResultItem = {
|
||||
...mockWebsite,
|
||||
markdown: 'A'.repeat(10000),
|
||||
}
|
||||
render(<StepOnePreview {...defaultProps} currentWebsite={longWebsite} />)
|
||||
expect(screen.getByText('Example Site')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('StepTwoPreview', () => {
|
||||
const mockFileList: FileItem[] = [
|
||||
{
|
||||
file: {
|
||||
id: 'file-1',
|
||||
name: 'file1.txt',
|
||||
extension: 'txt',
|
||||
size: 1024,
|
||||
} as CustomFile,
|
||||
progress: 100,
|
||||
},
|
||||
{
|
||||
file: {
|
||||
id: 'file-2',
|
||||
name: 'file2.txt',
|
||||
extension: 'txt',
|
||||
size: 2048,
|
||||
} as CustomFile,
|
||||
progress: 100,
|
||||
},
|
||||
] as FileItem[]
|
||||
|
||||
const mockOnlineDocuments: (NotionPage & { workspace_id: string })[] = [
|
||||
{
|
||||
page_id: 'page-1',
|
||||
page_name: 'Page 1',
|
||||
type: 'page',
|
||||
workspace_id: 'workspace-1',
|
||||
page_icon: null,
|
||||
is_bound: false,
|
||||
parent_id: '',
|
||||
},
|
||||
]
|
||||
|
||||
const mockWebsitePages: CrawlResultItem[] = [
|
||||
{ source_url: 'https://example.com', title: 'Example', markdown: 'Content' } as CrawlResultItem,
|
||||
]
|
||||
|
||||
const mockOnlineDriveFiles: OnlineDriveFile[] = [
|
||||
{ id: 'drive-1', name: 'drive-file.txt' } as OnlineDriveFile,
|
||||
]
|
||||
|
||||
const mockEstimateData: FileIndexingEstimateResponse = {
|
||||
tokens: 1000,
|
||||
total_price: 0.01,
|
||||
total_segments: 10,
|
||||
} as FileIndexingEstimateResponse
|
||||
|
||||
const defaultProps = {
|
||||
datasourceType: DatasourceType.localFile,
|
||||
localFileList: mockFileList,
|
||||
onlineDocuments: mockOnlineDocuments,
|
||||
websitePages: mockWebsitePages,
|
||||
selectedOnlineDriveFileList: mockOnlineDriveFiles,
|
||||
isIdle: true,
|
||||
isPendingPreview: false,
|
||||
estimateData: mockEstimateData,
|
||||
onPreview: vi.fn(),
|
||||
handlePreviewFileChange: vi.fn(),
|
||||
handlePreviewOnlineDocumentChange: vi.fn(),
|
||||
handlePreviewWebsitePageChange: vi.fn(),
|
||||
handlePreviewOnlineDriveFileChange: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<StepTwoPreview {...defaultProps} />)
|
||||
expect(container.querySelector('.h-full')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ChunkPreview component structure', () => {
|
||||
const { container } = render(<StepTwoPreview {...defaultProps} />)
|
||||
expect(container.querySelector('.flex.h-full.flex-col')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props Passing', () => {
|
||||
it('should render preview button when isIdle is true', () => {
|
||||
render(<StepTwoPreview {...defaultProps} isIdle={true} />)
|
||||
// ChunkPreview shows a preview button when idle
|
||||
const previewButton = screen.queryByRole('button')
|
||||
expect(previewButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onPreview when preview button is clicked', () => {
|
||||
const onPreview = vi.fn()
|
||||
render(<StepTwoPreview {...defaultProps} isIdle={true} onPreview={onPreview} />)
|
||||
|
||||
// Find and click the preview button
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const previewButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('preview'))
|
||||
if (previewButton) {
|
||||
previewButton.click()
|
||||
expect(onPreview).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty localFileList', () => {
|
||||
const { container } = render(<StepTwoPreview {...defaultProps} localFileList={[]} />)
|
||||
expect(container.querySelector('.h-full')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty onlineDocuments', () => {
|
||||
const { container } = render(<StepTwoPreview {...defaultProps} onlineDocuments={[]} />)
|
||||
expect(container.querySelector('.h-full')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty websitePages', () => {
|
||||
const { container } = render(<StepTwoPreview {...defaultProps} websitePages={[]} />)
|
||||
expect(container.querySelector('.h-full')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty onlineDriveFiles', () => {
|
||||
const { container } = render(<StepTwoPreview {...defaultProps} selectedOnlineDriveFileList={[]} />)
|
||||
expect(container.querySelector('.h-full')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined estimateData', () => {
|
||||
const { container } = render(<StepTwoPreview {...defaultProps} estimateData={undefined} />)
|
||||
expect(container.querySelector('.h-full')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,413 +0,0 @@
|
||||
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
import StepOneContent from './step-one-content'
|
||||
|
||||
// Mock context providers and hooks (底层依赖)
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: vi.fn(() => ({
|
||||
setShowPricingModal: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock billing components that have complex provider dependencies
|
||||
vi.mock('@/app/components/billing/vector-space-full', () => ({
|
||||
default: () => <div data-testid="vector-space-full">Vector Space Full</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/upgrade-btn', () => ({
|
||||
default: ({ onClick }: { onClick?: () => void }) => (
|
||||
<button data-testid="upgrade-btn" onClick={onClick}>Upgrade</button>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock data source store
|
||||
vi.mock('../data-source/store', () => ({
|
||||
useDataSourceStore: vi.fn(() => ({
|
||||
getState: () => ({
|
||||
localFileList: [],
|
||||
currentCredentialId: 'mock-credential-id',
|
||||
}),
|
||||
setState: vi.fn(),
|
||||
})),
|
||||
useDataSourceStoreWithSelector: vi.fn((selector: (state: unknown) => unknown) => {
|
||||
const mockState = {
|
||||
localFileList: [],
|
||||
onlineDocuments: [],
|
||||
websitePages: [],
|
||||
selectedOnlineDriveFileList: [],
|
||||
}
|
||||
return selector(mockState)
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock file upload config
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: vi.fn(() => ({
|
||||
data: {
|
||||
file_size_limit: 15 * 1024 * 1024,
|
||||
batch_count_limit: 20,
|
||||
document_file_extensions: ['.txt', '.md', '.pdf'],
|
||||
},
|
||||
isLoading: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock hooks used by data source options
|
||||
vi.mock('../hooks', () => ({
|
||||
useDatasourceOptions: vi.fn(() => [
|
||||
{ label: 'Local File', value: 'node-1', data: { type: 'data-source' } },
|
||||
]),
|
||||
}))
|
||||
|
||||
// Mock useDatasourceIcon hook to avoid complex data source list transformation
|
||||
vi.mock('../data-source-options/hooks', () => ({
|
||||
useDatasourceIcon: vi.fn(() => '/icons/local-file.svg'),
|
||||
}))
|
||||
|
||||
// Mock the entire local-file component since it has deep context dependencies
|
||||
vi.mock('../data-source/local-file', () => ({
|
||||
default: ({ allowedExtensions, supportBatchUpload }: {
|
||||
allowedExtensions: string[]
|
||||
supportBatchUpload: boolean
|
||||
}) => (
|
||||
<div data-testid="local-file">
|
||||
<div>Drag and drop file here</div>
|
||||
<span data-testid="allowed-extensions">{allowedExtensions.join(',')}</span>
|
||||
<span data-testid="support-batch-upload">{String(supportBatchUpload)}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock online documents since it has complex OAuth/API dependencies
|
||||
vi.mock('../data-source/online-documents', () => ({
|
||||
default: ({ nodeId, onCredentialChange }: {
|
||||
nodeId: string
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
}) => (
|
||||
<div data-testid="online-documents">
|
||||
<span data-testid="online-doc-node-id">{nodeId}</span>
|
||||
<button data-testid="credential-change-btn" onClick={() => onCredentialChange('new-credential')}>
|
||||
Change Credential
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock website crawl
|
||||
vi.mock('../data-source/website-crawl', () => ({
|
||||
default: ({ nodeId, onCredentialChange }: {
|
||||
nodeId: string
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
}) => (
|
||||
<div data-testid="website-crawl">
|
||||
<span data-testid="website-crawl-node-id">{nodeId}</span>
|
||||
<button data-testid="website-credential-btn" onClick={() => onCredentialChange('website-credential')}>
|
||||
Change Website Credential
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock online drive
|
||||
vi.mock('../data-source/online-drive', () => ({
|
||||
default: ({ nodeId, onCredentialChange }: {
|
||||
nodeId: string
|
||||
onCredentialChange: (credentialId: string) => void
|
||||
}) => (
|
||||
<div data-testid="online-drive">
|
||||
<span data-testid="online-drive-node-id">{nodeId}</span>
|
||||
<button data-testid="drive-credential-btn" onClick={() => onCredentialChange('drive-credential')}>
|
||||
Change Drive Credential
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock locale context
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: vi.fn(() => 'en'),
|
||||
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
|
||||
}))
|
||||
|
||||
// Mock theme hook
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: vi.fn(() => 'light'),
|
||||
}))
|
||||
|
||||
// Mock upload service
|
||||
vi.mock('@/service/base', () => ({
|
||||
upload: vi.fn().mockResolvedValue({ id: 'uploaded-file-id' }),
|
||||
}))
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ datasetId: 'mock-dataset-id' }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/datasets/mock-dataset-id',
|
||||
}))
|
||||
|
||||
// Mock pipeline service hooks
|
||||
vi.mock('@/service/use-pipeline', () => ({
|
||||
useNotionWorkspaces: vi.fn(() => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
})),
|
||||
useNotionPages: vi.fn(() => ({
|
||||
data: { pages: [] },
|
||||
isLoading: false,
|
||||
})),
|
||||
useDataSourceList: vi.fn(() => ({
|
||||
data: [
|
||||
{
|
||||
type: 'local_file',
|
||||
declaration: {
|
||||
identity: {
|
||||
name: 'Local File',
|
||||
icon: '/icons/local-file.svg',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
isSuccess: true,
|
||||
isLoading: false,
|
||||
})),
|
||||
useCrawlResult: vi.fn(() => ({
|
||||
data: { data: [] },
|
||||
isLoading: false,
|
||||
})),
|
||||
useSupportedOauth: vi.fn(() => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
})),
|
||||
useOnlineDriveCredentialList: vi.fn(() => ({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
})),
|
||||
useOnlineDriveFileList: vi.fn(() => ({
|
||||
data: { data: [] },
|
||||
isLoading: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('StepOneContent', () => {
|
||||
const mockDatasource: Datasource = {
|
||||
nodeId: 'test-node-id',
|
||||
nodeData: {
|
||||
type: 'data-source',
|
||||
fileExtensions: ['txt', 'pdf'],
|
||||
title: 'Test Data Source',
|
||||
desc: 'Test description',
|
||||
} as unknown as DataSourceNodeType,
|
||||
}
|
||||
|
||||
const mockPipelineNodes: Node<DataSourceNodeType>[] = [
|
||||
{
|
||||
id: 'node-1',
|
||||
data: {
|
||||
type: 'data-source',
|
||||
title: 'Node 1',
|
||||
desc: 'Description 1',
|
||||
} as unknown as DataSourceNodeType,
|
||||
} as Node<DataSourceNodeType>,
|
||||
{
|
||||
id: 'node-2',
|
||||
data: {
|
||||
type: 'data-source',
|
||||
title: 'Node 2',
|
||||
desc: 'Description 2',
|
||||
} as unknown as DataSourceNodeType,
|
||||
} as Node<DataSourceNodeType>,
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
datasource: mockDatasource,
|
||||
datasourceType: DatasourceType.localFile,
|
||||
pipelineNodes: mockPipelineNodes,
|
||||
supportBatchUpload: true,
|
||||
localFileListLength: 0,
|
||||
isShowVectorSpaceFull: false,
|
||||
showSelect: false,
|
||||
totalOptions: 10,
|
||||
selectedOptions: 5,
|
||||
tip: 'Test tip',
|
||||
nextBtnDisabled: false,
|
||||
onSelectDataSource: vi.fn(),
|
||||
onCredentialChange: vi.fn(),
|
||||
onSelectAll: vi.fn(),
|
||||
onNextStep: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<StepOneContent {...defaultProps} />)
|
||||
expect(container.querySelector('.flex.flex-col')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render DataSourceOptions component', () => {
|
||||
render(<StepOneContent {...defaultProps} />)
|
||||
// DataSourceOptions renders option cards
|
||||
expect(screen.getByText('Local File')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Actions component with next button', () => {
|
||||
render(<StepOneContent {...defaultProps} />)
|
||||
// Actions component renders a next step button (uses i18n key)
|
||||
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
|
||||
expect(nextButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Conditional Rendering - DatasourceType', () => {
|
||||
it('should render LocalFile component when datasourceType is localFile', () => {
|
||||
render(<StepOneContent {...defaultProps} datasourceType={DatasourceType.localFile} />)
|
||||
expect(screen.getByTestId('local-file')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render OnlineDocuments component when datasourceType is onlineDocument', () => {
|
||||
render(<StepOneContent {...defaultProps} datasourceType={DatasourceType.onlineDocument} />)
|
||||
expect(screen.getByTestId('online-documents')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render WebsiteCrawl component when datasourceType is websiteCrawl', () => {
|
||||
render(<StepOneContent {...defaultProps} datasourceType={DatasourceType.websiteCrawl} />)
|
||||
expect(screen.getByTestId('website-crawl')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render OnlineDrive component when datasourceType is onlineDrive', () => {
|
||||
render(<StepOneContent {...defaultProps} datasourceType={DatasourceType.onlineDrive} />)
|
||||
expect(screen.getByTestId('online-drive')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render data source component when datasourceType is undefined', () => {
|
||||
const { container } = render(<StepOneContent {...defaultProps} datasourceType={undefined} />)
|
||||
expect(container.querySelector('.flex.flex-col')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('local-file')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Conditional Rendering - VectorSpaceFull', () => {
|
||||
it('should render VectorSpaceFull when isShowVectorSpaceFull is true', () => {
|
||||
render(<StepOneContent {...defaultProps} isShowVectorSpaceFull={true} />)
|
||||
expect(screen.getByTestId('vector-space-full')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render VectorSpaceFull when isShowVectorSpaceFull is false', () => {
|
||||
render(<StepOneContent {...defaultProps} isShowVectorSpaceFull={false} />)
|
||||
expect(screen.queryByTestId('vector-space-full')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Conditional Rendering - UpgradeCard', () => {
|
||||
it('should render UpgradeCard when batch upload not supported and has local files', () => {
|
||||
render(
|
||||
<StepOneContent
|
||||
{...defaultProps}
|
||||
supportBatchUpload={false}
|
||||
datasourceType={DatasourceType.localFile}
|
||||
localFileListLength={3}
|
||||
/>,
|
||||
)
|
||||
// UpgradeCard contains an upgrade button
|
||||
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render UpgradeCard when batch upload is supported', () => {
|
||||
render(
|
||||
<StepOneContent
|
||||
{...defaultProps}
|
||||
supportBatchUpload={true}
|
||||
datasourceType={DatasourceType.localFile}
|
||||
localFileListLength={3}
|
||||
/>,
|
||||
)
|
||||
// The upgrade card should not be present
|
||||
const upgradeCard = screen.queryByText(/upload multiple files/i)
|
||||
expect(upgradeCard).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render UpgradeCard when datasourceType is not localFile', () => {
|
||||
render(
|
||||
<StepOneContent
|
||||
{...defaultProps}
|
||||
supportBatchUpload={false}
|
||||
datasourceType={undefined}
|
||||
localFileListLength={3}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId('upgrade-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render UpgradeCard when localFileListLength is 0', () => {
|
||||
render(
|
||||
<StepOneContent
|
||||
{...defaultProps}
|
||||
supportBatchUpload={false}
|
||||
datasourceType={DatasourceType.localFile}
|
||||
localFileListLength={0}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId('upgrade-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onNextStep when next button is clicked', () => {
|
||||
const onNextStep = vi.fn()
|
||||
render(<StepOneContent {...defaultProps} onNextStep={onNextStep} />)
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
|
||||
nextButton.click()
|
||||
|
||||
expect(onNextStep).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable next button when nextBtnDisabled is true', () => {
|
||||
render(<StepOneContent {...defaultProps} nextBtnDisabled={true} />)
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
|
||||
expect(nextButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined datasource when datasourceType is undefined', () => {
|
||||
const { container } = render(
|
||||
<StepOneContent {...defaultProps} datasource={undefined} datasourceType={undefined} />,
|
||||
)
|
||||
expect(container.querySelector('.flex.flex-col')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty pipelineNodes array', () => {
|
||||
render(<StepOneContent {...defaultProps} pipelineNodes={[]} />)
|
||||
// Should still render but DataSourceOptions may show no options
|
||||
const { container } = render(<StepOneContent {...defaultProps} pipelineNodes={[]} />)
|
||||
expect(container.querySelector('.flex.flex-col')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined totalOptions', () => {
|
||||
render(<StepOneContent {...defaultProps} totalOptions={undefined} />)
|
||||
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
|
||||
expect(nextButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined selectedOptions', () => {
|
||||
render(<StepOneContent {...defaultProps} selectedOptions={undefined} />)
|
||||
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
|
||||
expect(nextButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty tip', () => {
|
||||
render(<StepOneContent {...defaultProps} tip="" />)
|
||||
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
|
||||
expect(nextButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,97 +0,0 @@
|
||||
import type { InitialDocumentDetail } from '@/models/pipeline'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import StepThreeContent from './step-three-content'
|
||||
|
||||
// Mock context hooks used by Processing component
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
useDatasetDetailContextWithSelector: vi.fn((selector: (state: unknown) => unknown) => {
|
||||
const mockState = {
|
||||
dataset: {
|
||||
id: 'mock-dataset-id',
|
||||
indexing_technique: 'high_quality',
|
||||
retrieval_model_dict: {
|
||||
search_method: 'semantic_search',
|
||||
},
|
||||
},
|
||||
}
|
||||
return selector(mockState)
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
|
||||
}))
|
||||
|
||||
// Mock EmbeddingProcess component as it has complex dependencies
|
||||
vi.mock('../processing/embedding-process', () => ({
|
||||
default: ({ datasetId, batchId, documents }: {
|
||||
datasetId: string
|
||||
batchId: string
|
||||
documents: InitialDocumentDetail[]
|
||||
}) => (
|
||||
<div data-testid="embedding-process">
|
||||
<span data-testid="dataset-id">{datasetId}</span>
|
||||
<span data-testid="batch-id">{batchId}</span>
|
||||
<span data-testid="documents-count">{documents.length}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('StepThreeContent', () => {
|
||||
const mockDocuments: InitialDocumentDetail[] = [
|
||||
{ id: 'doc1', name: 'Document 1' } as InitialDocumentDetail,
|
||||
{ id: 'doc2', name: 'Document 2' } as InitialDocumentDetail,
|
||||
]
|
||||
|
||||
const defaultProps = {
|
||||
batchId: 'test-batch-id',
|
||||
documents: mockDocuments,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<StepThreeContent {...defaultProps} />)
|
||||
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Processing component', () => {
|
||||
render(<StepThreeContent {...defaultProps} />)
|
||||
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass batchId to Processing component', () => {
|
||||
render(<StepThreeContent {...defaultProps} />)
|
||||
expect(screen.getByTestId('batch-id')).toHaveTextContent('test-batch-id')
|
||||
})
|
||||
|
||||
it('should pass documents to Processing component', () => {
|
||||
render(<StepThreeContent {...defaultProps} />)
|
||||
expect(screen.getByTestId('documents-count')).toHaveTextContent('2')
|
||||
})
|
||||
|
||||
it('should handle empty documents array', () => {
|
||||
render(<StepThreeContent batchId="test-batch-id" documents={[]} />)
|
||||
expect(screen.getByTestId('documents-count')).toHaveTextContent('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with different batchId', () => {
|
||||
render(<StepThreeContent batchId="another-batch-id" documents={mockDocuments} />)
|
||||
expect(screen.getByTestId('batch-id')).toHaveTextContent('another-batch-id')
|
||||
})
|
||||
|
||||
it('should render with single document', () => {
|
||||
const singleDocument = [mockDocuments[0]]
|
||||
render(<StepThreeContent batchId="test-batch-id" documents={singleDocument} />)
|
||||
expect(screen.getByTestId('documents-count')).toHaveTextContent('1')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,136 +0,0 @@
|
||||
import type { RefObject } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import StepTwoContent from './step-two-content'
|
||||
|
||||
// Mock ProcessDocuments component as it has complex hook dependencies
|
||||
vi.mock('../process-documents', () => ({
|
||||
default: vi.fn().mockImplementation(({
|
||||
dataSourceNodeId,
|
||||
isRunning,
|
||||
onProcess,
|
||||
onPreview,
|
||||
onSubmit,
|
||||
onBack,
|
||||
}: {
|
||||
dataSourceNodeId: string
|
||||
isRunning: boolean
|
||||
onProcess: () => void
|
||||
onPreview: () => void
|
||||
onSubmit: (data: Record<string, unknown>) => void
|
||||
onBack: () => void
|
||||
}) => (
|
||||
<div data-testid="process-documents">
|
||||
<span data-testid="data-source-node-id">{dataSourceNodeId}</span>
|
||||
<span data-testid="is-running">{String(isRunning)}</span>
|
||||
<button data-testid="process-btn" onClick={onProcess}>Process</button>
|
||||
<button data-testid="preview-btn" onClick={onPreview}>Preview</button>
|
||||
<button data-testid="submit-btn" onClick={() => onSubmit({ key: 'value' })}>Submit</button>
|
||||
<button data-testid="back-btn" onClick={onBack}>Back</button>
|
||||
</div>
|
||||
)),
|
||||
}))
|
||||
|
||||
describe('StepTwoContent', () => {
|
||||
const mockFormRef: RefObject<{ submit: () => void } | null> = { current: null }
|
||||
|
||||
const defaultProps = {
|
||||
formRef: mockFormRef,
|
||||
dataSourceNodeId: 'test-node-id',
|
||||
isRunning: false,
|
||||
onProcess: vi.fn(),
|
||||
onPreview: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
onBack: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<StepTwoContent {...defaultProps} />)
|
||||
expect(screen.getByTestId('process-documents')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ProcessDocuments component', () => {
|
||||
render(<StepTwoContent {...defaultProps} />)
|
||||
expect(screen.getByTestId('process-documents')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should pass dataSourceNodeId to ProcessDocuments', () => {
|
||||
render(<StepTwoContent {...defaultProps} />)
|
||||
expect(screen.getByTestId('data-source-node-id')).toHaveTextContent('test-node-id')
|
||||
})
|
||||
|
||||
it('should pass isRunning false to ProcessDocuments', () => {
|
||||
render(<StepTwoContent {...defaultProps} isRunning={false} />)
|
||||
expect(screen.getByTestId('is-running')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should pass isRunning true to ProcessDocuments', () => {
|
||||
render(<StepTwoContent {...defaultProps} isRunning={true} />)
|
||||
expect(screen.getByTestId('is-running')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should pass different dataSourceNodeId', () => {
|
||||
render(<StepTwoContent {...defaultProps} dataSourceNodeId="different-node-id" />)
|
||||
expect(screen.getByTestId('data-source-node-id')).toHaveTextContent('different-node-id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onProcess when process button is clicked', () => {
|
||||
const onProcess = vi.fn()
|
||||
render(<StepTwoContent {...defaultProps} onProcess={onProcess} />)
|
||||
|
||||
screen.getByTestId('process-btn').click()
|
||||
|
||||
expect(onProcess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onPreview when preview button is clicked', () => {
|
||||
const onPreview = vi.fn()
|
||||
render(<StepTwoContent {...defaultProps} onPreview={onPreview} />)
|
||||
|
||||
screen.getByTestId('preview-btn').click()
|
||||
|
||||
expect(onPreview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onSubmit when submit button is clicked', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<StepTwoContent {...defaultProps} onSubmit={onSubmit} />)
|
||||
|
||||
screen.getByTestId('submit-btn').click()
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1)
|
||||
expect(onSubmit).toHaveBeenCalledWith({ key: 'value' })
|
||||
})
|
||||
|
||||
it('should call onBack when back button is clicked', () => {
|
||||
const onBack = vi.fn()
|
||||
render(<StepTwoContent {...defaultProps} onBack={onBack} />)
|
||||
|
||||
screen.getByTestId('back-btn').click()
|
||||
|
||||
expect(onBack).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty dataSourceNodeId', () => {
|
||||
render(<StepTwoContent {...defaultProps} dataSourceNodeId="" />)
|
||||
expect(screen.getByTestId('data-source-node-id')).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should handle null formRef', () => {
|
||||
const nullRef = { current: null }
|
||||
render(<StepTwoContent {...defaultProps} formRef={nullRef} />)
|
||||
expect(screen.getByTestId('process-documents')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,243 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
|
||||
import CSVDownload from './csv-downloader'
|
||||
|
||||
// Mock useLocale
|
||||
let mockLocale = LanguagesSupported[0] // en-US
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => mockLocale,
|
||||
}))
|
||||
|
||||
// Mock react-papaparse
|
||||
const MockCSVDownloader = ({ children, data, filename, type }: { children: ReactNode, data: unknown, filename: string, type: string }) => (
|
||||
<div
|
||||
data-testid="csv-downloader-link"
|
||||
data-filename={filename}
|
||||
data-type={type}
|
||||
data-data={JSON.stringify(data)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
vi.mock('react-papaparse', () => ({
|
||||
useCSVDownloader: () => ({
|
||||
CSVDownloader: MockCSVDownloader,
|
||||
Type: { Link: 'link' },
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('CSVDownloader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLocale = LanguagesSupported[0] // Reset to English
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<CSVDownload docForm={ChunkingMode.text} />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render structure title', () => {
|
||||
// Arrange & Act
|
||||
render(<CSVDownload docForm={ChunkingMode.text} />)
|
||||
|
||||
// Assert - i18n key format
|
||||
expect(screen.getByText(/csvStructureTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render download template link', () => {
|
||||
// Arrange & Act
|
||||
render(<CSVDownload docForm={ChunkingMode.text} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('csv-downloader-link')).toBeInTheDocument()
|
||||
expect(screen.getByText(/list\.batchModal\.template/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Table structure for QA mode
|
||||
describe('QA Mode Table', () => {
|
||||
it('should render QA table with question and answer columns when docForm is qa', () => {
|
||||
// Arrange & Act
|
||||
render(<CSVDownload docForm={ChunkingMode.qa} />)
|
||||
|
||||
// Assert - Check for question/answer headers
|
||||
const questionHeaders = screen.getAllByText(/list\.batchModal\.question/i)
|
||||
const answerHeaders = screen.getAllByText(/list\.batchModal\.answer/i)
|
||||
|
||||
expect(questionHeaders.length).toBeGreaterThan(0)
|
||||
expect(answerHeaders.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render two data rows for QA mode', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<CSVDownload docForm={ChunkingMode.qa} />)
|
||||
|
||||
// Assert
|
||||
const tbody = container.querySelector('tbody')
|
||||
expect(tbody).toBeInTheDocument()
|
||||
const rows = tbody?.querySelectorAll('tr')
|
||||
expect(rows?.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// Table structure for Text mode
|
||||
describe('Text Mode Table', () => {
|
||||
it('should render text table with content column when docForm is text', () => {
|
||||
// Arrange & Act
|
||||
render(<CSVDownload docForm={ChunkingMode.text} />)
|
||||
|
||||
// Assert - Check for content header
|
||||
expect(screen.getByText(/list\.batchModal\.contentTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render question/answer columns in text mode', () => {
|
||||
// Arrange & Act
|
||||
render(<CSVDownload docForm={ChunkingMode.text} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText(/list\.batchModal\.question/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/list\.batchModal\.answer/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render two data rows for text mode', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<CSVDownload docForm={ChunkingMode.text} />)
|
||||
|
||||
// Assert
|
||||
const tbody = container.querySelector('tbody')
|
||||
expect(tbody).toBeInTheDocument()
|
||||
const rows = tbody?.querySelectorAll('tr')
|
||||
expect(rows?.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// CSV Template Data
|
||||
describe('CSV Template Data', () => {
|
||||
it('should provide English QA template when locale is English and docForm is qa', () => {
|
||||
// Arrange
|
||||
mockLocale = LanguagesSupported[0] // en-US
|
||||
|
||||
// Act
|
||||
render(<CSVDownload docForm={ChunkingMode.qa} />)
|
||||
|
||||
// Assert
|
||||
const link = screen.getByTestId('csv-downloader-link')
|
||||
const data = JSON.parse(link.getAttribute('data-data') || '[]')
|
||||
expect(data).toEqual([
|
||||
['question', 'answer'],
|
||||
['question1', 'answer1'],
|
||||
['question2', 'answer2'],
|
||||
])
|
||||
})
|
||||
|
||||
it('should provide English text template when locale is English and docForm is text', () => {
|
||||
// Arrange
|
||||
mockLocale = LanguagesSupported[0] // en-US
|
||||
|
||||
// Act
|
||||
render(<CSVDownload docForm={ChunkingMode.text} />)
|
||||
|
||||
// Assert
|
||||
const link = screen.getByTestId('csv-downloader-link')
|
||||
const data = JSON.parse(link.getAttribute('data-data') || '[]')
|
||||
expect(data).toEqual([
|
||||
['segment content'],
|
||||
['content1'],
|
||||
['content2'],
|
||||
])
|
||||
})
|
||||
|
||||
it('should provide Chinese QA template when locale is Chinese and docForm is qa', () => {
|
||||
// Arrange
|
||||
mockLocale = LanguagesSupported[1] // zh-Hans
|
||||
|
||||
// Act
|
||||
render(<CSVDownload docForm={ChunkingMode.qa} />)
|
||||
|
||||
// Assert
|
||||
const link = screen.getByTestId('csv-downloader-link')
|
||||
const data = JSON.parse(link.getAttribute('data-data') || '[]')
|
||||
expect(data).toEqual([
|
||||
['问题', '答案'],
|
||||
['问题 1', '答案 1'],
|
||||
['问题 2', '答案 2'],
|
||||
])
|
||||
})
|
||||
|
||||
it('should provide Chinese text template when locale is Chinese and docForm is text', () => {
|
||||
// Arrange
|
||||
mockLocale = LanguagesSupported[1] // zh-Hans
|
||||
|
||||
// Act
|
||||
render(<CSVDownload docForm={ChunkingMode.text} />)
|
||||
|
||||
// Assert
|
||||
const link = screen.getByTestId('csv-downloader-link')
|
||||
const data = JSON.parse(link.getAttribute('data-data') || '[]')
|
||||
expect(data).toEqual([
|
||||
['分段内容'],
|
||||
['内容 1'],
|
||||
['内容 2'],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// CSVDownloader props
|
||||
describe('CSVDownloader Props', () => {
|
||||
it('should set filename to template', () => {
|
||||
// Arrange & Act
|
||||
render(<CSVDownload docForm={ChunkingMode.text} />)
|
||||
|
||||
// Assert
|
||||
const link = screen.getByTestId('csv-downloader-link')
|
||||
expect(link.getAttribute('data-filename')).toBe('template')
|
||||
})
|
||||
|
||||
it('should set type to Link', () => {
|
||||
// Arrange & Act
|
||||
render(<CSVDownload docForm={ChunkingMode.text} />)
|
||||
|
||||
// Assert
|
||||
const link = screen.getByTestId('csv-downloader-link')
|
||||
expect(link.getAttribute('data-type')).toBe('link')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should maintain structure when rerendered with different docForm', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<CSVDownload docForm={ChunkingMode.text} />)
|
||||
|
||||
// Act
|
||||
rerender(<CSVDownload docForm={ChunkingMode.qa} />)
|
||||
|
||||
// Assert - should now show QA table
|
||||
expect(screen.getAllByText(/list\.batchModal\.question/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render correctly for non-English locales', () => {
|
||||
// Arrange
|
||||
mockLocale = LanguagesSupported[1] // zh-Hans
|
||||
|
||||
// Act
|
||||
render(<CSVDownload docForm={ChunkingMode.qa} />)
|
||||
|
||||
// Assert - Check that Chinese template is used
|
||||
const link = screen.getByTestId('csv-downloader-link')
|
||||
const data = JSON.parse(link.getAttribute('data-data') || '[]')
|
||||
expect(data[0]).toEqual(['问题', '答案'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,485 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { CustomFile, FileItem } from '@/models/datasets'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Theme } from '@/types/app'
|
||||
|
||||
import CSVUploader from './csv-uploader'
|
||||
|
||||
// Mock upload service
|
||||
const mockUpload = vi.fn()
|
||||
vi.mock('@/service/base', () => ({
|
||||
upload: (...args: unknown[]) => mockUpload(...args),
|
||||
}))
|
||||
|
||||
// Mock useFileUploadConfig
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: () => ({
|
||||
data: { file_size_limit: 15 },
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useTheme
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: Theme.light }),
|
||||
}))
|
||||
|
||||
// Mock ToastContext
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
ToastContext: {
|
||||
Provider: ({ children }: { children: ReactNode }) => children,
|
||||
Consumer: ({ children }: { children: (ctx: { notify: typeof mockNotify }) => ReactNode }) => children({ notify: mockNotify }),
|
||||
},
|
||||
}))
|
||||
|
||||
// Create a mock ToastContext for useContext
|
||||
vi.mock('use-context-selector', async (importOriginal) => {
|
||||
const actual = await importOriginal() as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
useContext: () => ({ notify: mockNotify }),
|
||||
}
|
||||
})
|
||||
|
||||
describe('CSVUploader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
file: undefined as FileItem | undefined,
|
||||
updateFile: vi.fn(),
|
||||
}
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<CSVUploader {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render upload area when no file is present', () => {
|
||||
// Arrange & Act
|
||||
render(<CSVUploader {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/list\.batchModal\.csvUploadTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/list\.batchModal\.browse/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render hidden file input', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<CSVUploader {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
const fileInput = container.querySelector('input[type="file"]')
|
||||
expect(fileInput).toBeInTheDocument()
|
||||
expect(fileInput).toHaveStyle({ display: 'none' })
|
||||
})
|
||||
|
||||
it('should accept only CSV files', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<CSVUploader {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
const fileInput = container.querySelector('input[type="file"]')
|
||||
expect(fileInput).toHaveAttribute('accept', '.csv')
|
||||
})
|
||||
})
|
||||
|
||||
// File display tests
|
||||
describe('File Display', () => {
|
||||
it('should display file info when file is present', () => {
|
||||
// Arrange
|
||||
const mockFile: FileItem = {
|
||||
fileID: 'file-1',
|
||||
file: new File(['content'], 'test-file.csv', { type: 'text/csv' }) as CustomFile,
|
||||
progress: 100,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<CSVUploader {...defaultProps} file={mockFile} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('test-file')).toBeInTheDocument()
|
||||
expect(screen.getByText('.csv')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show upload area when file is present', () => {
|
||||
// Arrange
|
||||
const mockFile: FileItem = {
|
||||
fileID: 'file-1',
|
||||
file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
|
||||
progress: 100,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<CSVUploader {...defaultProps} file={mockFile} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText(/list\.batchModal\.csvUploadTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show change button when file is present', () => {
|
||||
// Arrange
|
||||
const mockFile: FileItem = {
|
||||
fileID: 'file-1',
|
||||
file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
|
||||
progress: 100,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<CSVUploader {...defaultProps} file={mockFile} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/stepOne\.uploader\.change/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should trigger file input click when browse is clicked', () => {
|
||||
// Arrange
|
||||
const { container } = render(<CSVUploader {...defaultProps} />)
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const clickSpy = vi.spyOn(fileInput, 'click')
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText(/list\.batchModal\.browse/i))
|
||||
|
||||
// Assert
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call updateFile when file is selected', async () => {
|
||||
// Arrange
|
||||
const mockUpdateFile = vi.fn()
|
||||
mockUpload.mockResolvedValueOnce({ id: 'uploaded-id' })
|
||||
|
||||
const { container } = render(
|
||||
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
|
||||
)
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
|
||||
|
||||
// Act
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } })
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFile).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updateFile with undefined when remove is clicked', () => {
|
||||
// Arrange
|
||||
const mockUpdateFile = vi.fn()
|
||||
const mockFile: FileItem = {
|
||||
fileID: 'file-1',
|
||||
file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
|
||||
progress: 100,
|
||||
}
|
||||
const { container } = render(
|
||||
<CSVUploader {...defaultProps} file={mockFile} updateFile={mockUpdateFile} />,
|
||||
)
|
||||
|
||||
// Act
|
||||
const deleteButton = container.querySelector('.cursor-pointer')
|
||||
if (deleteButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
// Assert
|
||||
expect(mockUpdateFile).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
|
||||
// Validation tests
|
||||
describe('Validation', () => {
|
||||
it('should show error for non-CSV files', () => {
|
||||
// Arrange
|
||||
const { container } = render(<CSVUploader {...defaultProps} />)
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const testFile = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
// Act
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } })
|
||||
|
||||
// Assert
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should show error for files exceeding size limit', () => {
|
||||
// Arrange
|
||||
const { container } = render(<CSVUploader {...defaultProps} />)
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
|
||||
// Create a mock file with a large size (16MB) without actually creating the data
|
||||
const testFile = new File(['test'], 'large.csv', { type: 'text/csv' })
|
||||
Object.defineProperty(testFile, 'size', { value: 16 * 1024 * 1024 })
|
||||
|
||||
// Act
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } })
|
||||
|
||||
// Assert
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Upload progress tests
|
||||
describe('Upload Progress', () => {
|
||||
it('should show progress indicator when upload is in progress', () => {
|
||||
// Arrange
|
||||
const mockFile: FileItem = {
|
||||
fileID: 'file-1',
|
||||
file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
|
||||
progress: 50,
|
||||
}
|
||||
|
||||
// Act
|
||||
const { container } = render(<CSVUploader {...defaultProps} file={mockFile} />)
|
||||
|
||||
// Assert - SimplePieChart should be rendered for progress 0-99
|
||||
// The pie chart would be in the hidden group element
|
||||
expect(container.querySelector('.group')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show progress for completed uploads', () => {
|
||||
// Arrange
|
||||
const mockFile: FileItem = {
|
||||
fileID: 'file-1',
|
||||
file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
|
||||
progress: 100,
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<CSVUploader {...defaultProps} file={mockFile} />)
|
||||
|
||||
// Assert - File name should be displayed
|
||||
expect(screen.getByText('test')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests
|
||||
describe('Props', () => {
|
||||
it('should call updateFile prop when provided', async () => {
|
||||
// Arrange
|
||||
const mockUpdateFile = vi.fn()
|
||||
mockUpload.mockResolvedValueOnce({ id: 'test-id' })
|
||||
|
||||
const { container } = render(
|
||||
<CSVUploader file={undefined} updateFile={mockUpdateFile} />,
|
||||
)
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
|
||||
|
||||
// Act
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } })
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFile).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty file list', () => {
|
||||
// Arrange
|
||||
const mockUpdateFile = vi.fn()
|
||||
const { container } = render(
|
||||
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
|
||||
)
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
|
||||
// Act
|
||||
fireEvent.change(fileInput, { target: { files: [] } })
|
||||
|
||||
// Assert
|
||||
expect(mockUpdateFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle null file', () => {
|
||||
// Arrange
|
||||
const mockUpdateFile = vi.fn()
|
||||
const { container } = render(
|
||||
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
|
||||
)
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
|
||||
// Act
|
||||
fireEvent.change(fileInput, { target: { files: null } })
|
||||
|
||||
// Assert
|
||||
expect(mockUpdateFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<CSVUploader {...defaultProps} />)
|
||||
|
||||
// Act
|
||||
const mockFile: FileItem = {
|
||||
fileID: 'file-1',
|
||||
file: new File(['content'], 'updated.csv', { type: 'text/csv' }) as CustomFile,
|
||||
progress: 100,
|
||||
}
|
||||
rerender(<CSVUploader {...defaultProps} file={mockFile} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('updated')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle upload error', async () => {
|
||||
// Arrange
|
||||
const mockUpdateFile = vi.fn()
|
||||
mockUpload.mockRejectedValueOnce(new Error('Upload failed'))
|
||||
|
||||
const { container } = render(
|
||||
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
|
||||
)
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
|
||||
|
||||
// Act
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } })
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle file without extension', () => {
|
||||
// Arrange
|
||||
const { container } = render(<CSVUploader {...defaultProps} />)
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const testFile = new File(['content'], 'noextension', { type: 'text/plain' })
|
||||
|
||||
// Act
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } })
|
||||
|
||||
// Assert
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Drag and drop tests
|
||||
// Note: Native drag and drop events use addEventListener which is set up in useEffect.
|
||||
// Testing these requires triggering native DOM events on the actual dropRef element.
|
||||
describe('Drag and Drop', () => {
|
||||
it('should render drop zone element', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<CSVUploader {...defaultProps} />)
|
||||
|
||||
// Assert - drop zone should exist for drag and drop
|
||||
const dropZone = container.querySelector('div > div')
|
||||
expect(dropZone).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have drag overlay element that can appear during drag', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<CSVUploader {...defaultProps} />)
|
||||
|
||||
// Assert - component structure supports dragging
|
||||
expect(container.querySelector('div')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Upload progress callback tests
|
||||
describe('Upload Progress Callbacks', () => {
|
||||
it('should update progress during file upload', async () => {
|
||||
// Arrange
|
||||
const mockUpdateFile = vi.fn()
|
||||
let progressCallback: ((e: ProgressEvent) => void) | undefined
|
||||
|
||||
mockUpload.mockImplementation(({ onprogress }) => {
|
||||
progressCallback = onprogress
|
||||
return Promise.resolve({ id: 'uploaded-id' })
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
|
||||
)
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
|
||||
|
||||
// Act
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } })
|
||||
|
||||
// Simulate progress event
|
||||
if (progressCallback) {
|
||||
const progressEvent = new ProgressEvent('progress', {
|
||||
lengthComputable: true,
|
||||
loaded: 50,
|
||||
total: 100,
|
||||
})
|
||||
progressCallback(progressEvent)
|
||||
}
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
progress: expect.any(Number),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle progress event with lengthComputable false', async () => {
|
||||
// Arrange
|
||||
const mockUpdateFile = vi.fn()
|
||||
let progressCallback: ((e: ProgressEvent) => void) | undefined
|
||||
|
||||
mockUpload.mockImplementation(({ onprogress }) => {
|
||||
progressCallback = onprogress
|
||||
return Promise.resolve({ id: 'uploaded-id' })
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
|
||||
)
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
|
||||
|
||||
// Act
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } })
|
||||
|
||||
// Simulate progress event with lengthComputable false
|
||||
if (progressCallback) {
|
||||
const progressEvent = new ProgressEvent('progress', {
|
||||
lengthComputable: false,
|
||||
loaded: 50,
|
||||
total: 100,
|
||||
})
|
||||
progressCallback(progressEvent)
|
||||
}
|
||||
|
||||
// Assert - should complete upload without progress updates when lengthComputable is false
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFile).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,232 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
|
||||
import BatchModal from './index'
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./csv-downloader', () => ({
|
||||
default: ({ docForm }: { docForm: ChunkingMode }) => (
|
||||
<div data-testid="csv-downloader" data-doc-form={docForm}>
|
||||
CSV Downloader
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./csv-uploader', () => ({
|
||||
default: ({ file, updateFile }: { file: { file?: { id: string } } | undefined, updateFile: (file: { file: { id: string } } | undefined) => void }) => (
|
||||
<div data-testid="csv-uploader">
|
||||
<button
|
||||
data-testid="upload-btn"
|
||||
onClick={() => updateFile({ file: { id: 'test-file-id' } })}
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
<button
|
||||
data-testid="clear-btn"
|
||||
onClick={() => updateFile(undefined)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{file && <span data-testid="file-info">{file.file?.id}</span>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('BatchModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
isShow: true,
|
||||
docForm: ChunkingMode.text,
|
||||
onCancel: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
}
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when isShow is true', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchModal {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/list\.batchModal\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render content when isShow is false', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchModal {...defaultProps} isShow={false} />)
|
||||
|
||||
// Assert - Modal is closed
|
||||
expect(screen.queryByText(/list\.batchModal\.title/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CSVDownloader component', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchModal {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('csv-downloader')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render CSVUploader component', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchModal {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('csv-uploader')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel and run buttons', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchModal {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/list\.batchModal\.cancel/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/list\.batchModal\.run/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call onCancel when cancel button is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnCancel = vi.fn()
|
||||
render(<BatchModal {...defaultProps} onCancel={mockOnCancel} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText(/list\.batchModal\.cancel/i))
|
||||
|
||||
// Assert
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable run button when no file is uploaded', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchModal {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
|
||||
expect(runButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable run button after file is uploaded', async () => {
|
||||
// Arrange
|
||||
render(<BatchModal {...defaultProps} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('upload-btn'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
|
||||
expect(runButton).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onConfirm with file when run button is clicked', async () => {
|
||||
// Arrange
|
||||
const mockOnConfirm = vi.fn()
|
||||
const mockOnCancel = vi.fn()
|
||||
render(<BatchModal {...defaultProps} onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
|
||||
|
||||
// Act - upload file first
|
||||
fireEvent.click(screen.getByTestId('upload-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
|
||||
expect(runButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
// Act - click run
|
||||
fireEvent.click(screen.getByText(/list\.batchModal\.run/i))
|
||||
|
||||
// Assert
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnConfirm).toHaveBeenCalledWith({ file: { id: 'test-file-id' } })
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests
|
||||
describe('Props', () => {
|
||||
it('should pass docForm to CSVDownloader', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchModal {...defaultProps} docForm={ChunkingMode.qa} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('csv-downloader').getAttribute('data-doc-form')).toBe(ChunkingMode.qa)
|
||||
})
|
||||
})
|
||||
|
||||
// State reset tests
|
||||
describe('State Reset', () => {
|
||||
it('should reset file when modal is closed and reopened', async () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<BatchModal {...defaultProps} />)
|
||||
|
||||
// Upload a file
|
||||
fireEvent.click(screen.getByTestId('upload-btn'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('file-info')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Close modal
|
||||
rerender(<BatchModal {...defaultProps} isShow={false} />)
|
||||
|
||||
// Reopen modal
|
||||
rerender(<BatchModal {...defaultProps} isShow={true} />)
|
||||
|
||||
// Assert - file should be cleared
|
||||
expect(screen.queryByTestId('file-info')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should not call onConfirm when no file is present', () => {
|
||||
// Arrange
|
||||
const mockOnConfirm = vi.fn()
|
||||
render(<BatchModal {...defaultProps} onConfirm={mockOnConfirm} />)
|
||||
|
||||
// Act - try to click run (should be disabled)
|
||||
const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
|
||||
if (runButton)
|
||||
fireEvent.click(runButton)
|
||||
|
||||
// Assert
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<BatchModal {...defaultProps} />)
|
||||
|
||||
// Act
|
||||
rerender(<BatchModal {...defaultProps} docForm={ChunkingMode.qa} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/list\.batchModal\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle file cleared after upload', async () => {
|
||||
// Arrange
|
||||
const mockOnConfirm = vi.fn()
|
||||
render(<BatchModal {...defaultProps} onConfirm={mockOnConfirm} />)
|
||||
|
||||
// Upload a file first
|
||||
fireEvent.click(screen.getByTestId('upload-btn'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('file-info')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Clear the file
|
||||
fireEvent.click(screen.getByTestId('clear-btn'))
|
||||
|
||||
// Assert - run button should be disabled again
|
||||
const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
|
||||
expect(runButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,330 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
|
||||
import ChildSegmentDetail from './child-segment-detail'
|
||||
|
||||
// Mock segment list context
|
||||
let mockFullScreen = false
|
||||
const mockToggleFullScreen = vi.fn()
|
||||
vi.mock('./index', () => ({
|
||||
useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => {
|
||||
const state = {
|
||||
fullScreen: mockFullScreen,
|
||||
toggleFullScreen: mockToggleFullScreen,
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock event emitter context
|
||||
let mockSubscriptionCallback: ((v: string) => void) | null = null
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
useSubscription: (callback: (v: string) => void) => {
|
||||
mockSubscriptionCallback = callback
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./common/action-buttons', () => ({
|
||||
default: ({ handleCancel, handleSave, loading, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, isChildChunk?: boolean }) => (
|
||||
<div data-testid="action-buttons">
|
||||
<button onClick={handleCancel} data-testid="cancel-btn">Cancel</button>
|
||||
<button onClick={handleSave} disabled={loading} data-testid="save-btn">Save</button>
|
||||
<span data-testid="is-child-chunk">{isChildChunk ? 'true' : 'false'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./common/chunk-content', () => ({
|
||||
default: ({ question, onQuestionChange, isEditMode }: { question: string, onQuestionChange: (v: string) => void, isEditMode: boolean }) => (
|
||||
<div data-testid="chunk-content">
|
||||
<input
|
||||
data-testid="content-input"
|
||||
value={question}
|
||||
onChange={e => onQuestionChange(e.target.value)}
|
||||
/>
|
||||
<span data-testid="edit-mode">{isEditMode ? 'editing' : 'viewing'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./common/dot', () => ({
|
||||
default: () => <span data-testid="dot">•</span>,
|
||||
}))
|
||||
|
||||
vi.mock('./common/segment-index-tag', () => ({
|
||||
SegmentIndexTag: ({ positionId, labelPrefix }: { positionId?: string, labelPrefix?: string }) => (
|
||||
<span data-testid="segment-index-tag">
|
||||
{labelPrefix}
|
||||
{' '}
|
||||
{positionId}
|
||||
</span>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ChildSegmentDetail', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFullScreen = false
|
||||
mockSubscriptionCallback = null
|
||||
})
|
||||
|
||||
const defaultChildChunkInfo = {
|
||||
id: 'child-chunk-1',
|
||||
content: 'Test content',
|
||||
position: 1,
|
||||
updated_at: 1609459200, // 2021-01-01
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
chunkId: 'chunk-1',
|
||||
childChunkInfo: defaultChildChunkInfo,
|
||||
onUpdate: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
docForm: ChunkingMode.text,
|
||||
}
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit child chunk title', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/segment\.editChildChunk/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render chunk content component', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render segment index tag', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render word count', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/segment\.characters/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render edit time', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/segment\.editedAt/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call onCancel when close button is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnCancel = vi.fn()
|
||||
const { container } = render(
|
||||
<ChildSegmentDetail {...defaultProps} onCancel={mockOnCancel} />,
|
||||
)
|
||||
|
||||
// Act
|
||||
const closeButtons = container.querySelectorAll('.cursor-pointer')
|
||||
if (closeButtons.length > 1)
|
||||
fireEvent.click(closeButtons[1])
|
||||
|
||||
// Assert
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call toggleFullScreen when expand button is clicked', () => {
|
||||
// Arrange
|
||||
const { container } = render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Act
|
||||
const expandButtons = container.querySelectorAll('.cursor-pointer')
|
||||
if (expandButtons.length > 0)
|
||||
fireEvent.click(expandButtons[0])
|
||||
|
||||
// Assert
|
||||
expect(mockToggleFullScreen).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onUpdate when save is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnUpdate = vi.fn()
|
||||
render(<ChildSegmentDetail {...defaultProps} onUpdate={mockOnUpdate} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('save-btn'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnUpdate).toHaveBeenCalledWith(
|
||||
'chunk-1',
|
||||
'child-chunk-1',
|
||||
'Test content',
|
||||
)
|
||||
})
|
||||
|
||||
it('should update content when input changes', () => {
|
||||
// Arrange
|
||||
render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Act
|
||||
fireEvent.change(screen.getByTestId('content-input'), {
|
||||
target: { value: 'Updated content' },
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('content-input')).toHaveValue('Updated content')
|
||||
})
|
||||
})
|
||||
|
||||
// Full screen mode
|
||||
describe('Full Screen Mode', () => {
|
||||
it('should show action buttons in header when fullScreen is true', () => {
|
||||
// Arrange
|
||||
mockFullScreen = true
|
||||
|
||||
// Act
|
||||
render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show footer action buttons when fullScreen is true', () => {
|
||||
// Arrange
|
||||
mockFullScreen = true
|
||||
|
||||
// Act
|
||||
render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Assert - footer with border-t-divider-subtle should not exist
|
||||
const actionButtons = screen.getAllByTestId('action-buttons')
|
||||
// Only one action buttons set should exist in fullScreen mode
|
||||
expect(actionButtons.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should show footer action buttons when fullScreen is false', () => {
|
||||
// Arrange
|
||||
mockFullScreen = false
|
||||
|
||||
// Act
|
||||
render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props
|
||||
describe('Props', () => {
|
||||
it('should pass isChildChunk true to ActionButtons', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('is-child-chunk')).toHaveTextContent('true')
|
||||
})
|
||||
|
||||
it('should pass isEditMode true to ChunkContent', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined childChunkInfo', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<ChildSegmentDetail {...defaultProps} childChunkInfo={undefined} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty content', () => {
|
||||
// Arrange
|
||||
const emptyChildChunkInfo = { ...defaultChildChunkInfo, content: '' }
|
||||
|
||||
// Act
|
||||
render(<ChildSegmentDetail {...defaultProps} childChunkInfo={emptyChildChunkInfo} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('content-input')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Act
|
||||
const updatedInfo = { ...defaultChildChunkInfo, content: 'New content' }
|
||||
rerender(<ChildSegmentDetail {...defaultProps} childChunkInfo={updatedInfo} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('content-input')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Event subscription tests
|
||||
describe('Event Subscription', () => {
|
||||
it('should register event subscription', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Assert - subscription callback should be registered
|
||||
expect(mockSubscriptionCallback).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should have save button enabled by default', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentDetail {...defaultProps} />)
|
||||
|
||||
// Assert - save button should be enabled initially
|
||||
expect(screen.getByTestId('save-btn')).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Cancel behavior
|
||||
describe('Cancel Behavior', () => {
|
||||
it('should call onCancel when cancel button is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnCancel = vi.fn()
|
||||
render(<ChildSegmentDetail {...defaultProps} onCancel={mockOnCancel} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('cancel-btn'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,430 +1,499 @@
|
||||
import type { ChildChunkDetail } from '@/models/datasets'
|
||||
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
|
||||
import type { ChildChunkDetail, ChunkingMode, ParentMode } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import * as React from 'react'
|
||||
import ChildSegmentList from './child-segment-list'
|
||||
|
||||
// ============================================================================
|
||||
// Hoisted Mocks
|
||||
// ============================================================================
|
||||
|
||||
const {
|
||||
mockParentMode,
|
||||
mockCurrChildChunk,
|
||||
} = vi.hoisted(() => ({
|
||||
mockParentMode: { current: 'paragraph' as ParentMode },
|
||||
mockCurrChildChunk: { current: { childChunkInfo: undefined, showModal: false } as { childChunkInfo?: ChildChunkDetail, showModal: boolean } },
|
||||
}))
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { count?: number, ns?: string }) => {
|
||||
if (key === 'segment.childChunks')
|
||||
return options?.count === 1 ? 'child chunk' : 'child chunks'
|
||||
if (key === 'segment.searchResults')
|
||||
return 'search results'
|
||||
if (key === 'segment.edited')
|
||||
return 'edited'
|
||||
if (key === 'operation.add')
|
||||
return 'Add'
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock document context
|
||||
let mockParentMode = 'paragraph'
|
||||
vi.mock('../context', () => ({
|
||||
useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => {
|
||||
return selector({ parentMode: mockParentMode })
|
||||
useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => {
|
||||
const value: DocumentContextValue = {
|
||||
datasetId: 'test-dataset-id',
|
||||
documentId: 'test-document-id',
|
||||
docForm: 'text' as ChunkingMode,
|
||||
parentMode: mockParentMode.current,
|
||||
}
|
||||
return selector(value)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock segment list context
|
||||
let mockCurrChildChunk: { childChunkInfo: { id: string } } | null = null
|
||||
vi.mock('./index', () => ({
|
||||
useSegmentListContext: (selector: (state: { currChildChunk: { childChunkInfo: { id: string } } | null }) => unknown) => {
|
||||
return selector({ currChildChunk: mockCurrChildChunk })
|
||||
useSegmentListContext: (selector: (value: { currChildChunk: { childChunkInfo?: ChildChunkDetail, showModal: boolean } }) => unknown) => {
|
||||
return selector({ currChildChunk: mockCurrChildChunk.current })
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock child components
|
||||
// Mock skeleton component
|
||||
vi.mock('./skeleton/full-doc-list-skeleton', () => ({
|
||||
default: () => <div data-testid="full-doc-list-skeleton">Loading...</div>,
|
||||
}))
|
||||
|
||||
// Mock Empty component
|
||||
vi.mock('./common/empty', () => ({
|
||||
default: ({ onClearFilter }: { onClearFilter: () => void }) => (
|
||||
<div data-testid="empty">
|
||||
<button onClick={onClearFilter} data-testid="clear-filter-btn">Clear Filter</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./skeleton/full-doc-list-skeleton', () => ({
|
||||
default: () => <div data-testid="full-doc-skeleton">Loading...</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../../../formatted-text/flavours/edit-slice', () => ({
|
||||
EditSlice: ({
|
||||
label,
|
||||
text,
|
||||
onDelete,
|
||||
className,
|
||||
labelClassName,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
text: string
|
||||
onDelete: () => void
|
||||
className: string
|
||||
labelClassName: string
|
||||
contentClassName: string
|
||||
labelInnerClassName: string
|
||||
showDivider: boolean
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
offsetOptions: unknown
|
||||
}) => (
|
||||
<div data-testid="edit-slice" className={className}>
|
||||
<span data-testid="slice-label" className={labelClassName}>{label}</span>
|
||||
<span data-testid="slice-text">{text}</span>
|
||||
<button data-testid="delete-slice-btn" onClick={onDelete}>Delete</button>
|
||||
<button data-testid="click-slice-btn" onClick={e => onClick(e)}>Click</button>
|
||||
<div data-testid="empty-component">
|
||||
<button onClick={onClearFilter}>Clear Filter</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock FormattedText and EditSlice
|
||||
vi.mock('../../../formatted-text/formatted', () => ({
|
||||
FormattedText: ({ children, className }: { children: React.ReactNode, className: string }) => (
|
||||
FormattedText: ({ children, className }: { children: React.ReactNode, className?: string }) => (
|
||||
<div data-testid="formatted-text" className={className}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../formatted-text/flavours/edit-slice', () => ({
|
||||
EditSlice: ({ label, text, onDelete, onClick, labelClassName, contentClassName }: {
|
||||
label: string
|
||||
text: string
|
||||
onDelete: () => void
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
labelClassName?: string
|
||||
contentClassName?: string
|
||||
}) => (
|
||||
<div data-testid="edit-slice" onClick={onClick}>
|
||||
<span data-testid="edit-slice-label" className={labelClassName}>{label}</span>
|
||||
<span data-testid="edit-slice-content" className={contentClassName}>{text}</span>
|
||||
<button
|
||||
data-testid="delete-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// ============================================================================
|
||||
// Test Data Factories
|
||||
// ============================================================================
|
||||
|
||||
const createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildChunkDetail => ({
|
||||
id: `child-${Math.random().toString(36).substr(2, 9)}`,
|
||||
position: 1,
|
||||
segment_id: 'segment-1',
|
||||
content: 'Child chunk content',
|
||||
word_count: 100,
|
||||
created_at: 1700000000,
|
||||
updated_at: 1700000000,
|
||||
type: 'automatic',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('ChildSegmentList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockParentMode = 'paragraph'
|
||||
mockCurrChildChunk = null
|
||||
})
|
||||
|
||||
const createMockChildChunk = (id: string, content: string, edited = false): ChildChunkDetail => ({
|
||||
id,
|
||||
content,
|
||||
position: 1,
|
||||
word_count: 10,
|
||||
segment_id: 'seg-1',
|
||||
created_at: Date.now(),
|
||||
updated_at: edited ? Date.now() + 1000 : Date.now(),
|
||||
type: 'automatic',
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
childChunks: [createMockChildChunk('child-1', 'Child content 1')],
|
||||
childChunks: [] as ChildChunkDetail[],
|
||||
parentChunkId: 'parent-1',
|
||||
handleInputChange: vi.fn(),
|
||||
handleAddNewChildChunk: vi.fn(),
|
||||
enabled: true,
|
||||
onDelete: vi.fn(),
|
||||
onClickSlice: vi.fn(),
|
||||
total: 1,
|
||||
inputValue: '',
|
||||
onClearFilter: vi.fn(),
|
||||
isLoading: false,
|
||||
focused: false,
|
||||
}
|
||||
|
||||
// Rendering tests
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockParentMode.current = 'paragraph'
|
||||
mockCurrChildChunk.current = { childChunkInfo: undefined, showModal: false }
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<ChildSegmentList {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render total count text', () => {
|
||||
// Arrange & Act
|
||||
it('should render with empty child chunks', () => {
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/segment\.childChunks/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/child chunks/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render add button', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
it('should render child chunks when provided', () => {
|
||||
const childChunks = [
|
||||
createMockChildChunk({ id: 'child-1', position: 1, content: 'First chunk' }),
|
||||
createMockChildChunk({ id: 'child-2', position: 2, content: 'Second chunk' }),
|
||||
]
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/operation\.add/i)).toBeInTheDocument()
|
||||
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
|
||||
|
||||
// In paragraph mode, content is collapsed by default
|
||||
expect(screen.getByText(/2 child chunks/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render total count correctly with total prop in full-doc mode', () => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
const childChunks = [createMockChildChunk()]
|
||||
|
||||
// Pass inputValue="" to ensure isSearching is false
|
||||
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} total={5} isLoading={false} inputValue="" />)
|
||||
|
||||
expect(screen.getByText(/5 child chunks/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render loading skeleton in full-doc mode when loading', () => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
|
||||
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
|
||||
|
||||
expect(screen.getByTestId('full-doc-list-skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render loading skeleton when not loading', () => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
|
||||
render(<ChildSegmentList {...defaultProps} isLoading={false} />)
|
||||
|
||||
expect(screen.queryByTestId('full-doc-list-skeleton')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Paragraph mode tests
|
||||
describe('Paragraph Mode', () => {
|
||||
beforeEach(() => {
|
||||
mockParentMode = 'paragraph'
|
||||
mockParentMode.current = 'paragraph'
|
||||
})
|
||||
|
||||
it('should render collapsed by default in paragraph mode', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
it('should show collapse icon in paragraph mode', () => {
|
||||
const childChunks = [createMockChildChunk()]
|
||||
|
||||
// Assert - collapsed icon should be present
|
||||
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
|
||||
|
||||
// Check for collapse/expand behavior
|
||||
const totalRow = screen.getByText(/1 child chunk/i).closest('div')
|
||||
expect(totalRow).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle collapsed state when clicked', () => {
|
||||
const childChunks = [createMockChildChunk({ content: 'Test content' })]
|
||||
|
||||
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
|
||||
|
||||
// Initially collapsed in paragraph mode - content should not be visible
|
||||
expect(screen.queryByTestId('formatted-text')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expand when clicking toggle in paragraph mode', () => {
|
||||
// Arrange
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
// Find and click the toggle area
|
||||
const toggleArea = screen.getByText(/1 child chunk/i).closest('div')
|
||||
|
||||
// Act - click on the collapse toggle
|
||||
const toggleArea = screen.getByText(/segment\.childChunks/i).closest('div')
|
||||
// Click to expand
|
||||
if (toggleArea)
|
||||
fireEvent.click(toggleArea)
|
||||
|
||||
// Assert - child chunks should be visible
|
||||
// After expansion, content should be visible
|
||||
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should collapse when clicking toggle again', () => {
|
||||
// Arrange
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
|
||||
// Act - click twice
|
||||
const toggleArea = screen.getByText(/segment\.childChunks/i).closest('div')
|
||||
if (toggleArea) {
|
||||
fireEvent.click(toggleArea)
|
||||
fireEvent.click(toggleArea)
|
||||
}
|
||||
|
||||
// Assert - child chunks should be hidden
|
||||
expect(screen.queryByTestId('formatted-text')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Full doc mode tests
|
||||
describe('Full Doc Mode', () => {
|
||||
beforeEach(() => {
|
||||
mockParentMode = 'full-doc'
|
||||
})
|
||||
|
||||
it('should render input field in full-doc mode', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render child chunks without collapse in full-doc mode', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call handleInputChange when input changes', () => {
|
||||
// Arrange
|
||||
const mockHandleInputChange = vi.fn()
|
||||
render(<ChildSegmentList {...defaultProps} handleInputChange={mockHandleInputChange} />)
|
||||
|
||||
// Act
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'search term' } })
|
||||
|
||||
// Assert
|
||||
expect(mockHandleInputChange).toHaveBeenCalledWith('search term')
|
||||
})
|
||||
|
||||
it('should show search results text when searching', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentList {...defaultProps} inputValue="search" total={5} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/segment\.searchResults/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show empty component when no results and searching', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentList {...defaultProps} inputValue="search" childChunks={[]} total={0} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading skeleton when isLoading is true', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('full-doc-skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined total in full-doc mode', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<ChildSegmentList {...defaultProps} total={undefined} />)
|
||||
|
||||
// Assert - component should render without crashing
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call handleAddNewChildChunk when add button is clicked', () => {
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
const mockHandleAddNewChildChunk = vi.fn()
|
||||
render(<ChildSegmentList {...defaultProps} handleAddNewChildChunk={mockHandleAddNewChildChunk} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText(/operation\.add/i))
|
||||
|
||||
// Assert
|
||||
expect(mockHandleAddNewChildChunk).toHaveBeenCalledWith('parent-1')
|
||||
})
|
||||
|
||||
it('should call onDelete when delete button is clicked', () => {
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
const mockOnDelete = vi.fn()
|
||||
render(<ChildSegmentList {...defaultProps} onDelete={mockOnDelete} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('delete-slice-btn'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnDelete).toHaveBeenCalledWith('seg-1', 'child-1')
|
||||
})
|
||||
|
||||
it('should call onClickSlice when slice is clicked', () => {
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
const mockOnClickSlice = vi.fn()
|
||||
render(<ChildSegmentList {...defaultProps} onClickSlice={mockOnClickSlice} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('click-slice-btn'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnClickSlice).toHaveBeenCalledWith(expect.objectContaining({ id: 'child-1' }))
|
||||
})
|
||||
|
||||
it('should call onClearFilter when clear filter button is clicked', () => {
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
const mockOnClearFilter = vi.fn()
|
||||
render(<ChildSegmentList {...defaultProps} inputValue="search" childChunks={[]} onClearFilter={mockOnClearFilter} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('clear-filter-btn'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnClearFilter).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Focused state
|
||||
describe('Focused State', () => {
|
||||
it('should apply focused style when currChildChunk matches', () => {
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
mockCurrChildChunk = { childChunkInfo: { id: 'child-1' } }
|
||||
|
||||
// Act
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
|
||||
// Assert - check for focused class on label
|
||||
const label = screen.getByTestId('slice-label')
|
||||
expect(label).toHaveClass('bg-state-accent-solid')
|
||||
})
|
||||
|
||||
it('should not apply focused style when currChildChunk does not match', () => {
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
mockCurrChildChunk = { childChunkInfo: { id: 'other-child' } }
|
||||
|
||||
// Act
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
const label = screen.getByTestId('slice-label')
|
||||
expect(label).not.toHaveClass('bg-state-accent-solid')
|
||||
})
|
||||
})
|
||||
|
||||
// Enabled/Disabled state
|
||||
describe('Enabled State', () => {
|
||||
it('should apply opacity when enabled is false', () => {
|
||||
// Arrange & Act
|
||||
it('should apply opacity when disabled', () => {
|
||||
const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} />)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
const wrapper = container.firstChild
|
||||
expect(wrapper).toHaveClass('opacity-50')
|
||||
})
|
||||
|
||||
it('should not apply opacity when enabled is true', () => {
|
||||
// Arrange & Act
|
||||
it('should not apply opacity when enabled', () => {
|
||||
const { container } = render(<ChildSegmentList {...defaultProps} enabled={true} />)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).not.toHaveClass('opacity-50')
|
||||
})
|
||||
|
||||
it('should not apply opacity when focused is true even if enabled is false', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} focused={true} />)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
const wrapper = container.firstChild
|
||||
expect(wrapper).not.toHaveClass('opacity-50')
|
||||
})
|
||||
})
|
||||
|
||||
// Edited indicator
|
||||
describe('Edited Indicator', () => {
|
||||
it('should show edited indicator for edited chunks', () => {
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
const editedChunk = createMockChildChunk('child-edited', 'Edited content', true)
|
||||
describe('Full-Doc Mode', () => {
|
||||
beforeEach(() => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<ChildSegmentList {...defaultProps} childChunks={[editedChunk]} />)
|
||||
it('should show content by default in full-doc mode', () => {
|
||||
const childChunks = [createMockChildChunk({ content: 'Full doc content' })]
|
||||
|
||||
// Assert
|
||||
const label = screen.getByTestId('slice-label')
|
||||
expect(label.textContent).toContain('segment.edited')
|
||||
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} isLoading={false} />)
|
||||
|
||||
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render search input in full-doc mode', () => {
|
||||
render(<ChildSegmentList {...defaultProps} inputValue="" handleInputChange={vi.fn()} />)
|
||||
|
||||
const input = document.querySelector('input')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call handleInputChange when input changes', () => {
|
||||
const handleInputChange = vi.fn()
|
||||
|
||||
render(<ChildSegmentList {...defaultProps} inputValue="" handleInputChange={handleInputChange} />)
|
||||
|
||||
const input = document.querySelector('input')
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
expect(handleInputChange).toHaveBeenCalledWith('test search')
|
||||
}
|
||||
})
|
||||
|
||||
it('should show search results text when searching', () => {
|
||||
render(<ChildSegmentList {...defaultProps} inputValue="search term" total={3} />)
|
||||
|
||||
expect(screen.getByText(/3 search results/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show empty component when no results and searching', () => {
|
||||
render(
|
||||
<ChildSegmentList
|
||||
{...defaultProps}
|
||||
childChunks={[]}
|
||||
inputValue="search term"
|
||||
onClearFilter={vi.fn()}
|
||||
isLoading={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClearFilter when clear button clicked in empty state', () => {
|
||||
const onClearFilter = vi.fn()
|
||||
|
||||
render(
|
||||
<ChildSegmentList
|
||||
{...defaultProps}
|
||||
childChunks={[]}
|
||||
inputValue="search term"
|
||||
onClearFilter={onClearFilter}
|
||||
isLoading={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const clearButton = screen.getByText('Clear Filter')
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(onClearFilter).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Multiple chunks
|
||||
describe('Multiple Chunks', () => {
|
||||
it('should render multiple child chunks', () => {
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
const chunks = [
|
||||
createMockChildChunk('child-1', 'Content 1'),
|
||||
createMockChildChunk('child-2', 'Content 2'),
|
||||
createMockChildChunk('child-3', 'Content 3'),
|
||||
]
|
||||
describe('Child Chunk Items', () => {
|
||||
it('should render edited label when chunk is edited', () => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
const editedChunk = createMockChildChunk({
|
||||
id: 'edited-chunk',
|
||||
position: 1,
|
||||
created_at: 1700000000,
|
||||
updated_at: 1700000001, // Different from created_at
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<ChildSegmentList {...defaultProps} childChunks={chunks} total={3} />)
|
||||
render(<ChildSegmentList {...defaultProps} childChunks={[editedChunk]} isLoading={false} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('edit-slice')).toHaveLength(3)
|
||||
expect(screen.getByText(/C-1 · edited/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show edited label when chunk is not edited', () => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
const normalChunk = createMockChildChunk({
|
||||
id: 'normal-chunk',
|
||||
position: 2,
|
||||
created_at: 1700000000,
|
||||
updated_at: 1700000000, // Same as created_at
|
||||
})
|
||||
|
||||
render(<ChildSegmentList {...defaultProps} childChunks={[normalChunk]} isLoading={false} />)
|
||||
|
||||
expect(screen.getByText('C-2')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/edited/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClickSlice when chunk is clicked', () => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
const onClickSlice = vi.fn()
|
||||
const chunk = createMockChildChunk({ id: 'clickable-chunk' })
|
||||
|
||||
render(
|
||||
<ChildSegmentList
|
||||
{...defaultProps}
|
||||
childChunks={[chunk]}
|
||||
onClickSlice={onClickSlice}
|
||||
isLoading={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const editSlice = screen.getByTestId('edit-slice')
|
||||
fireEvent.click(editSlice)
|
||||
|
||||
expect(onClickSlice).toHaveBeenCalledWith(chunk)
|
||||
})
|
||||
|
||||
it('should call onDelete when delete button is clicked', () => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
const onDelete = vi.fn()
|
||||
const chunk = createMockChildChunk({ id: 'deletable-chunk', segment_id: 'seg-1' })
|
||||
|
||||
render(
|
||||
<ChildSegmentList
|
||||
{...defaultProps}
|
||||
childChunks={[chunk]}
|
||||
onDelete={onDelete}
|
||||
isLoading={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
const deleteButton = screen.getByTestId('delete-button')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(onDelete).toHaveBeenCalledWith('seg-1', 'deletable-chunk')
|
||||
})
|
||||
|
||||
it('should apply focused styles when chunk is currently selected', () => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
const chunk = createMockChildChunk({ id: 'focused-chunk' })
|
||||
mockCurrChildChunk.current = { childChunkInfo: chunk, showModal: true }
|
||||
|
||||
render(<ChildSegmentList {...defaultProps} childChunks={[chunk]} isLoading={false} />)
|
||||
|
||||
const label = screen.getByTestId('edit-slice-label')
|
||||
expect(label).toHaveClass('bg-state-accent-solid')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty childChunks array', () => {
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
describe('Add Button', () => {
|
||||
it('should call handleAddNewChildChunk when Add button is clicked', () => {
|
||||
const handleAddNewChildChunk = vi.fn()
|
||||
|
||||
// Act
|
||||
const { container } = render(<ChildSegmentList {...defaultProps} childChunks={[]} />)
|
||||
render(
|
||||
<ChildSegmentList
|
||||
{...defaultProps}
|
||||
handleAddNewChildChunk={handleAddNewChildChunk}
|
||||
parentChunkId="parent-123"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
const addButton = screen.getByText('Add')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
expect(handleAddNewChildChunk).toHaveBeenCalledWith('parent-123')
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
const { rerender } = render(<ChildSegmentList {...defaultProps} />)
|
||||
it('should disable Add button when loading in full-doc mode', () => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
|
||||
// Act
|
||||
const newChunks = [createMockChildChunk('new-child', 'New content')]
|
||||
rerender(<ChildSegmentList {...defaultProps} childChunks={newChunks} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('New content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable add button when loading', () => {
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
|
||||
// Act
|
||||
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
|
||||
|
||||
// Assert
|
||||
const addButton = screen.getByText(/operation\.add/i)
|
||||
const addButton = screen.getByText('Add')
|
||||
expect(addButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should stop propagation when Add button is clicked', () => {
|
||||
const handleAddNewChildChunk = vi.fn()
|
||||
const parentClickHandler = vi.fn()
|
||||
|
||||
render(
|
||||
<div onClick={parentClickHandler}>
|
||||
<ChildSegmentList
|
||||
{...defaultProps}
|
||||
handleAddNewChildChunk={handleAddNewChildChunk}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
const addButton = screen.getByText('Add')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
expect(handleAddNewChildChunk).toHaveBeenCalled()
|
||||
// Parent should not be called due to stopPropagation
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeTotalInfo function', () => {
|
||||
it('should return search results when searching in full-doc mode', () => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
|
||||
render(<ChildSegmentList {...defaultProps} inputValue="search" total={10} />)
|
||||
|
||||
expect(screen.getByText(/10 search results/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return "--" when total is 0 in full-doc mode', () => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
|
||||
render(<ChildSegmentList {...defaultProps} total={0} />)
|
||||
|
||||
// When total is 0, displayText is '--'
|
||||
expect(screen.getByText(/--/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use childChunks length in paragraph mode', () => {
|
||||
mockParentMode.current = 'paragraph'
|
||||
const childChunks = [
|
||||
createMockChildChunk(),
|
||||
createMockChildChunk(),
|
||||
createMockChildChunk(),
|
||||
]
|
||||
|
||||
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
|
||||
|
||||
expect(screen.getByText(/3 child chunks/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Focused State', () => {
|
||||
it('should not apply opacity when focused even if disabled', () => {
|
||||
const { container } = render(
|
||||
<ChildSegmentList {...defaultProps} enabled={false} focused={true} />,
|
||||
)
|
||||
|
||||
const wrapper = container.firstChild
|
||||
expect(wrapper).not.toHaveClass('opacity-50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Input clear button', () => {
|
||||
it('should call handleInputChange with empty string when clear is clicked', () => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
const handleInputChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ChildSegmentList
|
||||
{...defaultProps}
|
||||
inputValue="test"
|
||||
handleInputChange={handleInputChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find the clear button (it's the showClearIcon button in Input)
|
||||
const input = document.querySelector('input')
|
||||
if (input) {
|
||||
// Trigger clear by simulating the input's onClear
|
||||
const clearButton = document.querySelector('[class*="cursor-pointer"]')
|
||||
if (clearButton)
|
||||
fireEvent.click(clearButton)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,523 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import { DocumentContext } from '../../context'
|
||||
import ActionButtons from './action-buttons'
|
||||
|
||||
// Mock useKeyPress from ahooks to capture and test callback functions
|
||||
const mockUseKeyPress = vi.fn()
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: (keys: string | string[], callback: (e: KeyboardEvent) => void, options?: object) => {
|
||||
mockUseKeyPress(keys, callback, options)
|
||||
},
|
||||
}))
|
||||
|
||||
// Create wrapper component for providing context
|
||||
const createWrapper = (contextValue: {
|
||||
docForm?: ChunkingMode
|
||||
parentMode?: 'paragraph' | 'full-doc'
|
||||
}) => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<DocumentContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</DocumentContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to get captured callbacks from useKeyPress mock
|
||||
const getEscCallback = (): ((e: KeyboardEvent) => void) | undefined => {
|
||||
const escCall = mockUseKeyPress.mock.calls.find(
|
||||
(call) => {
|
||||
const keys = call[0]
|
||||
return Array.isArray(keys) && keys.includes('esc')
|
||||
},
|
||||
)
|
||||
return escCall?.[1]
|
||||
}
|
||||
|
||||
const getCtrlSCallback = (): ((e: KeyboardEvent) => void) | undefined => {
|
||||
const ctrlSCall = mockUseKeyPress.mock.calls.find(
|
||||
(call) => {
|
||||
const keys = call[0]
|
||||
return typeof keys === 'string' && keys.includes('.s')
|
||||
},
|
||||
)
|
||||
return ctrlSCall?.[1]
|
||||
}
|
||||
|
||||
describe('ActionButtons', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseKeyPress.mockClear()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
loading={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel button', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
loading={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render save button', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
loading={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ESC keyboard hint on cancel button', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
loading={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('ESC')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render S keyboard hint on save button', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
loading={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('S')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call handleCancel when cancel button is clicked', () => {
|
||||
// Arrange
|
||||
const mockHandleCancel = vi.fn()
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={mockHandleCancel}
|
||||
handleSave={vi.fn()}
|
||||
loading={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Act
|
||||
const cancelButton = screen.getAllByRole('button')[0]
|
||||
fireEvent.click(cancelButton)
|
||||
|
||||
// Assert
|
||||
expect(mockHandleCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleSave when save button is clicked', () => {
|
||||
// Arrange
|
||||
const mockHandleSave = vi.fn()
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={mockHandleSave}
|
||||
loading={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Act
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveButton = buttons[buttons.length - 1] // Save button is last
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
// Assert
|
||||
expect(mockHandleSave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable save button when loading is true', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
loading={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Assert
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveButton = buttons[buttons.length - 1]
|
||||
expect(saveButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Regeneration button tests
|
||||
describe('Regeneration Button', () => {
|
||||
it('should show regeneration button in parent-child paragraph mode for edit action', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleRegeneration={vi.fn()}
|
||||
loading={false}
|
||||
actionType="edit"
|
||||
isChildChunk={false}
|
||||
showRegenerationButton={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show regeneration button when isChildChunk is true', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleRegeneration={vi.fn()}
|
||||
loading={false}
|
||||
actionType="edit"
|
||||
isChildChunk={true}
|
||||
showRegenerationButton={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show regeneration button when showRegenerationButton is false', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleRegeneration={vi.fn()}
|
||||
loading={false}
|
||||
actionType="edit"
|
||||
isChildChunk={false}
|
||||
showRegenerationButton={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show regeneration button when actionType is add', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleRegeneration={vi.fn()}
|
||||
loading={false}
|
||||
actionType="add"
|
||||
isChildChunk={false}
|
||||
showRegenerationButton={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call handleRegeneration when regeneration button is clicked', () => {
|
||||
// Arrange
|
||||
const mockHandleRegeneration = vi.fn()
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleRegeneration={mockHandleRegeneration}
|
||||
loading={false}
|
||||
actionType="edit"
|
||||
isChildChunk={false}
|
||||
showRegenerationButton={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
|
||||
)
|
||||
|
||||
// Act
|
||||
const regenerationButton = screen.getByText(/operation\.saveAndRegenerate/i).closest('button')
|
||||
if (regenerationButton)
|
||||
fireEvent.click(regenerationButton)
|
||||
|
||||
// Assert
|
||||
expect(mockHandleRegeneration).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable regeneration button when loading is true', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleRegeneration={vi.fn()}
|
||||
loading={true}
|
||||
actionType="edit"
|
||||
isChildChunk={false}
|
||||
showRegenerationButton={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
|
||||
)
|
||||
|
||||
// Assert
|
||||
const regenerationButton = screen.getByText(/operation\.saveAndRegenerate/i).closest('button')
|
||||
expect(regenerationButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Default props tests
|
||||
describe('Default Props', () => {
|
||||
it('should use default actionType of edit', () => {
|
||||
// Arrange & Act - when not specifying actionType and other conditions are met
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleRegeneration={vi.fn()}
|
||||
loading={false}
|
||||
showRegenerationButton={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
|
||||
)
|
||||
|
||||
// Assert - regeneration button should show with default actionType='edit'
|
||||
expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use default isChildChunk of false', () => {
|
||||
// Arrange & Act - when not specifying isChildChunk
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleRegeneration={vi.fn()}
|
||||
loading={false}
|
||||
actionType="edit"
|
||||
showRegenerationButton={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
|
||||
)
|
||||
|
||||
// Assert - regeneration button should show with default isChildChunk=false
|
||||
expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use default showRegenerationButton of true', () => {
|
||||
// Arrange & Act - when not specifying showRegenerationButton
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
handleRegeneration={vi.fn()}
|
||||
loading={false}
|
||||
actionType="edit"
|
||||
isChildChunk={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
|
||||
)
|
||||
|
||||
// Assert - regeneration button should show with default showRegenerationButton=true
|
||||
expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle missing context values gracefully', () => {
|
||||
// Arrange & Act & Assert - should not throw
|
||||
expect(() => {
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
loading={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
loading={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Act
|
||||
rerender(
|
||||
<DocumentContext.Provider value={{}}>
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
loading={true}
|
||||
/>
|
||||
</DocumentContext.Provider>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Keyboard shortcuts tests via useKeyPress callbacks
|
||||
describe('Keyboard Shortcuts', () => {
|
||||
it('should display ctrl key hint on save button', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
loading={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Assert - check for ctrl key hint (Ctrl or Cmd depending on system)
|
||||
const kbdElements = document.querySelectorAll('.system-kbd')
|
||||
expect(kbdElements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should call handleCancel and preventDefault when ESC key is pressed', () => {
|
||||
// Arrange
|
||||
const mockHandleCancel = vi.fn()
|
||||
const mockPreventDefault = vi.fn()
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={mockHandleCancel}
|
||||
handleSave={vi.fn()}
|
||||
loading={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Act - get the ESC callback and invoke it
|
||||
const escCallback = getEscCallback()
|
||||
expect(escCallback).toBeDefined()
|
||||
escCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent)
|
||||
|
||||
// Assert
|
||||
expect(mockPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleSave and preventDefault when Ctrl+S is pressed and not loading', () => {
|
||||
// Arrange
|
||||
const mockHandleSave = vi.fn()
|
||||
const mockPreventDefault = vi.fn()
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={mockHandleSave}
|
||||
loading={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Act - get the Ctrl+S callback and invoke it
|
||||
const ctrlSCallback = getCtrlSCallback()
|
||||
expect(ctrlSCallback).toBeDefined()
|
||||
ctrlSCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent)
|
||||
|
||||
// Assert
|
||||
expect(mockPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleSave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call handleSave when Ctrl+S is pressed while loading', () => {
|
||||
// Arrange
|
||||
const mockHandleSave = vi.fn()
|
||||
const mockPreventDefault = vi.fn()
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={mockHandleSave}
|
||||
loading={true}
|
||||
/>,
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Act - get the Ctrl+S callback and invoke it
|
||||
const ctrlSCallback = getCtrlSCallback()
|
||||
expect(ctrlSCallback).toBeDefined()
|
||||
ctrlSCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent)
|
||||
|
||||
// Assert
|
||||
expect(mockPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should register useKeyPress with correct options for Ctrl+S', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ActionButtons
|
||||
handleCancel={vi.fn()}
|
||||
handleSave={vi.fn()}
|
||||
loading={false}
|
||||
/>,
|
||||
{ wrapper: createWrapper({}) },
|
||||
)
|
||||
|
||||
// Assert - verify useKeyPress was called with correct options
|
||||
const ctrlSCall = mockUseKeyPress.mock.calls.find(
|
||||
call => typeof call[0] === 'string' && call[0].includes('.s'),
|
||||
)
|
||||
expect(ctrlSCall).toBeDefined()
|
||||
expect(ctrlSCall![2]).toEqual({ exactMatch: true, useCapture: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,194 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AddAnother from './add-another'
|
||||
|
||||
describe('AddAnother', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<AddAnother isChecked={false} onCheck={vi.fn()} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the checkbox', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<AddAnother isChecked={false} onCheck={vi.fn()} />,
|
||||
)
|
||||
|
||||
// Assert - Checkbox component renders with shrink-0 class
|
||||
const checkbox = container.querySelector('.shrink-0')
|
||||
expect(checkbox).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the add another text', () => {
|
||||
// Arrange & Act
|
||||
render(<AddAnother isChecked={false} onCheck={vi.fn()} />)
|
||||
|
||||
// Assert - i18n key format
|
||||
expect(screen.getByText(/segment\.addAnother/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with correct base styling classes', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<AddAnother isChecked={false} onCheck={vi.fn()} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('flex')
|
||||
expect(wrapper).toHaveClass('items-center')
|
||||
expect(wrapper).toHaveClass('gap-x-1')
|
||||
expect(wrapper).toHaveClass('pl-1')
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests
|
||||
describe('Props', () => {
|
||||
it('should render unchecked state when isChecked is false', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<AddAnother isChecked={false} onCheck={vi.fn()} />,
|
||||
)
|
||||
|
||||
// Assert - unchecked checkbox has border class
|
||||
const checkbox = container.querySelector('.border-components-checkbox-border')
|
||||
expect(checkbox).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render checked state when isChecked is true', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<AddAnother isChecked={true} onCheck={vi.fn()} />,
|
||||
)
|
||||
|
||||
// Assert - checked checkbox has bg-components-checkbox-bg class
|
||||
const checkbox = container.querySelector('.bg-components-checkbox-bg')
|
||||
expect(checkbox).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<AddAnother
|
||||
isChecked={false}
|
||||
onCheck={vi.fn()}
|
||||
className="custom-class"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call onCheck when checkbox is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnCheck = vi.fn()
|
||||
const { container } = render(
|
||||
<AddAnother isChecked={false} onCheck={mockOnCheck} />,
|
||||
)
|
||||
|
||||
// Act - click on the checkbox element
|
||||
const checkbox = container.querySelector('.shrink-0')
|
||||
if (checkbox)
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
// Assert
|
||||
expect(mockOnCheck).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should toggle checked state on multiple clicks', () => {
|
||||
// Arrange
|
||||
const mockOnCheck = vi.fn()
|
||||
const { container, rerender } = render(
|
||||
<AddAnother isChecked={false} onCheck={mockOnCheck} />,
|
||||
)
|
||||
|
||||
// Act - first click
|
||||
const checkbox = container.querySelector('.shrink-0')
|
||||
if (checkbox) {
|
||||
fireEvent.click(checkbox)
|
||||
rerender(<AddAnother isChecked={true} onCheck={mockOnCheck} />)
|
||||
fireEvent.click(checkbox)
|
||||
}
|
||||
|
||||
// Assert
|
||||
expect(mockOnCheck).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// Structure tests
|
||||
describe('Structure', () => {
|
||||
it('should render text with tertiary text color', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<AddAnother isChecked={false} onCheck={vi.fn()} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const textElement = container.querySelector('.text-text-tertiary')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text with xs medium font styling', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<AddAnother isChecked={false} onCheck={vi.fn()} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const textElement = container.querySelector('.system-xs-medium')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should maintain structure when rerendered', () => {
|
||||
// Arrange
|
||||
const mockOnCheck = vi.fn()
|
||||
const { rerender, container } = render(
|
||||
<AddAnother isChecked={false} onCheck={mockOnCheck} />,
|
||||
)
|
||||
|
||||
// Act
|
||||
rerender(<AddAnother isChecked={true} onCheck={mockOnCheck} />)
|
||||
|
||||
// Assert
|
||||
const checkbox = container.querySelector('.shrink-0')
|
||||
expect(checkbox).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle rapid state changes', () => {
|
||||
// Arrange
|
||||
const mockOnCheck = vi.fn()
|
||||
const { container } = render(
|
||||
<AddAnother isChecked={false} onCheck={mockOnCheck} />,
|
||||
)
|
||||
|
||||
// Act
|
||||
const checkbox = container.querySelector('.shrink-0')
|
||||
if (checkbox) {
|
||||
for (let i = 0; i < 5; i++)
|
||||
fireEvent.click(checkbox)
|
||||
}
|
||||
|
||||
// Assert
|
||||
expect(mockOnCheck).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,277 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import BatchAction from './batch-action'
|
||||
|
||||
describe('BatchAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
selectedIds: ['1', '2', '3'],
|
||||
onBatchEnable: vi.fn(),
|
||||
onBatchDisable: vi.fn(),
|
||||
onBatchDelete: vi.fn().mockResolvedValue(undefined),
|
||||
onCancel: vi.fn(),
|
||||
}
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<BatchAction {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display selected count', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchAction {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render enable button', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchAction {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/batchAction\.enable/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render disable button', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchAction {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/batchAction\.disable/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete button', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchAction {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/batchAction\.delete/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel button', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchAction {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/batchAction\.cancel/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call onBatchEnable when enable button is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnBatchEnable = vi.fn()
|
||||
render(<BatchAction {...defaultProps} onBatchEnable={mockOnBatchEnable} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText(/batchAction\.enable/i))
|
||||
|
||||
// Assert
|
||||
expect(mockOnBatchEnable).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onBatchDisable when disable button is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnBatchDisable = vi.fn()
|
||||
render(<BatchAction {...defaultProps} onBatchDisable={mockOnBatchDisable} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText(/batchAction\.disable/i))
|
||||
|
||||
// Assert
|
||||
expect(mockOnBatchDisable).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel button is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnCancel = vi.fn()
|
||||
render(<BatchAction {...defaultProps} onCancel={mockOnCancel} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText(/batchAction\.cancel/i))
|
||||
|
||||
// Assert
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should show delete confirmation dialog when delete button is clicked', () => {
|
||||
// Arrange
|
||||
render(<BatchAction {...defaultProps} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText(/batchAction\.delete/i))
|
||||
|
||||
// Assert - Confirm dialog should appear
|
||||
expect(screen.getByText(/list\.delete\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onBatchDelete when confirm is clicked in delete dialog', async () => {
|
||||
// Arrange
|
||||
const mockOnBatchDelete = vi.fn().mockResolvedValue(undefined)
|
||||
render(<BatchAction {...defaultProps} onBatchDelete={mockOnBatchDelete} />)
|
||||
|
||||
// Act - open delete dialog
|
||||
fireEvent.click(screen.getByText(/batchAction\.delete/i))
|
||||
|
||||
// Act - click confirm
|
||||
const confirmButton = screen.getByText(/operation\.sure/i)
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockOnBatchDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Optional props tests
|
||||
describe('Optional Props', () => {
|
||||
it('should render download button when onBatchDownload is provided', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchAction {...defaultProps} onBatchDownload={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/batchAction\.download/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render download button when onBatchDownload is not provided', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchAction {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText(/batchAction\.download/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render archive button when onArchive is provided', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchAction {...defaultProps} onArchive={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/batchAction\.archive/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render metadata button when onEditMetadata is provided', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchAction {...defaultProps} onEditMetadata={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/metadata\.metadata/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render re-index button when onBatchReIndex is provided', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchAction {...defaultProps} onBatchReIndex={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/batchAction\.reIndex/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onBatchDownload when download button is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnBatchDownload = vi.fn()
|
||||
render(<BatchAction {...defaultProps} onBatchDownload={mockOnBatchDownload} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText(/batchAction\.download/i))
|
||||
|
||||
// Assert
|
||||
expect(mockOnBatchDownload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onArchive when archive button is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnArchive = vi.fn()
|
||||
render(<BatchAction {...defaultProps} onArchive={mockOnArchive} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText(/batchAction\.archive/i))
|
||||
|
||||
// Assert
|
||||
expect(mockOnArchive).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onEditMetadata when metadata button is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnEditMetadata = vi.fn()
|
||||
render(<BatchAction {...defaultProps} onEditMetadata={mockOnEditMetadata} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText(/metadata\.metadata/i))
|
||||
|
||||
// Assert
|
||||
expect(mockOnEditMetadata).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onBatchReIndex when re-index button is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnBatchReIndex = vi.fn()
|
||||
render(<BatchAction {...defaultProps} onBatchReIndex={mockOnBatchReIndex} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText(/batchAction\.reIndex/i))
|
||||
|
||||
// Assert
|
||||
expect(mockOnBatchReIndex).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<BatchAction {...defaultProps} className="custom-class" />)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
// Selected count display tests
|
||||
describe('Selected Count', () => {
|
||||
it('should display correct count for single selection', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchAction {...defaultProps} selectedIds={['1']} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display correct count for multiple selections', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchAction {...defaultProps} selectedIds={['1', '2', '3', '4', '5']} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should maintain structure when rerendered', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<BatchAction {...defaultProps} />)
|
||||
|
||||
// Act
|
||||
rerender(<BatchAction {...defaultProps} selectedIds={['1', '2']} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty selectedIds array', () => {
|
||||
// Arrange & Act
|
||||
render(<BatchAction {...defaultProps} selectedIds={[]} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,317 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
import ChunkContent from './chunk-content'
|
||||
|
||||
// Mock ResizeObserver
|
||||
const OriginalResizeObserver = globalThis.ResizeObserver
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = MockResizeObserver as typeof ResizeObserver
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.ResizeObserver = OriginalResizeObserver
|
||||
})
|
||||
|
||||
describe('ChunkContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
question: 'Test question content',
|
||||
onQuestionChange: vi.fn(),
|
||||
docForm: ChunkingMode.text,
|
||||
}
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<ChunkContent {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render textarea in edit mode with text docForm', () => {
|
||||
// Arrange & Act
|
||||
render(<ChunkContent {...defaultProps} isEditMode={true} />)
|
||||
|
||||
// Assert
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Markdown content in view mode with text docForm', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<ChunkContent {...defaultProps} isEditMode={false} />)
|
||||
|
||||
// Assert - In view mode, textarea should not be present, Markdown renders instead
|
||||
expect(container.querySelector('textarea')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// QA mode tests
|
||||
describe('QA Mode', () => {
|
||||
it('should render QA layout when docForm is qa', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkContent
|
||||
{...defaultProps}
|
||||
docForm={ChunkingMode.qa}
|
||||
answer="Test answer"
|
||||
onAnswerChange={vi.fn()}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - QA mode has QUESTION and ANSWER labels
|
||||
expect(screen.getByText('QUESTION')).toBeInTheDocument()
|
||||
expect(screen.getByText('ANSWER')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display question value in QA mode', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkContent
|
||||
{...defaultProps}
|
||||
docForm={ChunkingMode.qa}
|
||||
question="My question"
|
||||
answer="My answer"
|
||||
onAnswerChange={vi.fn()}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const textareas = screen.getAllByRole('textbox')
|
||||
expect(textareas[0]).toHaveValue('My question')
|
||||
})
|
||||
|
||||
it('should display answer value in QA mode', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkContent
|
||||
{...defaultProps}
|
||||
docForm={ChunkingMode.qa}
|
||||
question="My question"
|
||||
answer="My answer"
|
||||
onAnswerChange={vi.fn()}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const textareas = screen.getAllByRole('textbox')
|
||||
expect(textareas[1]).toHaveValue('My answer')
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call onQuestionChange when textarea value changes in text mode', () => {
|
||||
// Arrange
|
||||
const mockOnQuestionChange = vi.fn()
|
||||
render(
|
||||
<ChunkContent
|
||||
{...defaultProps}
|
||||
isEditMode={true}
|
||||
onQuestionChange={mockOnQuestionChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const textarea = screen.getByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: 'New content' } })
|
||||
|
||||
// Assert
|
||||
expect(mockOnQuestionChange).toHaveBeenCalledWith('New content')
|
||||
})
|
||||
|
||||
it('should call onQuestionChange when question textarea changes in QA mode', () => {
|
||||
// Arrange
|
||||
const mockOnQuestionChange = vi.fn()
|
||||
render(
|
||||
<ChunkContent
|
||||
{...defaultProps}
|
||||
docForm={ChunkingMode.qa}
|
||||
isEditMode={true}
|
||||
onQuestionChange={mockOnQuestionChange}
|
||||
onAnswerChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const textareas = screen.getAllByRole('textbox')
|
||||
fireEvent.change(textareas[0], { target: { value: 'New question' } })
|
||||
|
||||
// Assert
|
||||
expect(mockOnQuestionChange).toHaveBeenCalledWith('New question')
|
||||
})
|
||||
|
||||
it('should call onAnswerChange when answer textarea changes in QA mode', () => {
|
||||
// Arrange
|
||||
const mockOnAnswerChange = vi.fn()
|
||||
render(
|
||||
<ChunkContent
|
||||
{...defaultProps}
|
||||
docForm={ChunkingMode.qa}
|
||||
isEditMode={true}
|
||||
answer="Old answer"
|
||||
onAnswerChange={mockOnAnswerChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
const textareas = screen.getAllByRole('textbox')
|
||||
fireEvent.change(textareas[1], { target: { value: 'New answer' } })
|
||||
|
||||
// Assert
|
||||
expect(mockOnAnswerChange).toHaveBeenCalledWith('New answer')
|
||||
})
|
||||
|
||||
it('should disable textarea when isEditMode is false in text mode', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<ChunkContent {...defaultProps} isEditMode={false} />,
|
||||
)
|
||||
|
||||
// Assert - In view mode, Markdown is rendered instead of textarea
|
||||
expect(container.querySelector('textarea')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable textareas when isEditMode is false in QA mode', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkContent
|
||||
{...defaultProps}
|
||||
docForm={ChunkingMode.qa}
|
||||
isEditMode={false}
|
||||
answer="Answer"
|
||||
onAnswerChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const textareas = screen.getAllByRole('textbox')
|
||||
textareas.forEach((textarea) => {
|
||||
expect(textarea).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// DocForm variations
|
||||
describe('DocForm Variations', () => {
|
||||
it('should handle ChunkingMode.text', () => {
|
||||
// Arrange & Act
|
||||
render(<ChunkContent {...defaultProps} docForm={ChunkingMode.text} isEditMode={true} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle ChunkingMode.qa', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkContent
|
||||
{...defaultProps}
|
||||
docForm={ChunkingMode.qa}
|
||||
answer="answer"
|
||||
onAnswerChange={vi.fn()}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - QA mode should show both question and answer
|
||||
expect(screen.getByText('QUESTION')).toBeInTheDocument()
|
||||
expect(screen.getByText('ANSWER')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle ChunkingMode.parentChild similar to text mode', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkContent
|
||||
{...defaultProps}
|
||||
docForm={ChunkingMode.parentChild}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - parentChild should render like text mode
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty question', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkContent
|
||||
{...defaultProps}
|
||||
question=""
|
||||
isEditMode={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle empty answer in QA mode', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkContent
|
||||
{...defaultProps}
|
||||
docForm={ChunkingMode.qa}
|
||||
question="question"
|
||||
answer=""
|
||||
onAnswerChange={vi.fn()}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const textareas = screen.getAllByRole('textbox')
|
||||
expect(textareas[1]).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle undefined answer in QA mode', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<ChunkContent
|
||||
{...defaultProps}
|
||||
docForm={ChunkingMode.qa}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - should render without crashing
|
||||
expect(screen.getByText('QUESTION')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(
|
||||
<ChunkContent {...defaultProps} question="Initial" isEditMode={true} />,
|
||||
)
|
||||
|
||||
// Act
|
||||
rerender(
|
||||
<ChunkContent {...defaultProps} question="Updated" isEditMode={true} />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveValue('Updated')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,60 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Dot from './dot'
|
||||
|
||||
describe('Dot', () => {
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Dot />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the dot character', () => {
|
||||
// Arrange & Act
|
||||
render(<Dot />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with correct styling classes', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Dot />)
|
||||
|
||||
// Assert
|
||||
const dotElement = container.firstChild as HTMLElement
|
||||
expect(dotElement).toHaveClass('system-xs-medium')
|
||||
expect(dotElement).toHaveClass('text-text-quaternary')
|
||||
})
|
||||
})
|
||||
|
||||
// Memoization tests
|
||||
describe('Memoization', () => {
|
||||
it('should render consistently across multiple renders', () => {
|
||||
// Arrange & Act
|
||||
const { container: container1 } = render(<Dot />)
|
||||
const { container: container2 } = render(<Dot />)
|
||||
|
||||
// Assert
|
||||
expect(container1.firstChild?.textContent).toBe(container2.firstChild?.textContent)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should maintain structure when rerendered', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(<Dot />)
|
||||
|
||||
// Act
|
||||
rerender(<Dot />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('·')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,153 +1,129 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Empty from './empty'
|
||||
|
||||
describe('Empty', () => {
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
if (key === 'segment.empty')
|
||||
return 'No results found'
|
||||
if (key === 'segment.clearFilter')
|
||||
return 'Clear Filter'
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('Empty Component', () => {
|
||||
const defaultProps = {
|
||||
onClearFilter: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Empty onClearFilter={vi.fn()} />)
|
||||
it('should render empty state message', () => {
|
||||
render(<Empty {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the file list icon', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Empty onClearFilter={vi.fn()} />)
|
||||
|
||||
// Assert - RiFileList2Line icon should be rendered
|
||||
const icon = container.querySelector('.h-6.w-6')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty message text', () => {
|
||||
// Arrange & Act
|
||||
render(<Empty onClearFilter={vi.fn()} />)
|
||||
|
||||
// Assert - i18n key format: datasetDocuments:segment.empty
|
||||
expect(screen.getByText(/segment\.empty/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('No results found')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render clear filter button', () => {
|
||||
// Arrange & Act
|
||||
render(<Empty onClearFilter={vi.fn()} />)
|
||||
render(<Empty {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
expect(screen.getByText('Clear Filter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render background empty cards', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Empty onClearFilter={vi.fn()} />)
|
||||
it('should render icon', () => {
|
||||
const { container } = render(<Empty {...defaultProps} />)
|
||||
|
||||
// Assert - should have 10 background cards
|
||||
const emptyCards = container.querySelectorAll('.bg-background-section-burn')
|
||||
expect(emptyCards).toHaveLength(10)
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClearFilter when clear filter button is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnClearFilter = vi.fn()
|
||||
render(<Empty onClearFilter={mockOnClearFilter} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnClearFilter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Structure tests
|
||||
describe('Structure', () => {
|
||||
it('should render the decorative lines', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Empty onClearFilter={vi.fn()} />)
|
||||
|
||||
// Assert - there should be 4 Line components (SVG elements)
|
||||
const svgElements = container.querySelectorAll('svg')
|
||||
expect(svgElements.length).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
|
||||
it('should render mask overlay', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Empty onClearFilter={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg')
|
||||
expect(maskElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon container with proper styling', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Empty onClearFilter={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
// Check for the icon container
|
||||
const iconContainer = container.querySelector('.shadow-lg')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render clear filter button with accent text styling', () => {
|
||||
// Arrange & Act
|
||||
render(<Empty onClearFilter={vi.fn()} />)
|
||||
it('should render decorative lines', () => {
|
||||
const { container } = render(<Empty {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('text-text-accent')
|
||||
// Check for SVG lines
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render background cards', () => {
|
||||
const { container } = render(<Empty {...defaultProps} />)
|
||||
|
||||
// Check for background empty cards (10 of them)
|
||||
const backgroundCards = container.querySelectorAll('.rounded-xl.bg-background-section-burn')
|
||||
expect(backgroundCards.length).toBe(10)
|
||||
})
|
||||
|
||||
it('should render mask overlay', () => {
|
||||
const { container } = render(<Empty {...defaultProps} />)
|
||||
|
||||
const maskOverlay = container.querySelector('.bg-dataset-chunk-list-mask-bg')
|
||||
expect(maskOverlay).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests
|
||||
describe('Props', () => {
|
||||
it('should accept onClearFilter callback prop', () => {
|
||||
// Arrange
|
||||
const mockCallback = vi.fn()
|
||||
describe('Interactions', () => {
|
||||
it('should call onClearFilter when clear filter button is clicked', () => {
|
||||
const onClearFilter = vi.fn()
|
||||
|
||||
// Act
|
||||
render(<Empty onClearFilter={mockCallback} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
render(<Empty onClearFilter={onClearFilter} />)
|
||||
|
||||
// Assert
|
||||
expect(mockCallback).toHaveBeenCalled()
|
||||
const clearButton = screen.getByText('Clear Filter')
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(onClearFilter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple clicks on clear filter button', () => {
|
||||
// Arrange
|
||||
const mockOnClearFilter = vi.fn()
|
||||
render(<Empty onClearFilter={mockOnClearFilter} />)
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized', () => {
|
||||
// Empty is wrapped with React.memo
|
||||
const { rerender } = render(<Empty {...defaultProps} />)
|
||||
|
||||
// Act
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
// Same props should not cause re-render issues
|
||||
rerender(<Empty {...defaultProps} />)
|
||||
|
||||
// Assert
|
||||
expect(mockOnClearFilter).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
// Arrange
|
||||
const { rerender, container } = render(<Empty onClearFilter={vi.fn()} />)
|
||||
|
||||
// Act
|
||||
rerender(<Empty onClearFilter={vi.fn()} />)
|
||||
|
||||
// Assert
|
||||
const emptyCards = container.querySelectorAll('.bg-background-section-burn')
|
||||
expect(emptyCards).toHaveLength(10)
|
||||
expect(screen.getByText('No results found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EmptyCard Component', () => {
|
||||
it('should render within Empty component', () => {
|
||||
const { container } = render(<Empty onClearFilter={vi.fn()} />)
|
||||
|
||||
// EmptyCard renders as background cards
|
||||
const emptyCards = container.querySelectorAll('.h-32.w-full')
|
||||
expect(emptyCards.length).toBe(10)
|
||||
})
|
||||
|
||||
it('should have correct opacity', () => {
|
||||
const { container } = render(<Empty onClearFilter={vi.fn()} />)
|
||||
|
||||
const emptyCards = container.querySelectorAll('.opacity-30')
|
||||
expect(emptyCards.length).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Line Component', () => {
|
||||
it('should render SVG lines within Empty component', () => {
|
||||
const { container } = render(<Empty onClearFilter={vi.fn()} />)
|
||||
|
||||
// Line components render as SVG elements (4 Line components + 1 icon SVG)
|
||||
const lines = container.querySelectorAll('svg')
|
||||
expect(lines.length).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
|
||||
it('should have gradient definition', () => {
|
||||
const { container } = render(<Empty onClearFilter={vi.fn()} />)
|
||||
|
||||
const gradients = container.querySelectorAll('linearGradient')
|
||||
expect(gradients.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,262 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import FullScreenDrawer from './full-screen-drawer'
|
||||
|
||||
// Mock the Drawer component since it has high complexity
|
||||
vi.mock('./drawer', () => ({
|
||||
default: ({ children, open, panelClassName, panelContentClassName, showOverlay, needCheckChunks, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, showOverlay: boolean, needCheckChunks: boolean, modal: boolean }) => {
|
||||
if (!open)
|
||||
return null
|
||||
return (
|
||||
<div
|
||||
data-testid="drawer-mock"
|
||||
data-panel-class={panelClassName}
|
||||
data-panel-content-class={panelContentClassName}
|
||||
data-show-overlay={showOverlay}
|
||||
data-need-check-chunks={needCheckChunks}
|
||||
data-modal={modal}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('FullScreenDrawer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when open', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when closed', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<FullScreenDrawer isOpen={false} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children content', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Test Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Test Content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests
|
||||
describe('Props', () => {
|
||||
it('should pass fullScreen=true to Drawer with full width class', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={true}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-panel-class')).toContain('w-full')
|
||||
})
|
||||
|
||||
it('should pass fullScreen=false to Drawer with fixed width class', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-panel-class')).toContain('w-[568px]')
|
||||
})
|
||||
|
||||
it('should pass showOverlay prop with default true', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-show-overlay')).toBe('true')
|
||||
})
|
||||
|
||||
it('should pass showOverlay=false when specified', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false} showOverlay={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-show-overlay')).toBe('false')
|
||||
})
|
||||
|
||||
it('should pass needCheckChunks prop with default false', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-need-check-chunks')).toBe('false')
|
||||
})
|
||||
|
||||
it('should pass needCheckChunks=true when specified', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false} needCheckChunks={true}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-need-check-chunks')).toBe('true')
|
||||
})
|
||||
|
||||
it('should pass modal prop with default false', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-modal')).toBe('false')
|
||||
})
|
||||
|
||||
it('should pass modal=true when specified', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false} modal={true}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
expect(drawer.getAttribute('data-modal')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
// Styling tests
|
||||
describe('Styling', () => {
|
||||
it('should apply panel content classes for non-fullScreen mode', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
const contentClass = drawer.getAttribute('data-panel-content-class')
|
||||
expect(contentClass).toContain('bg-components-panel-bg')
|
||||
expect(contentClass).toContain('rounded-xl')
|
||||
})
|
||||
|
||||
it('should apply panel content classes without border for fullScreen mode', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={true}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const drawer = screen.getByTestId('drawer-mock')
|
||||
const contentClass = drawer.getAttribute('data-panel-content-class')
|
||||
expect(contentClass).toContain('bg-components-panel-bg')
|
||||
expect(contentClass).not.toContain('rounded-xl')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined onClose gracefully', () => {
|
||||
// Arrange & Act & Assert - should not throw
|
||||
expect(() => {
|
||||
render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Act
|
||||
rerender(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={true}>
|
||||
<div>Updated Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Updated Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle toggle between open and closed states', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(
|
||||
<FullScreenDrawer isOpen={true} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
|
||||
|
||||
// Act
|
||||
rerender(
|
||||
<FullScreenDrawer isOpen={false} fullScreen={false}>
|
||||
<div>Content</div>
|
||||
</FullScreenDrawer>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,317 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Keywords from './keywords'
|
||||
|
||||
describe('Keywords', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Keywords
|
||||
keywords={['test']}
|
||||
onKeywordsChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the keywords label', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<Keywords
|
||||
keywords={['test']}
|
||||
onKeywordsChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - i18n key format
|
||||
expect(screen.getByText(/segment\.keywords/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with correct container classes', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Keywords
|
||||
keywords={['test']}
|
||||
onKeywordsChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('flex')
|
||||
expect(wrapper).toHaveClass('flex-col')
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests
|
||||
describe('Props', () => {
|
||||
it('should display dash when no keywords and actionType is view', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<Keywords
|
||||
segInfo={{ id: '1', keywords: [] }}
|
||||
keywords={[]}
|
||||
onKeywordsChange={vi.fn()}
|
||||
actionType="view"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not display dash when actionType is edit', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<Keywords
|
||||
segInfo={{ id: '1', keywords: [] }}
|
||||
keywords={[]}
|
||||
onKeywordsChange={vi.fn()}
|
||||
actionType="edit"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('-')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not display dash when actionType is add', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<Keywords
|
||||
segInfo={{ id: '1', keywords: [] }}
|
||||
keywords={[]}
|
||||
onKeywordsChange={vi.fn()}
|
||||
actionType="add"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('-')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Keywords
|
||||
keywords={['test']}
|
||||
onKeywordsChange={vi.fn()}
|
||||
className="custom-class"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should use default actionType of view', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<Keywords
|
||||
segInfo={{ id: '1', keywords: [] }}
|
||||
keywords={[]}
|
||||
onKeywordsChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - dash should appear in view mode with empty keywords
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Structure tests
|
||||
describe('Structure', () => {
|
||||
it('should render label with uppercase styling', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Keywords
|
||||
keywords={['test']}
|
||||
onKeywordsChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const labelElement = container.querySelector('.system-xs-medium-uppercase')
|
||||
expect(labelElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render keywords container with overflow handling', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Keywords
|
||||
keywords={['test']}
|
||||
onKeywordsChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const keywordsContainer = container.querySelector('.overflow-auto')
|
||||
expect(keywordsContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render keywords container with max height', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Keywords
|
||||
keywords={['test']}
|
||||
onKeywordsChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const keywordsContainer = container.querySelector('.max-h-\\[200px\\]')
|
||||
expect(keywordsContainer).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edit mode tests
|
||||
describe('Edit Mode', () => {
|
||||
it('should render TagInput component when keywords exist', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Keywords
|
||||
segInfo={{ id: '1', keywords: ['keyword1', 'keyword2'] }}
|
||||
keywords={['keyword1', 'keyword2']}
|
||||
onKeywordsChange={vi.fn()}
|
||||
isEditMode={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - TagInput should be rendered instead of dash
|
||||
expect(screen.queryByText('-')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.flex-wrap')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty keywords array in view mode without segInfo keywords', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Keywords
|
||||
keywords={[]}
|
||||
onKeywordsChange={vi.fn()}
|
||||
actionType="view"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - container should be rendered
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
// Arrange
|
||||
const { rerender, container } = render(
|
||||
<Keywords
|
||||
segInfo={{ id: '1', keywords: ['test'] }}
|
||||
keywords={['test']}
|
||||
onKeywordsChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act
|
||||
rerender(
|
||||
<Keywords
|
||||
segInfo={{ id: '1', keywords: ['test', 'new'] }}
|
||||
keywords={['test', 'new']}
|
||||
onKeywordsChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle segInfo with undefined keywords showing dash in view mode', () => {
|
||||
// Arrange & Act
|
||||
render(
|
||||
<Keywords
|
||||
segInfo={{ id: '1' }}
|
||||
keywords={['test']}
|
||||
onKeywordsChange={vi.fn()}
|
||||
actionType="view"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - dash should show because segInfo.keywords is undefined/empty
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// TagInput callback tests
|
||||
describe('TagInput Callback', () => {
|
||||
it('should call onKeywordsChange when keywords are modified', () => {
|
||||
// Arrange
|
||||
const mockOnKeywordsChange = vi.fn()
|
||||
render(
|
||||
<Keywords
|
||||
segInfo={{ id: '1', keywords: ['existing'] }}
|
||||
keywords={['existing']}
|
||||
onKeywordsChange={mockOnKeywordsChange}
|
||||
isEditMode={true}
|
||||
actionType="edit"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - TagInput should be rendered
|
||||
expect(screen.queryByText('-')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable add when isEditMode is false', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Keywords
|
||||
segInfo={{ id: '1', keywords: ['test'] }}
|
||||
keywords={['test']}
|
||||
onKeywordsChange={vi.fn()}
|
||||
isEditMode={false}
|
||||
actionType="view"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - TagInput should exist but with disabled add
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable remove when only one keyword exists in edit mode', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Keywords
|
||||
segInfo={{ id: '1', keywords: ['only-one'] }}
|
||||
keywords={['only-one']}
|
||||
onKeywordsChange={vi.fn()}
|
||||
isEditMode={true}
|
||||
actionType="edit"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - component should render
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow remove when multiple keywords exist in edit mode', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<Keywords
|
||||
segInfo={{ id: '1', keywords: ['first', 'second'] }}
|
||||
keywords={['first', 'second']}
|
||||
onKeywordsChange={vi.fn()}
|
||||
isEditMode={true}
|
||||
actionType="edit"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert - component should render
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,327 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { EventEmitterContextProvider, useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import RegenerationModal from './regeneration-modal'
|
||||
|
||||
// Store emit function for triggering events in tests
|
||||
let emitFunction: ((v: string) => void) | null = null
|
||||
|
||||
const EmitCapture = () => {
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
emitFunction = eventEmitter?.emit?.bind(eventEmitter) || null
|
||||
return null
|
||||
}
|
||||
|
||||
// Custom wrapper that captures emit function
|
||||
const TestWrapper = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<EventEmitterContextProvider>
|
||||
<EmitCapture />
|
||||
{children}
|
||||
</EventEmitterContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Create a wrapper component with event emitter context
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<TestWrapper>
|
||||
{children}
|
||||
</TestWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
describe('RegenerationModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
isShow: true,
|
||||
onConfirm: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
}
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing when isShow is true', () => {
|
||||
// Arrange & Act
|
||||
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render content when isShow is false', () => {
|
||||
// Arrange & Act
|
||||
render(<RegenerationModal {...defaultProps} isShow={false} />, { wrapper: createWrapper() })
|
||||
|
||||
// Assert - Modal container might exist but content should not be visible
|
||||
expect(screen.queryByText(/segment\.regenerationConfirmTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render confirmation message', () => {
|
||||
// Arrange & Act
|
||||
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/segment\.regenerationConfirmMessage/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel button in default state', () => {
|
||||
// Arrange & Act
|
||||
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render regenerate button in default state', () => {
|
||||
// Arrange & Act
|
||||
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/operation\.regenerate/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User Interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should call onCancel when cancel button is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnCancel = vi.fn()
|
||||
render(<RegenerationModal {...defaultProps} onCancel={mockOnCancel} />, { wrapper: createWrapper() })
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/i))
|
||||
|
||||
// Assert
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onConfirm when regenerate button is clicked', () => {
|
||||
// Arrange
|
||||
const mockOnConfirm = vi.fn()
|
||||
render(<RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} />, { wrapper: createWrapper() })
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText(/operation\.regenerate/i))
|
||||
|
||||
// Assert
|
||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Modal content states - these would require event emitter manipulation
|
||||
describe('Modal States', () => {
|
||||
it('should show default content initially', () => {
|
||||
// Arrange & Act
|
||||
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle toggling isShow prop', () => {
|
||||
// Arrange
|
||||
const { rerender } = render(
|
||||
<RegenerationModal {...defaultProps} isShow={true} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
|
||||
|
||||
// Act
|
||||
rerender(
|
||||
<TestWrapper>
|
||||
<RegenerationModal {...defaultProps} isShow={false} />
|
||||
</TestWrapper>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText(/segment\.regenerationConfirmTitle/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain handlers when rerendered', () => {
|
||||
// Arrange
|
||||
const mockOnConfirm = vi.fn()
|
||||
const { rerender } = render(
|
||||
<RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} />,
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
// Act
|
||||
rerender(
|
||||
<TestWrapper>
|
||||
<RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} />
|
||||
</TestWrapper>,
|
||||
)
|
||||
fireEvent.click(screen.getByText(/operation\.regenerate/i))
|
||||
|
||||
// Assert
|
||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Loading state
|
||||
describe('Loading State', () => {
|
||||
it('should show regenerating content when update-segment event is emitted', async () => {
|
||||
// Arrange
|
||||
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
if (emitFunction)
|
||||
emitFunction('update-segment')
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/segment\.regeneratingTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show regenerating message during loading', async () => {
|
||||
// Arrange
|
||||
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
if (emitFunction)
|
||||
emitFunction('update-segment')
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/segment\.regeneratingMessage/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable regenerate button during loading', async () => {
|
||||
// Arrange
|
||||
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
if (emitFunction)
|
||||
emitFunction('update-segment')
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
const button = screen.getByText(/operation\.regenerate/i).closest('button')
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Success state
|
||||
describe('Success State', () => {
|
||||
it('should show success content when update-segment-success event is emitted followed by done', async () => {
|
||||
// Arrange
|
||||
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Act - trigger loading then success then done
|
||||
act(() => {
|
||||
if (emitFunction) {
|
||||
emitFunction('update-segment')
|
||||
emitFunction('update-segment-success')
|
||||
emitFunction('update-segment-done')
|
||||
}
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/segment\.regenerationSuccessTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show success message when completed', async () => {
|
||||
// Arrange
|
||||
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
if (emitFunction) {
|
||||
emitFunction('update-segment')
|
||||
emitFunction('update-segment-success')
|
||||
emitFunction('update-segment-done')
|
||||
}
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/segment\.regenerationSuccessMessage/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show close button with countdown in success state', async () => {
|
||||
// Arrange
|
||||
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
if (emitFunction) {
|
||||
emitFunction('update-segment')
|
||||
emitFunction('update-segment-success')
|
||||
emitFunction('update-segment-done')
|
||||
}
|
||||
})
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/operation\.close/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onClose when close button is clicked in success state', async () => {
|
||||
// Arrange
|
||||
const mockOnClose = vi.fn()
|
||||
render(<RegenerationModal {...defaultProps} onClose={mockOnClose} />, { wrapper: createWrapper() })
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
if (emitFunction) {
|
||||
emitFunction('update-segment')
|
||||
emitFunction('update-segment-success')
|
||||
emitFunction('update-segment-done')
|
||||
}
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/operation\.close/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.close/i))
|
||||
|
||||
// Assert
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// State transitions
|
||||
describe('State Transitions', () => {
|
||||
it('should return to default content when update fails (no success event)', async () => {
|
||||
// Arrange
|
||||
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Act - trigger loading then done without success
|
||||
act(() => {
|
||||
if (emitFunction) {
|
||||
emitFunction('update-segment')
|
||||
emitFunction('update-segment-done')
|
||||
}
|
||||
})
|
||||
|
||||
// Assert - should show default content
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,215 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import SegmentIndexTag from './segment-index-tag'
|
||||
|
||||
describe('SegmentIndexTag', () => {
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<SegmentIndexTag positionId={1} />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the Chunk icon', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<SegmentIndexTag positionId={1} />)
|
||||
|
||||
// Assert
|
||||
const icon = container.querySelector('.h-3.w-3')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with correct container classes', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<SegmentIndexTag positionId={1} />)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('flex')
|
||||
expect(wrapper).toHaveClass('items-center')
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests
|
||||
describe('Props', () => {
|
||||
it('should render position ID with default prefix', () => {
|
||||
// Arrange & Act
|
||||
render(<SegmentIndexTag positionId={5} />)
|
||||
|
||||
// Assert - default prefix is 'Chunk'
|
||||
expect(screen.getByText('Chunk-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render position ID without padding for two-digit numbers', () => {
|
||||
// Arrange & Act
|
||||
render(<SegmentIndexTag positionId={15} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Chunk-15')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render position ID without padding for three-digit numbers', () => {
|
||||
// Arrange & Act
|
||||
render(<SegmentIndexTag positionId={123} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Chunk-123')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom label when provided', () => {
|
||||
// Arrange & Act
|
||||
render(<SegmentIndexTag positionId={1} label="Custom Label" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Custom Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use custom labelPrefix', () => {
|
||||
// Arrange & Act
|
||||
render(<SegmentIndexTag positionId={3} labelPrefix="Segment" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Segment-03')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<SegmentIndexTag positionId={1} className="custom-class" />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should apply custom iconClassName', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<SegmentIndexTag positionId={1} iconClassName="custom-icon-class" />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const icon = container.querySelector('.custom-icon-class')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom labelClassName', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(
|
||||
<SegmentIndexTag positionId={1} labelClassName="custom-label-class" />,
|
||||
)
|
||||
|
||||
// Assert
|
||||
const label = container.querySelector('.custom-label-class')
|
||||
expect(label).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle string positionId', () => {
|
||||
// Arrange & Act
|
||||
render(<SegmentIndexTag positionId="7" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Chunk-07')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Memoization tests
|
||||
describe('Memoization', () => {
|
||||
it('should compute localPositionId based on positionId and labelPrefix', () => {
|
||||
// Arrange & Act
|
||||
const { rerender } = render(<SegmentIndexTag positionId={1} />)
|
||||
expect(screen.getByText('Chunk-01')).toBeInTheDocument()
|
||||
|
||||
// Act - change positionId
|
||||
rerender(<SegmentIndexTag positionId={2} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Chunk-02')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update when labelPrefix changes', () => {
|
||||
// Arrange & Act
|
||||
const { rerender } = render(<SegmentIndexTag positionId={1} labelPrefix="Chunk" />)
|
||||
expect(screen.getByText('Chunk-01')).toBeInTheDocument()
|
||||
|
||||
// Act - change labelPrefix
|
||||
rerender(<SegmentIndexTag positionId={1} labelPrefix="Part" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Part-01')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Structure tests
|
||||
describe('Structure', () => {
|
||||
it('should render icon with tertiary text color', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<SegmentIndexTag positionId={1} />)
|
||||
|
||||
// Assert
|
||||
const icon = container.querySelector('.text-text-tertiary')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render label with xs medium font styling', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<SegmentIndexTag positionId={1} />)
|
||||
|
||||
// Assert
|
||||
const label = container.querySelector('.system-xs-medium')
|
||||
expect(label).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon with margin-right spacing', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<SegmentIndexTag positionId={1} />)
|
||||
|
||||
// Assert
|
||||
const icon = container.querySelector('.mr-0\\.5')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle positionId of 0', () => {
|
||||
// Arrange & Act
|
||||
render(<SegmentIndexTag positionId={0} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Chunk-00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle undefined positionId', () => {
|
||||
// Arrange & Act
|
||||
render(<SegmentIndexTag />)
|
||||
|
||||
// Assert - should display 'Chunk-undefined' or similar
|
||||
expect(screen.getByText(/Chunk-/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prioritize label over computed positionId', () => {
|
||||
// Arrange & Act
|
||||
render(<SegmentIndexTag positionId={99} label="Override" />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Override')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Chunk-99')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
// Arrange
|
||||
const { rerender, container } = render(<SegmentIndexTag positionId={1} />)
|
||||
|
||||
// Act
|
||||
rerender(<SegmentIndexTag positionId={1} />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user