mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 09:55:09 +00:00
Compare commits
29 Commits
7b3b3dbe52
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84533cbfe0 | ||
|
|
0eaae4f573 | ||
|
|
9819f7d69c | ||
|
|
a040b9428d | ||
|
|
740d94c6ed | ||
|
|
657eeb65b8 | ||
|
|
f923901d3f | ||
|
|
a0ddaed6d3 | ||
|
|
2162cd1a69 | ||
|
|
0070891114 | ||
|
|
6e531fe44f | ||
|
|
80f49367eb | ||
|
|
7c60ad01d3 | ||
|
|
57890eed25 | ||
|
|
737575d637 | ||
|
|
f76ee7cfa4 | ||
|
|
a0244d1390 | ||
|
|
42af9d5438 | ||
|
|
4c48e3b997 | ||
|
|
46f0cebbb0 | ||
|
|
2d54192f35 | ||
|
|
80a5398dea | ||
|
|
ab64c4adf9 | ||
|
|
ce8354a42a | ||
|
|
d0bb642fc5 | ||
|
|
e4ddf07194 | ||
|
|
aad980f267 | ||
|
|
8141e3af99 | ||
|
|
b108de6607 |
@@ -10,7 +10,7 @@ import services
|
||||
from controllers.common.fields import Parameters as ParametersResponse
|
||||
from controllers.common.fields import Site as SiteResponse
|
||||
from controllers.common.schema import get_or_create_model
|
||||
from controllers.console import api, console_ns
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import (
|
||||
AppUnavailableError,
|
||||
AudioTooLargeError,
|
||||
@@ -469,7 +469,7 @@ class TrialSitApi(Resource):
|
||||
"""Resource for trial app sites."""
|
||||
|
||||
@trial_feature_enable
|
||||
@get_app_model_with_trial
|
||||
@get_app_model_with_trial(None)
|
||||
def get(self, app_model):
|
||||
"""Retrieve app site info.
|
||||
|
||||
@@ -491,7 +491,7 @@ class TrialAppParameterApi(Resource):
|
||||
"""Resource for app variables."""
|
||||
|
||||
@trial_feature_enable
|
||||
@get_app_model_with_trial
|
||||
@get_app_model_with_trial(None)
|
||||
def get(self, app_model):
|
||||
"""Retrieve app parameters."""
|
||||
|
||||
@@ -520,7 +520,7 @@ class TrialAppParameterApi(Resource):
|
||||
|
||||
class AppApi(Resource):
|
||||
@trial_feature_enable
|
||||
@get_app_model_with_trial
|
||||
@get_app_model_with_trial(None)
|
||||
@marshal_with(app_detail_with_site_model)
|
||||
def get(self, app_model):
|
||||
"""Get app detail"""
|
||||
@@ -533,7 +533,7 @@ class AppApi(Resource):
|
||||
|
||||
class AppWorkflowApi(Resource):
|
||||
@trial_feature_enable
|
||||
@get_app_model_with_trial
|
||||
@get_app_model_with_trial(None)
|
||||
@marshal_with(workflow_model)
|
||||
def get(self, app_model):
|
||||
"""Get workflow detail"""
|
||||
@@ -552,7 +552,7 @@ class AppWorkflowApi(Resource):
|
||||
|
||||
class DatasetListApi(Resource):
|
||||
@trial_feature_enable
|
||||
@get_app_model_with_trial
|
||||
@get_app_model_with_trial(None)
|
||||
def get(self, app_model):
|
||||
page = request.args.get("page", default=1, type=int)
|
||||
limit = request.args.get("limit", default=20, type=int)
|
||||
@@ -570,27 +570,31 @@ class DatasetListApi(Resource):
|
||||
return response
|
||||
|
||||
|
||||
api.add_resource(TrialChatApi, "/trial-apps/<uuid:app_id>/chat-messages", endpoint="trial_app_chat_completion")
|
||||
console_ns.add_resource(TrialChatApi, "/trial-apps/<uuid:app_id>/chat-messages", endpoint="trial_app_chat_completion")
|
||||
|
||||
api.add_resource(
|
||||
console_ns.add_resource(
|
||||
TrialMessageSuggestedQuestionApi,
|
||||
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
|
||||
endpoint="trial_app_suggested_question",
|
||||
)
|
||||
|
||||
api.add_resource(TrialChatAudioApi, "/trial-apps/<uuid:app_id>/audio-to-text", endpoint="trial_app_audio")
|
||||
api.add_resource(TrialChatTextApi, "/trial-apps/<uuid:app_id>/text-to-audio", endpoint="trial_app_text")
|
||||
console_ns.add_resource(TrialChatAudioApi, "/trial-apps/<uuid:app_id>/audio-to-text", endpoint="trial_app_audio")
|
||||
console_ns.add_resource(TrialChatTextApi, "/trial-apps/<uuid:app_id>/text-to-audio", endpoint="trial_app_text")
|
||||
|
||||
api.add_resource(TrialCompletionApi, "/trial-apps/<uuid:app_id>/completion-messages", endpoint="trial_app_completion")
|
||||
console_ns.add_resource(
|
||||
TrialCompletionApi, "/trial-apps/<uuid:app_id>/completion-messages", endpoint="trial_app_completion"
|
||||
)
|
||||
|
||||
api.add_resource(TrialSitApi, "/trial-apps/<uuid:app_id>/site")
|
||||
console_ns.add_resource(TrialSitApi, "/trial-apps/<uuid:app_id>/site")
|
||||
|
||||
api.add_resource(TrialAppParameterApi, "/trial-apps/<uuid:app_id>/parameters", endpoint="trial_app_parameters")
|
||||
console_ns.add_resource(TrialAppParameterApi, "/trial-apps/<uuid:app_id>/parameters", endpoint="trial_app_parameters")
|
||||
|
||||
api.add_resource(AppApi, "/trial-apps/<uuid:app_id>", endpoint="trial_app")
|
||||
console_ns.add_resource(AppApi, "/trial-apps/<uuid:app_id>", endpoint="trial_app")
|
||||
|
||||
api.add_resource(TrialAppWorkflowRunApi, "/trial-apps/<uuid:app_id>/workflows/run", endpoint="trial_app_workflow_run")
|
||||
api.add_resource(TrialAppWorkflowTaskStopApi, "/trial-apps/<uuid:app_id>/workflows/tasks/<string:task_id>/stop")
|
||||
console_ns.add_resource(
|
||||
TrialAppWorkflowRunApi, "/trial-apps/<uuid:app_id>/workflows/run", endpoint="trial_app_workflow_run"
|
||||
)
|
||||
console_ns.add_resource(TrialAppWorkflowTaskStopApi, "/trial-apps/<uuid:app_id>/workflows/tasks/<string:task_id>/stop")
|
||||
|
||||
api.add_resource(AppWorkflowApi, "/trial-apps/<uuid:app_id>/workflows", endpoint="trial_app_workflow")
|
||||
api.add_resource(DatasetListApi, "/trial-apps/<uuid:app_id>/datasets", endpoint="trial_app_datasets")
|
||||
console_ns.add_resource(AppWorkflowApi, "/trial-apps/<uuid:app_id>/workflows", endpoint="trial_app_workflow")
|
||||
console_ns.add_resource(DatasetListApi, "/trial-apps/<uuid:app_id>/datasets", endpoint="trial_app_datasets")
|
||||
|
||||
@@ -105,9 +105,9 @@ def trial_app_required(view: Callable[Concatenate[App, P], R] | None = None):
|
||||
return decorator
|
||||
|
||||
|
||||
def trial_feature_enable(view: Callable[..., R]) -> Callable[..., R]:
|
||||
def trial_feature_enable(view: Callable[P, R]):
|
||||
@wraps(view)
|
||||
def decorated(*args, **kwargs):
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
features = FeatureService.get_system_features()
|
||||
if not features.enable_trial_app:
|
||||
abort(403, "Trial app feature is not enabled.")
|
||||
@@ -116,9 +116,9 @@ def trial_feature_enable(view: Callable[..., R]) -> Callable[..., R]:
|
||||
return decorated
|
||||
|
||||
|
||||
def explore_banner_enabled(view: Callable[..., R]) -> Callable[..., R]:
|
||||
def explore_banner_enabled(view: Callable[P, R]):
|
||||
@wraps(view)
|
||||
def decorated(*args, **kwargs):
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
features = FeatureService.get_system_features()
|
||||
if not features.enable_explore_banner:
|
||||
abort(403, "Explore banner feature is not enabled.")
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from abc import abstractmethod
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import IntEnum, auto
|
||||
from typing import Any
|
||||
|
||||
@@ -31,7 +31,7 @@ class PublishFrom(IntEnum):
|
||||
TASK_PIPELINE = auto()
|
||||
|
||||
|
||||
class AppQueueManager:
|
||||
class AppQueueManager(ABC):
|
||||
def __init__(self, task_id: str, user_id: str, invoke_from: InvokeFrom):
|
||||
if not user_id:
|
||||
raise ValueError("user is required")
|
||||
@@ -133,7 +133,7 @@ class AppQueueManager:
|
||||
self._publish(event, pub_from)
|
||||
|
||||
@abstractmethod
|
||||
def _publish(self, event: AppQueueEvent, pub_from: PublishFrom):
|
||||
def _publish(self, event: AppQueueEvent, pub_from: PublishFrom) -> None:
|
||||
"""
|
||||
Publish event to queue
|
||||
:param event:
|
||||
|
||||
@@ -39,7 +39,7 @@ class Moderation(Extensible, ABC):
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def validate_config(cls, tenant_id: str, config: dict):
|
||||
def validate_config(cls, tenant_id: str, config: dict) -> None:
|
||||
"""
|
||||
Validate the incoming form config data.
|
||||
|
||||
|
||||
@@ -129,11 +129,11 @@ class LangfuseSpan(BaseModel):
|
||||
default=None,
|
||||
description="The id of the user that triggered the execution. Used to provide user-level analytics.",
|
||||
)
|
||||
start_time: datetime | str | None = Field(
|
||||
start_time: datetime | None = Field(
|
||||
default_factory=datetime.now,
|
||||
description="The time at which the span started, defaults to the current time.",
|
||||
)
|
||||
end_time: datetime | str | None = Field(
|
||||
end_time: datetime | None = Field(
|
||||
default=None,
|
||||
description="The time at which the span ended. Automatically set by span.end().",
|
||||
)
|
||||
@@ -146,7 +146,7 @@ class LangfuseSpan(BaseModel):
|
||||
description="Additional metadata of the span. Can be any JSON object. Metadata is merged when being updated "
|
||||
"via the API.",
|
||||
)
|
||||
level: str | None = Field(
|
||||
level: LevelEnum | None = Field(
|
||||
default=None,
|
||||
description="The level of the span. Can be DEBUG, DEFAULT, WARNING or ERROR. Used for sorting/filtering of "
|
||||
"traces with elevated error levels and for highlighting in the UI.",
|
||||
@@ -222,16 +222,16 @@ class LangfuseGeneration(BaseModel):
|
||||
default=None,
|
||||
description="Identifier of the generation. Useful for sorting/filtering in the UI.",
|
||||
)
|
||||
start_time: datetime | str | None = Field(
|
||||
start_time: datetime | None = Field(
|
||||
default_factory=datetime.now,
|
||||
description="The time at which the generation started, defaults to the current time.",
|
||||
)
|
||||
completion_start_time: datetime | str | None = Field(
|
||||
completion_start_time: datetime | None = Field(
|
||||
default=None,
|
||||
description="The time at which the completion started (streaming). Set it to get latency analytics broken "
|
||||
"down into time until completion started and completion duration.",
|
||||
)
|
||||
end_time: datetime | str | None = Field(
|
||||
end_time: datetime | None = Field(
|
||||
default=None,
|
||||
description="The time at which the generation ended. Automatically set by generation.end().",
|
||||
)
|
||||
|
||||
@@ -18,8 +18,7 @@ except ImportError:
|
||||
from importlib_metadata import version # type: ignore[import-not-found]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.metrics import Meter
|
||||
from opentelemetry.metrics._internal.instrument import Histogram
|
||||
from opentelemetry.metrics import Histogram, Meter
|
||||
from opentelemetry.sdk.metrics.export import MetricReader
|
||||
|
||||
from opentelemetry import trace as trace_api
|
||||
|
||||
@@ -15,7 +15,7 @@ class BaseVector(ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs):
|
||||
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs) -> list[str] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -2,7 +2,7 @@ import re
|
||||
from json import dumps as json_dumps
|
||||
from json import loads as json_loads
|
||||
from json.decoder import JSONDecodeError
|
||||
from typing import Any
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import httpx
|
||||
from flask import request
|
||||
@@ -14,6 +14,12 @@ from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolParamet
|
||||
from core.tools.errors import ToolApiSchemaError, ToolNotSupportedError, ToolProviderNotFoundError
|
||||
|
||||
|
||||
class _OpenAPIInterface(TypedDict):
|
||||
path: str
|
||||
method: str
|
||||
operation: dict[str, Any]
|
||||
|
||||
|
||||
class ApiBasedToolSchemaParser:
|
||||
@staticmethod
|
||||
def parse_openapi_to_tool_bundle(
|
||||
@@ -35,17 +41,17 @@ class ApiBasedToolSchemaParser:
|
||||
server_url = matched_servers[0] if matched_servers else server_url
|
||||
|
||||
# list all interfaces
|
||||
interfaces = []
|
||||
interfaces: list[_OpenAPIInterface] = []
|
||||
for path, path_item in openapi["paths"].items():
|
||||
methods = ["get", "post", "put", "delete", "patch", "head", "options", "trace"]
|
||||
for method in methods:
|
||||
if method in path_item:
|
||||
interfaces.append(
|
||||
{
|
||||
"path": path,
|
||||
"method": method,
|
||||
"operation": path_item[method],
|
||||
}
|
||||
_OpenAPIInterface(
|
||||
path=path,
|
||||
method=method,
|
||||
operation=path_item[method],
|
||||
)
|
||||
)
|
||||
|
||||
# get all parameters
|
||||
|
||||
@@ -65,16 +65,16 @@ dependencies = [
|
||||
"psycogreen~=1.0.2",
|
||||
"psycopg2-binary~=2.9.6",
|
||||
"pycryptodome==3.23.0",
|
||||
"pydantic~=2.11.4",
|
||||
"pydantic~=2.12.5",
|
||||
"pydantic-extra-types~=2.10.3",
|
||||
"pydantic-settings~=2.12.0",
|
||||
"pyjwt~=2.10.1",
|
||||
"pypdfium2==5.2.0",
|
||||
"python-docx~=1.1.0",
|
||||
"python-docx~=1.2.0",
|
||||
"python-dotenv==1.0.1",
|
||||
"pyyaml~=6.0.1",
|
||||
"readabilipy~=0.3.0",
|
||||
"redis[hiredis]~=6.1.0",
|
||||
"redis[hiredis]~=7.2.0",
|
||||
"resend~=2.9.0",
|
||||
"sentry-sdk[flask]~=2.28.0",
|
||||
"sqlalchemy~=2.0.29",
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Integration tests for Dataset and Document model properties using testcontainers.
|
||||
|
||||
These tests validate database-backed model properties (total_documents, word_count, etc.)
|
||||
without mocking SQLAlchemy queries, ensuring real query behavior against PostgreSQL.
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.dataset import Dataset, Document, DocumentSegment
|
||||
|
||||
|
||||
class TestDatasetDocumentProperties:
|
||||
"""Integration tests for Dataset and Document model properties."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _auto_rollback(self, db_session_with_containers: Session) -> Generator[None, None, None]:
|
||||
"""Automatically rollback session changes after each test."""
|
||||
yield
|
||||
db_session_with_containers.rollback()
|
||||
|
||||
def test_dataset_with_documents_relationship(self, db_session_with_containers: Session) -> None:
|
||||
"""Test dataset can track its documents."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i in range(3):
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=i + 1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name=f"doc_{i}.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.total_documents == 3
|
||||
|
||||
def test_dataset_available_documents_count(self, db_session_with_containers: Session) -> None:
|
||||
"""Test dataset can count available documents."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc_available = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="available.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
indexing_status="completed",
|
||||
enabled=True,
|
||||
archived=False,
|
||||
)
|
||||
doc_pending = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=2,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="pending.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
indexing_status="waiting",
|
||||
enabled=True,
|
||||
archived=False,
|
||||
)
|
||||
doc_disabled = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=3,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="disabled.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
indexing_status="completed",
|
||||
enabled=False,
|
||||
archived=False,
|
||||
)
|
||||
db_session_with_containers.add_all([doc_available, doc_pending, doc_disabled])
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.total_available_documents == 1
|
||||
|
||||
def test_dataset_word_count_aggregation(self, db_session_with_containers: Session) -> None:
|
||||
"""Test dataset can aggregate word count from documents."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i, wc in enumerate([2000, 3000]):
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=i + 1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name=f"doc_{i}.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
word_count=wc,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.word_count == 5000
|
||||
|
||||
def test_dataset_available_segment_count(self, db_session_with_containers: Session) -> None:
|
||||
"""Test Dataset.available_segment_count counts completed and enabled segments."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="doc.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i in range(2):
|
||||
seg = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=i + 1,
|
||||
content=f"segment {i}",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
status="completed",
|
||||
enabled=True,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg)
|
||||
|
||||
seg_waiting = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=3,
|
||||
content="waiting segment",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
status="waiting",
|
||||
enabled=True,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg_waiting)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.available_segment_count == 2
|
||||
|
||||
def test_document_segment_count_property(self, db_session_with_containers: Session) -> None:
|
||||
"""Test document can count its segments."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="doc.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i in range(3):
|
||||
seg = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=i + 1,
|
||||
content=f"segment {i}",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert doc.segment_count == 3
|
||||
|
||||
def test_document_hit_count_aggregation(self, db_session_with_containers: Session) -> None:
|
||||
"""Test document can aggregate hit count from segments."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="doc.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i, hits in enumerate([10, 15]):
|
||||
seg = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=i + 1,
|
||||
content=f"segment {i}",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
hit_count=hits,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert doc.hit_count == 25
|
||||
@@ -12,7 +12,7 @@ This test suite covers:
|
||||
import json
|
||||
import pickle
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from models.dataset import (
|
||||
@@ -954,156 +954,6 @@ class TestChildChunk:
|
||||
assert child_chunk.index_node_hash == index_node_hash
|
||||
|
||||
|
||||
class TestDatasetDocumentCascadeDeletes:
|
||||
"""Test suite for Dataset-Document cascade delete operations."""
|
||||
|
||||
def test_dataset_with_documents_relationship(self):
|
||||
"""Test dataset can track its documents."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid4())
|
||||
dataset = Dataset(
|
||||
tenant_id=str(uuid4()),
|
||||
name="Test Dataset",
|
||||
data_source_type="upload_file",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
dataset.id = dataset_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.where.return_value.scalar.return_value = 3
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
total_docs = dataset.total_documents
|
||||
|
||||
# Assert
|
||||
assert total_docs == 3
|
||||
|
||||
def test_dataset_available_documents_count(self):
|
||||
"""Test dataset can count available documents."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid4())
|
||||
dataset = Dataset(
|
||||
tenant_id=str(uuid4()),
|
||||
name="Test Dataset",
|
||||
data_source_type="upload_file",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
dataset.id = dataset_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.where.return_value.scalar.return_value = 2
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
available_docs = dataset.total_available_documents
|
||||
|
||||
# Assert
|
||||
assert available_docs == 2
|
||||
|
||||
def test_dataset_word_count_aggregation(self):
|
||||
"""Test dataset can aggregate word count from documents."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid4())
|
||||
dataset = Dataset(
|
||||
tenant_id=str(uuid4()),
|
||||
name="Test Dataset",
|
||||
data_source_type="upload_file",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
dataset.id = dataset_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.with_entities.return_value.where.return_value.scalar.return_value = 5000
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
total_words = dataset.word_count
|
||||
|
||||
# Assert
|
||||
assert total_words == 5000
|
||||
|
||||
def test_dataset_available_segment_count(self):
|
||||
"""Test dataset can count available segments."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid4())
|
||||
dataset = Dataset(
|
||||
tenant_id=str(uuid4()),
|
||||
name="Test Dataset",
|
||||
data_source_type="upload_file",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
dataset.id = dataset_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.where.return_value.scalar.return_value = 15
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
segment_count = dataset.available_segment_count
|
||||
|
||||
# Assert
|
||||
assert segment_count == 15
|
||||
|
||||
def test_document_segment_count_property(self):
|
||||
"""Test document can count its segments."""
|
||||
# Arrange
|
||||
document_id = str(uuid4())
|
||||
document = Document(
|
||||
tenant_id=str(uuid4()),
|
||||
dataset_id=str(uuid4()),
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="test.pdf",
|
||||
created_from="web",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
document.id = document_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.where.return_value.count.return_value = 10
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
segment_count = document.segment_count
|
||||
|
||||
# Assert
|
||||
assert segment_count == 10
|
||||
|
||||
def test_document_hit_count_aggregation(self):
|
||||
"""Test document can aggregate hit count from segments."""
|
||||
# Arrange
|
||||
document_id = str(uuid4())
|
||||
document = Document(
|
||||
tenant_id=str(uuid4()),
|
||||
dataset_id=str(uuid4()),
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="test.pdf",
|
||||
created_from="web",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
document.id = document_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.with_entities.return_value.where.return_value.scalar.return_value = 25
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
hit_count = document.hit_count
|
||||
|
||||
# Assert
|
||||
assert hit_count == 25
|
||||
|
||||
|
||||
class TestDocumentSegmentNavigation:
|
||||
"""Test suite for DocumentSegment navigation properties."""
|
||||
|
||||
|
||||
109
api/uv.lock
generated
109
api/uv.lock
generated
@@ -1633,16 +1633,16 @@ requires-dist = [
|
||||
{ name = "psycogreen", specifier = "~=1.0.2" },
|
||||
{ name = "psycopg2-binary", specifier = "~=2.9.6" },
|
||||
{ name = "pycryptodome", specifier = "==3.23.0" },
|
||||
{ name = "pydantic", specifier = "~=2.11.4" },
|
||||
{ name = "pydantic", specifier = "~=2.12.5" },
|
||||
{ name = "pydantic-extra-types", specifier = "~=2.10.3" },
|
||||
{ name = "pydantic-settings", specifier = "~=2.12.0" },
|
||||
{ name = "pyjwt", specifier = "~=2.10.1" },
|
||||
{ name = "pypdfium2", specifier = "==5.2.0" },
|
||||
{ name = "python-docx", specifier = "~=1.1.0" },
|
||||
{ name = "python-docx", specifier = "~=1.2.0" },
|
||||
{ name = "python-dotenv", specifier = "==1.0.1" },
|
||||
{ name = "pyyaml", specifier = "~=6.0.1" },
|
||||
{ name = "readabilipy", specifier = "~=0.3.0" },
|
||||
{ name = "redis", extras = ["hiredis"], specifier = "~=6.1.0" },
|
||||
{ name = "redis", extras = ["hiredis"], specifier = "~=7.2.0" },
|
||||
{ name = "resend", specifier = "~=2.9.0" },
|
||||
{ name = "sendgrid", specifier = "~=6.12.3" },
|
||||
{ name = "sentry-sdk", extras = ["flask"], specifier = "~=2.28.0" },
|
||||
@@ -4854,7 +4854,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.10"
|
||||
version = "2.12.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
@@ -4862,57 +4862,64 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/54/ecab642b3bed45f7d5f59b38443dcb36ef50f85af192e6ece103dbfe9587/pydantic-2.11.10.tar.gz", hash = "sha256:dc280f0982fbda6c38fada4e476dc0a4f3aeaf9c6ad4c28df68a666ec3c61423", size = 788494, upload-time = "2025-10-04T10:40:41.338Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/1f/73c53fcbfb0b5a78f91176df41945ca466e71e9d9d836e5c522abda39ee7/pydantic-2.11.10-py3-none-any.whl", hash = "sha256:802a655709d49bd004c31e865ef37da30b540786a46bfce02333e0e24b5fe29a", size = 444823, upload-time = "2025-10-04T10:40:39.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
version = "2.41.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5251,15 +5258,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-docx"
|
||||
version = "1.1.2"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "lxml" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/35/e4/386c514c53684772885009c12b67a7edd526c15157778ac1b138bc75063e/python_docx-1.1.2.tar.gz", hash = "sha256:0cf1f22e95b9002addca7948e16f2cd7acdfd498047f1941ca5d293db7762efd", size = 5656581, upload-time = "2024-05-01T19:41:57.772Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/3d/330d9efbdb816d3f60bf2ad92f05e1708e4a1b9abe80461ac3444c83f749/python_docx-1.1.2-py3-none-any.whl", hash = "sha256:08c20d6058916fb19853fcf080f7f42b6270d89eac9fa5f8c15f691c0017fabe", size = 244315, upload-time = "2024-05-01T19:41:47.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5469,14 +5476,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "6.1.1"
|
||||
version = "7.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/8b/14ef373ffe71c0d2fde93c204eab78472ea13c021d9aee63b0e11bd65896/redis-6.1.1.tar.gz", hash = "sha256:88c689325b5b41cedcbdbdfd4d937ea86cf6dab2222a83e86d8a466e4b3d2600", size = 4629515, upload-time = "2025-06-02T11:44:04.137Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/cd/29503c609186104c363ef1f38d6e752e7d91ef387fc90aa165e96d69f446/redis-6.1.1-py3-none-any.whl", hash = "sha256:ed44d53d065bbe04ac6d76864e331cfe5c5353f86f6deccc095f8794fd15bb2e", size = 273930, upload-time = "2025-06-02T11:44:02.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
|
||||
import type { IConfigVarProps } from './index'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
@@ -237,7 +237,8 @@ describe('ConfigVar', () => {
|
||||
expect(actionButtons).toHaveLength(2)
|
||||
fireEvent.click(actionButtons[0])
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'common.operation.save' })
|
||||
const editDialog = await screen.findByRole('dialog')
|
||||
const saveButton = within(editDialog).getByRole('button', { name: 'common.operation.save' })
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -323,14 +323,8 @@ describe('CustomizeModal', () => {
|
||||
expect(screen.getByText('appOverview.overview.appInfo.customize.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find the close button by navigating from the heading to the close icon
|
||||
// The close icon is an SVG inside a sibling div of the title
|
||||
const heading = screen.getByRole('heading', { name: /customize\.title/i })
|
||||
const closeIcon = heading.parentElement!.querySelector('svg')
|
||||
|
||||
// Assert - closeIcon must exist for the test to be valid
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
fireEvent.click(closeIcon!)
|
||||
const closeButton = screen.getByTestId('modal-close-button')
|
||||
fireEvent.click(closeButton)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
96
web/app/components/base/alert.spec.tsx
Normal file
96
web/app/components/base/alert.spec.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Alert from './alert'
|
||||
|
||||
describe('Alert', () => {
|
||||
const defaultProps = {
|
||||
message: 'This is an alert message',
|
||||
onHide: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Alert {...defaultProps} />)
|
||||
expect(screen.getByText(defaultProps.message)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the info icon', () => {
|
||||
render(<Alert {...defaultProps} />)
|
||||
const icon = screen.getByTestId('info-icon')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the close icon', () => {
|
||||
render(<Alert {...defaultProps} />)
|
||||
const closeIcon = screen.getByTestId('close-icon')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<Alert {...defaultProps} className="my-custom-class" />)
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv).toHaveClass('my-custom-class')
|
||||
})
|
||||
|
||||
it('should retain base classes when custom className is applied', () => {
|
||||
const { container } = render(<Alert {...defaultProps} className="my-custom-class" />)
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv).toHaveClass('pointer-events-none', 'w-full')
|
||||
})
|
||||
|
||||
it('should default type to info', () => {
|
||||
render(<Alert {...defaultProps} />)
|
||||
const gradientDiv = screen.getByTestId('alert-gradient')
|
||||
expect(gradientDiv).toHaveClass('from-components-badge-status-light-normal-halo')
|
||||
})
|
||||
|
||||
it('should render with explicit type info', () => {
|
||||
render(<Alert {...defaultProps} type="info" />)
|
||||
const gradientDiv = screen.getByTestId('alert-gradient')
|
||||
expect(gradientDiv).toHaveClass('from-components-badge-status-light-normal-halo')
|
||||
})
|
||||
|
||||
it('should display the provided message text', () => {
|
||||
const msg = 'A different alert message'
|
||||
render(<Alert {...defaultProps} message={msg} />)
|
||||
expect(screen.getByText(msg)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onHide when close button is clicked', () => {
|
||||
const onHide = vi.fn()
|
||||
render(<Alert {...defaultProps} onHide={onHide} />)
|
||||
const closeButton = screen.getByTestId('close-icon')
|
||||
fireEvent.click(closeButton)
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onHide when other parts of the alert are clicked', () => {
|
||||
const onHide = vi.fn()
|
||||
render(<Alert {...defaultProps} onHide={onHide} />)
|
||||
fireEvent.click(screen.getByText(defaultProps.message))
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with an empty message string', () => {
|
||||
render(<Alert {...defaultProps} message="" />)
|
||||
const messageDiv = screen.getByTestId('msg-container')
|
||||
expect(messageDiv).toBeInTheDocument()
|
||||
expect(messageDiv).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should render with a very long message', () => {
|
||||
const longMessage = 'A'.repeat(1000)
|
||||
render(<Alert {...defaultProps} message={longMessage} />)
|
||||
expect(screen.getByText(longMessage)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiInformation2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import {
|
||||
memo,
|
||||
@@ -35,13 +31,13 @@ const Alert: React.FC<Props> = ({
|
||||
<div
|
||||
className="relative flex space-x-1 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg"
|
||||
>
|
||||
<div className={cn('pointer-events-none absolute inset-0 bg-gradient-to-r opacity-[0.4]', bgVariants({ type }))}>
|
||||
<div className={cn('pointer-events-none absolute inset-0 bg-gradient-to-r opacity-[0.4]', bgVariants({ type }))} data-testid="alert-gradient">
|
||||
</div>
|
||||
<div className="flex h-6 w-6 items-center justify-center">
|
||||
<RiInformation2Fill className="text-text-accent" />
|
||||
<span className="i-ri-information-2-fill text-text-accent" data-testid="info-icon" />
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
<div className="text-text-secondary system-xs-regular" data-testid="msg-container">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,7 +45,7 @@ const Alert: React.FC<Props> = ({
|
||||
className="pointer-events-auto flex h-6 w-6 cursor-pointer items-center justify-center"
|
||||
onClick={onHide}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" data-testid="close-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
82
web/app/components/base/app-unavailable.spec.tsx
Normal file
82
web/app/components/base/app-unavailable.spec.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import AppUnavailable from './app-unavailable'
|
||||
|
||||
describe('AppUnavailable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<AppUnavailable />)
|
||||
expect(screen.getByText(/404/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the error code in a heading', () => {
|
||||
render(<AppUnavailable />)
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toHaveTextContent(/404/)
|
||||
})
|
||||
|
||||
it('should render the default unavailable message', () => {
|
||||
render(<AppUnavailable />)
|
||||
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display custom error code', () => {
|
||||
render(<AppUnavailable code={500} />)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('500')
|
||||
})
|
||||
|
||||
it('should accept string error code', () => {
|
||||
render(<AppUnavailable code="403" />)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('403')
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<AppUnavailable className="my-custom" />)
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv).toHaveClass('my-custom')
|
||||
})
|
||||
|
||||
it('should retain base classes when custom className is applied', () => {
|
||||
const { container } = render(<AppUnavailable className="my-custom" />)
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv).toHaveClass('flex', 'h-screen', 'w-screen', 'items-center', 'justify-center')
|
||||
})
|
||||
|
||||
it('should display unknownReason when provided', () => {
|
||||
render(<AppUnavailable unknownReason="Custom error occurred" />)
|
||||
expect(screen.getByText(/Custom error occurred/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display unknown error translation when isUnknownReason is true', () => {
|
||||
render(<AppUnavailable isUnknownReason />)
|
||||
expect(screen.getByText(/share.common.appUnknownError/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prioritize unknownReason over isUnknownReason', () => {
|
||||
render(<AppUnavailable isUnknownReason unknownReason="My custom reason" />)
|
||||
expect(screen.getByText(/My custom reason/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show appUnavailable translation when isUnknownReason is false', () => {
|
||||
render(<AppUnavailable isUnknownReason={false} />)
|
||||
expect(screen.getByText(/share.common.appUnavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with code 0', () => {
|
||||
render(<AppUnavailable code={0} />)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('0')
|
||||
})
|
||||
|
||||
it('should render with an empty unknownReason and fall back to translation', () => {
|
||||
render(<AppUnavailable unknownReason="" />)
|
||||
expect(screen.getByText(/share.common.appUnavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
49
web/app/components/base/audio-gallery/index.spec.tsx
Normal file
49
web/app/components/base/audio-gallery/index.spec.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
// AudioGallery.spec.tsx
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import AudioGallery from './index'
|
||||
|
||||
// Mock AudioPlayer so we only assert prop forwarding
|
||||
const audioPlayerMock = vi.fn()
|
||||
|
||||
vi.mock('./AudioPlayer', () => ({
|
||||
default: (props: { srcs: string[] }) => {
|
||||
audioPlayerMock(props)
|
||||
return <div data-testid="audio-player" />
|
||||
},
|
||||
}))
|
||||
|
||||
describe('AudioGallery', () => {
|
||||
afterEach(() => {
|
||||
audioPlayerMock.mockClear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('returns null when srcs array is empty', () => {
|
||||
const { container } = render(<AudioGallery srcs={[]} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(screen.queryByTestId('audio-player')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when all srcs are falsy', () => {
|
||||
const { container } = render(<AudioGallery srcs={['', '', '']} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(screen.queryByTestId('audio-player')).toBeNull()
|
||||
})
|
||||
|
||||
it('filters out falsy srcs and passes valid srcs to AudioPlayer', () => {
|
||||
render(<AudioGallery srcs={['a.mp3', '', 'b.mp3']} />)
|
||||
expect(screen.getByTestId('audio-player')).toBeInTheDocument()
|
||||
expect(audioPlayerMock).toHaveBeenCalledTimes(1)
|
||||
expect(audioPlayerMock).toHaveBeenCalledWith({ srcs: ['a.mp3', 'b.mp3'] })
|
||||
})
|
||||
|
||||
it('wraps AudioPlayer inside container with expected class', () => {
|
||||
const { container } = render(<AudioGallery srcs={['a.mp3']} />)
|
||||
const root = container.firstChild as HTMLElement
|
||||
expect(root).toBeTruthy()
|
||||
expect(root.className).toContain('my-3')
|
||||
})
|
||||
})
|
||||
201
web/app/components/base/auto-height-textarea/index.spec.tsx
Normal file
201
web/app/components/base/auto-height-textarea/index.spec.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { sleep } from '@/utils'
|
||||
import AutoHeightTextarea from './index'
|
||||
|
||||
vi.mock('@/utils', async () => {
|
||||
const actual = await vi.importActual('@/utils')
|
||||
return {
|
||||
...actual,
|
||||
sleep: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('AutoHeightTextarea', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} />)
|
||||
const textarea = container.querySelector('textarea')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with placeholder when value is empty', () => {
|
||||
render(<AutoHeightTextarea placeholder="Enter text" value="" onChange={vi.fn()} />)
|
||||
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with value', () => {
|
||||
render(<AutoHeightTextarea value="Hello World" onChange={vi.fn()} />)
|
||||
const textarea = screen.getByDisplayValue('Hello World')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className to textarea', () => {
|
||||
const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} className="custom-class" />)
|
||||
const textarea = container.querySelector('textarea')
|
||||
expect(textarea).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should apply custom wrapperClassName to wrapper div', () => {
|
||||
const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} wrapperClassName="wrapper-class" />)
|
||||
const wrapper = container.querySelector('div.relative')
|
||||
expect(wrapper).toHaveClass('wrapper-class')
|
||||
})
|
||||
|
||||
it('should apply minHeight and maxHeight styles to hidden div', () => {
|
||||
const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} minHeight={50} maxHeight={200} />)
|
||||
const hiddenDiv = container.querySelector('div.invisible')
|
||||
expect(hiddenDiv).toHaveStyle({ minHeight: '50px', maxHeight: '200px' })
|
||||
})
|
||||
|
||||
it('should use default minHeight and maxHeight when not provided', () => {
|
||||
const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} />)
|
||||
const hiddenDiv = container.querySelector('div.invisible')
|
||||
expect(hiddenDiv).toHaveStyle({ minHeight: '36px', maxHeight: '96px' })
|
||||
})
|
||||
|
||||
it('should set autoFocus on textarea', () => {
|
||||
const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus')
|
||||
render(<AutoHeightTextarea value="" onChange={vi.fn()} autoFocus />)
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
focusSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange when textarea value changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<AutoHeightTextarea value="" onChange={handleChange} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(textarea, { target: { value: 'new value' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onKeyDown when key is pressed', () => {
|
||||
const handleKeyDown = vi.fn()
|
||||
render(<AutoHeightTextarea value="" onChange={vi.fn()} onKeyDown={handleKeyDown} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
|
||||
expect(handleKeyDown).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onKeyUp when key is released', () => {
|
||||
const handleKeyUp = vi.fn()
|
||||
render(<AutoHeightTextarea value="" onChange={vi.fn()} onKeyUp={handleKeyUp} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.keyUp(textarea, { key: 'Enter' })
|
||||
|
||||
expect(handleKeyUp).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty string value', () => {
|
||||
render(<AutoHeightTextarea value="" onChange={vi.fn()} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle whitespace-only value', () => {
|
||||
render(<AutoHeightTextarea value=" " onChange={vi.fn()} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveValue(' ')
|
||||
})
|
||||
|
||||
it('should handle very long text (>10000 chars)', () => {
|
||||
const longText = 'a'.repeat(10001)
|
||||
render(<AutoHeightTextarea value={longText} onChange={vi.fn()} />)
|
||||
const textarea = screen.getByDisplayValue(longText)
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle newlines in value', () => {
|
||||
const textWithNewlines = 'line1\nline2\nline3'
|
||||
render(<AutoHeightTextarea value={textWithNewlines} onChange={vi.fn()} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveValue(textWithNewlines)
|
||||
})
|
||||
|
||||
it('should handle special characters in value', () => {
|
||||
const specialChars = '!@#$%^&*()_+-=[]{}|;:,.<>?'
|
||||
render(<AutoHeightTextarea value={specialChars} onChange={vi.fn()} />)
|
||||
const textarea = screen.getByDisplayValue(specialChars)
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Ref forwarding', () => {
|
||||
it('should accept ref and allow focusing', () => {
|
||||
const ref = { current: null as HTMLTextAreaElement | null }
|
||||
render(<AutoHeightTextarea ref={ref as React.RefObject<HTMLTextAreaElement>} value="" onChange={vi.fn()} />)
|
||||
|
||||
expect(ref.current).not.toBeNull()
|
||||
expect(ref.current?.tagName).toBe('TEXTAREA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('controlFocus prop', () => {
|
||||
it('should call focus when controlFocus changes', () => {
|
||||
const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus')
|
||||
const { rerender } = render(<AutoHeightTextarea value="" onChange={vi.fn()} controlFocus={1} />)
|
||||
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<AutoHeightTextarea value="" onChange={vi.fn()} controlFocus={2} />)
|
||||
|
||||
expect(focusSpy).toHaveBeenCalledTimes(2)
|
||||
focusSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should retry focus recursively when ref is not ready during autoFocus', async () => {
|
||||
const delayedRef = {} as React.RefObject<HTMLTextAreaElement>
|
||||
let assignedNode: HTMLTextAreaElement | null = null
|
||||
let exposedNode: HTMLTextAreaElement | null = null
|
||||
|
||||
Object.defineProperty(delayedRef, 'current', {
|
||||
get: () => exposedNode,
|
||||
set: (value: HTMLTextAreaElement | null) => {
|
||||
assignedNode = value
|
||||
},
|
||||
})
|
||||
|
||||
const sleepMock = vi.mocked(sleep)
|
||||
let sleepCalls = 0
|
||||
sleepMock.mockImplementation(async () => {
|
||||
sleepCalls += 1
|
||||
if (sleepCalls === 2)
|
||||
exposedNode = assignedNode
|
||||
})
|
||||
|
||||
const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus')
|
||||
const setSelectionRangeSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'setSelectionRange')
|
||||
|
||||
render(<AutoHeightTextarea ref={delayedRef} value="" onChange={vi.fn()} autoFocus />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sleepMock).toHaveBeenCalledTimes(2)
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
expect(setSelectionRangeSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
focusSpy.mockRestore()
|
||||
setSelectionRangeSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('displayName', () => {
|
||||
it('should have displayName set', () => {
|
||||
expect(AutoHeightTextarea.displayName).toBe('AutoHeightTextarea')
|
||||
})
|
||||
})
|
||||
})
|
||||
86
web/app/components/base/badge.spec.tsx
Normal file
86
web/app/components/base/badge.spec.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Badge from './badge'
|
||||
|
||||
describe('Badge', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Badge text="beta" />)
|
||||
expect(screen.getByText(/beta/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with children instead of text', () => {
|
||||
render(<Badge><span>child content</span></Badge>)
|
||||
expect(screen.getByText(/child content/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with no text or children', () => {
|
||||
const { container } = render(<Badge />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveTextContent('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<Badge text="test" className="my-custom" />)
|
||||
const badge = container.firstChild as HTMLElement
|
||||
expect(badge).toHaveClass('my-custom')
|
||||
})
|
||||
|
||||
it('should retain base classes when custom className is applied', () => {
|
||||
const { container } = render(<Badge text="test" className="my-custom" />)
|
||||
const badge = container.firstChild as HTMLElement
|
||||
expect(badge).toHaveClass('relative', 'inline-flex', 'h-5', 'items-center')
|
||||
})
|
||||
|
||||
it('should apply uppercase class by default', () => {
|
||||
const { container } = render(<Badge text="test" />)
|
||||
const badge = container.firstChild as HTMLElement
|
||||
expect(badge).toHaveClass('system-2xs-medium-uppercase')
|
||||
})
|
||||
|
||||
it('should apply non-uppercase class when uppercase is false', () => {
|
||||
const { container } = render(<Badge text="test" uppercase={false} />)
|
||||
const badge = container.firstChild as HTMLElement
|
||||
expect(badge).toHaveClass('system-xs-medium')
|
||||
expect(badge).not.toHaveClass('system-2xs-medium-uppercase')
|
||||
})
|
||||
|
||||
it('should render red corner mark when hasRedCornerMark is true', () => {
|
||||
const { container } = render(<Badge text="test" hasRedCornerMark />)
|
||||
const mark = container.querySelector('.bg-components-badge-status-light-error-bg')
|
||||
expect(mark).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render red corner mark by default', () => {
|
||||
const { container } = render(<Badge text="test" />)
|
||||
const mark = container.querySelector('.bg-components-badge-status-light-error-bg')
|
||||
expect(mark).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prioritize children over text', () => {
|
||||
render(<Badge text="text content"><span>child wins</span></Badge>)
|
||||
expect(screen.getByText(/child wins/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/text content/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ReactNode as text prop', () => {
|
||||
render(<Badge text={<strong>bold badge</strong>} />)
|
||||
expect(screen.getByText(/bold badge/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with empty string text', () => {
|
||||
const { container } = render(<Badge text="" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should render with hasRedCornerMark false explicitly', () => {
|
||||
const { container } = render(<Badge text="test" hasRedCornerMark={false} />)
|
||||
const mark = container.querySelector('.bg-components-badge-status-light-error-bg')
|
||||
expect(mark).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
226
web/app/components/base/block-input/index.spec.tsx
Normal file
226
web/app/components/base/block-input/index.spec.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import BlockInput, { getInputKeys } from './index'
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
checkKeys: vi.fn((_keys: string[]) => ({
|
||||
isValid: true,
|
||||
errorMessageKey: '',
|
||||
errorKey: '',
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('BlockInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(Toast, 'notify')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<BlockInput value="" />)
|
||||
const wrapper = screen.getByTestId('block-input')
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with initial value', () => {
|
||||
const { container } = render(<BlockInput value="Hello World" />)
|
||||
expect(container.textContent).toContain('Hello World')
|
||||
})
|
||||
|
||||
it('should render variable highlights', () => {
|
||||
render(<BlockInput value="Hello {{name}}" />)
|
||||
const nameElement = screen.getByText('name')
|
||||
expect(nameElement).toBeInTheDocument()
|
||||
expect(nameElement.parentElement).toHaveClass('text-primary-600')
|
||||
})
|
||||
|
||||
it('should render multiple variable highlights', () => {
|
||||
render(<BlockInput value="{{foo}} and {{bar}}" />)
|
||||
expect(screen.getByText('foo')).toBeInTheDocument()
|
||||
expect(screen.getByText('bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display character count in footer when not readonly', () => {
|
||||
render(<BlockInput value="Hello" />)
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide footer in readonly mode', () => {
|
||||
render(<BlockInput value="Hello" readonly />)
|
||||
expect(screen.queryByText('5')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<BlockInput value="test" className="custom-class" />)
|
||||
const innerContent = screen.getByTestId('block-input-content')
|
||||
expect(innerContent).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should apply readonly prop with max height', () => {
|
||||
render(<BlockInput value="test" readonly />)
|
||||
const contentDiv = screen.getByTestId('block-input').firstChild as Element
|
||||
expect(contentDiv).toHaveClass('max-h-[180px]')
|
||||
})
|
||||
|
||||
it('should have default empty value', () => {
|
||||
render(<BlockInput value="" />)
|
||||
const contentDiv = screen.getByTestId('block-input')
|
||||
expect(contentDiv).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should enter edit mode when clicked', async () => {
|
||||
render(<BlockInput value="Hello" />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should update value when typing in edit mode', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { checkKeys } = await import('@/utils/var')
|
||||
; (checkKeys as ReturnType<typeof vi.fn>).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' })
|
||||
|
||||
render(<BlockInput value="Hello" onConfirm={onConfirm} />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: 'Hello World' } })
|
||||
|
||||
expect(textarea).toHaveValue('Hello World')
|
||||
})
|
||||
|
||||
it('should call onConfirm on value change with valid keys', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { checkKeys } = await import('@/utils/var')
|
||||
; (checkKeys as ReturnType<typeof vi.fn>).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' })
|
||||
|
||||
render(<BlockInput value="initial" onConfirm={onConfirm} />)
|
||||
|
||||
const contentArea = screen.getByText('initial')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '{{name}}' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalledWith('{{name}}', ['name'])
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast on value change with invalid keys', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { checkKeys } = await import('@/utils/var');
|
||||
(checkKeys as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
isValid: false,
|
||||
errorMessageKey: 'invalidKey',
|
||||
errorKey: 'test_key',
|
||||
})
|
||||
|
||||
render(<BlockInput value="initial" onConfirm={onConfirm} />)
|
||||
|
||||
const contentArea = screen.getByText('initial')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '{{invalid}}' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalled()
|
||||
})
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not enter edit mode when readonly is true', () => {
|
||||
render(<BlockInput value="Hello" readonly />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty string value', () => {
|
||||
const { container } = render(<BlockInput value="" />)
|
||||
expect(container.textContent).toBe('0')
|
||||
const span = screen.getByTestId('block-input').querySelector('span')
|
||||
expect(span).toBeInTheDocument()
|
||||
expect(span).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should handle value without variables', () => {
|
||||
render(<BlockInput value="plain text" />)
|
||||
expect(screen.getByText('plain text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle newlines in value', () => {
|
||||
render(<BlockInput value="line1\nline2" />)
|
||||
expect(screen.getByText(/line1/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple same variables', () => {
|
||||
render(<BlockInput value="{{name}} and {{name}}" />)
|
||||
const highlights = screen.getAllByText('name')
|
||||
expect(highlights).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle value with only variables', () => {
|
||||
render(<BlockInput value="{{foo}}{{bar}}{{baz}}" />)
|
||||
expect(screen.getByText('foo')).toBeInTheDocument()
|
||||
expect(screen.getByText('bar')).toBeInTheDocument()
|
||||
expect(screen.getByText('baz')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle text adjacent to variables', () => {
|
||||
render(<BlockInput value="prefix {{var}} suffix" />)
|
||||
expect(screen.getByText(/prefix/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/suffix/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputKeys', () => {
|
||||
it('should extract keys from {{}} syntax', () => {
|
||||
const keys = getInputKeys('Hello {{name}}')
|
||||
expect(keys).toEqual(['name'])
|
||||
})
|
||||
|
||||
it('should extract multiple keys', () => {
|
||||
const keys = getInputKeys('{{foo}} and {{bar}}')
|
||||
expect(keys).toEqual(['foo', 'bar'])
|
||||
})
|
||||
|
||||
it('should remove duplicate keys', () => {
|
||||
const keys = getInputKeys('{{name}} and {{name}}')
|
||||
expect(keys).toEqual(['name'])
|
||||
})
|
||||
|
||||
it('should return empty array for no variables', () => {
|
||||
const keys = getInputKeys('plain text')
|
||||
expect(keys).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array for empty string', () => {
|
||||
const keys = getInputKeys('')
|
||||
expect(keys).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle keys with underscores and numbers', () => {
|
||||
const keys = getInputKeys('{{user_1}} and {{user_2}}')
|
||||
expect(keys).toEqual(['user_1', 'user_2'])
|
||||
})
|
||||
})
|
||||
@@ -63,7 +63,7 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
}, [isEditing])
|
||||
|
||||
const style = cn({
|
||||
'block px-4 py-2 w-full h-full text-sm text-gray-900 outline-0 border-0 break-all': true,
|
||||
'block h-full w-full break-all border-0 px-4 py-2 text-sm text-gray-900 outline-0': true,
|
||||
'block-input--editing': isEditing,
|
||||
})
|
||||
|
||||
@@ -111,7 +111,7 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
// Prevent rerendering caused cursor to jump to the start of the contentEditable element
|
||||
const TextAreaContentView = () => {
|
||||
return (
|
||||
<div className={cn(style, className)}>
|
||||
<div className={cn(style, className)} data-testid="block-input-content">
|
||||
{renderSafeContent(currentValue || '')}
|
||||
</div>
|
||||
)
|
||||
@@ -121,7 +121,7 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
const editAreaClassName = 'focus:outline-none bg-transparent text-sm'
|
||||
|
||||
const textAreaContent = (
|
||||
<div className={cn(readonly ? 'max-h-[180px] pb-5' : 'h-[180px]', ' overflow-y-auto')} onClick={() => !readonly && setIsEditing(true)}>
|
||||
<div className={cn(readonly ? 'max-h-[180px] pb-5' : 'h-[180px]', 'overflow-y-auto')} onClick={() => !readonly && setIsEditing(true)}>
|
||||
{isEditing
|
||||
? (
|
||||
<div className="h-full px-4 py-2">
|
||||
@@ -134,10 +134,10 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
onBlur={() => {
|
||||
blur()
|
||||
setIsEditing(false)
|
||||
// click confirm also make blur. Then outer value is change. So below code has problem.
|
||||
// setTimeout(() => {
|
||||
// handleCancel()
|
||||
// }, 1000)
|
||||
// click confirm also make blur. Then outer value is change. So below code has problem.
|
||||
// setTimeout(() => {
|
||||
// handleCancel()
|
||||
// }, 1000)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -147,7 +147,7 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('block-input w-full overflow-y-auto rounded-xl border-none bg-white')}>
|
||||
<div className={cn('block-input w-full overflow-y-auto rounded-xl border-none bg-white')} data-testid="block-input">
|
||||
{textAreaContent}
|
||||
{/* footer */}
|
||||
{!readonly && (
|
||||
|
||||
49
web/app/components/base/button/add-button.spec.tsx
Normal file
49
web/app/components/base/button/add-button.spec.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AddButton from './add-button'
|
||||
|
||||
describe('AddButton', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<AddButton onClick={vi.fn()} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render an add icon', () => {
|
||||
render(<AddButton onClick={vi.fn()} />)
|
||||
const iconSpan = screen.getByTestId('add-button').querySelector('span')
|
||||
expect(iconSpan).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<AddButton onClick={vi.fn()} className="my-custom" />)
|
||||
expect(container.firstChild).toHaveClass('my-custom')
|
||||
})
|
||||
|
||||
it('should retain base classes when custom className is applied', () => {
|
||||
const { container } = render(<AddButton onClick={vi.fn()} className="my-custom" />)
|
||||
expect(container.firstChild).toHaveClass('cursor-pointer')
|
||||
expect(container.firstChild).toHaveClass('rounded-md')
|
||||
expect(container.firstChild).toHaveClass('select-none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(<AddButton onClick={onClick} />)
|
||||
fireEvent.click(container.firstChild!)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClick multiple times on repeated clicks', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(<AddButton onClick={onClick} />)
|
||||
fireEvent.click(container.firstChild!)
|
||||
fireEvent.click(container.firstChild!)
|
||||
fireEvent.click(container.firstChild!)
|
||||
expect(onClick).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
@@ -14,8 +13,8 @@ const AddButton: FC<Props> = ({
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick}>
|
||||
<RiAddLine className="h-4 w-4 text-text-tertiary" />
|
||||
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick} data-testid="add-button">
|
||||
<span className="i-ri-add-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
56
web/app/components/base/button/sync-button.spec.tsx
Normal file
56
web/app/components/base/button/sync-button.spec.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SyncButton from './sync-button'
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useBoolean: () => [false, { setTrue: vi.fn(), setFalse: vi.fn() }],
|
||||
}))
|
||||
|
||||
describe('SyncButton', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<SyncButton onClick={vi.fn()} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a refresh icon', () => {
|
||||
render(<SyncButton onClick={vi.fn()} />)
|
||||
const iconSpan = screen.getByTestId('sync-button').querySelector('span')
|
||||
expect(iconSpan).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<SyncButton onClick={vi.fn()} className="my-custom" />)
|
||||
const clickableDiv = screen.getByTestId('sync-button')
|
||||
expect(clickableDiv).toHaveClass('my-custom')
|
||||
})
|
||||
|
||||
it('should retain base classes when custom className is applied', () => {
|
||||
render(<SyncButton onClick={vi.fn()} className="my-custom" />)
|
||||
const clickableDiv = screen.getByTestId('sync-button')
|
||||
expect(clickableDiv).toHaveClass('rounded-md')
|
||||
expect(clickableDiv).toHaveClass('select-none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<SyncButton onClick={onClick} />)
|
||||
const clickableDiv = screen.getByTestId('sync-button')
|
||||
fireEvent.click(clickableDiv)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClick multiple times on repeated clicks', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<SyncButton onClick={onClick} />)
|
||||
const clickableDiv = screen.getByTestId('sync-button')
|
||||
fireEvent.click(clickableDiv)
|
||||
fireEvent.click(clickableDiv)
|
||||
fireEvent.click(clickableDiv)
|
||||
expect(onClick).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiRefreshLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import TooltipPlus from '@/app/components/base/tooltip'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@@ -18,8 +17,8 @@ const SyncButton: FC<Props> = ({
|
||||
}) => {
|
||||
return (
|
||||
<TooltipPlus popupContent={popupContent}>
|
||||
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick}>
|
||||
<RiRefreshLine className="h-4 w-4 text-text-tertiary" />
|
||||
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick} data-testid="sync-button">
|
||||
<span className="i-ri-refresh-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</TooltipPlus>
|
||||
)
|
||||
|
||||
231
web/app/components/base/carousel/index.spec.tsx
Normal file
231
web/app/components/base/carousel/index.spec.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import useEmblaCarousel from 'embla-carousel-react'
|
||||
import { Carousel, useCarousel } from './index'
|
||||
|
||||
vi.mock('embla-carousel-react', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
type EmblaEventName = 'reInit' | 'select'
|
||||
type EmblaListener = (api: MockEmblaApi | undefined) => void
|
||||
|
||||
type MockEmblaApi = {
|
||||
scrollPrev: Mock
|
||||
scrollNext: Mock
|
||||
scrollTo: Mock
|
||||
selectedScrollSnap: Mock
|
||||
canScrollPrev: Mock
|
||||
canScrollNext: Mock
|
||||
slideNodes: Mock
|
||||
on: Mock
|
||||
off: Mock
|
||||
}
|
||||
|
||||
let mockCanScrollPrev = false
|
||||
let mockCanScrollNext = false
|
||||
let mockSelectedIndex = 0
|
||||
let mockSlideCount = 3
|
||||
let listeners: Record<EmblaEventName, EmblaListener[]>
|
||||
let mockApi: MockEmblaApi
|
||||
const mockCarouselRef = vi.fn()
|
||||
|
||||
const mockedUseEmblaCarousel = vi.mocked(useEmblaCarousel)
|
||||
|
||||
const createMockEmblaApi = (): MockEmblaApi => ({
|
||||
scrollPrev: vi.fn(),
|
||||
scrollNext: vi.fn(),
|
||||
scrollTo: vi.fn(),
|
||||
selectedScrollSnap: vi.fn(() => mockSelectedIndex),
|
||||
canScrollPrev: vi.fn(() => mockCanScrollPrev),
|
||||
canScrollNext: vi.fn(() => mockCanScrollNext),
|
||||
slideNodes: vi.fn(() =>
|
||||
Array.from({ length: mockSlideCount }, () => document.createElement('div')),
|
||||
),
|
||||
on: vi.fn((event: EmblaEventName, callback: EmblaListener) => {
|
||||
listeners[event].push(callback)
|
||||
}),
|
||||
off: vi.fn((event: EmblaEventName, callback: EmblaListener) => {
|
||||
listeners[event] = listeners[event].filter(listener => listener !== callback)
|
||||
}),
|
||||
})
|
||||
|
||||
const emitEmblaEvent = (event: EmblaEventName, api: MockEmblaApi | undefined = mockApi) => {
|
||||
listeners[event].forEach((callback) => {
|
||||
callback(api)
|
||||
})
|
||||
}
|
||||
|
||||
const renderCarouselWithControls = (orientation: 'horizontal' | 'vertical' = 'horizontal') => {
|
||||
return render(
|
||||
<Carousel orientation={orientation}>
|
||||
<Carousel.Content data-testid="carousel-content">
|
||||
<Carousel.Item>Slide 1</Carousel.Item>
|
||||
<Carousel.Item>Slide 2</Carousel.Item>
|
||||
<Carousel.Item>Slide 3</Carousel.Item>
|
||||
</Carousel.Content>
|
||||
<Carousel.Previous>Prev</Carousel.Previous>
|
||||
<Carousel.Next>Next</Carousel.Next>
|
||||
<Carousel.Dot>Dot</Carousel.Dot>
|
||||
</Carousel>,
|
||||
)
|
||||
}
|
||||
|
||||
const mockPlugin = () => ({
|
||||
name: 'mock',
|
||||
options: {},
|
||||
init: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
})
|
||||
|
||||
describe('Carousel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanScrollPrev = false
|
||||
mockCanScrollNext = false
|
||||
mockSelectedIndex = 0
|
||||
mockSlideCount = 3
|
||||
listeners = { reInit: [], select: [] }
|
||||
mockApi = createMockEmblaApi()
|
||||
|
||||
mockedUseEmblaCarousel.mockReturnValue(
|
||||
[mockCarouselRef, mockApi] as unknown as ReturnType<typeof useEmblaCarousel>,
|
||||
)
|
||||
})
|
||||
|
||||
// Rendering and basic semantic structure.
|
||||
describe('Rendering', () => {
|
||||
it('should render region and slides when used with content and items', () => {
|
||||
renderCarouselWithControls()
|
||||
|
||||
expect(screen.getByRole('region')).toHaveAttribute('aria-roledescription', 'carousel')
|
||||
expect(screen.getByTestId('carousel-content')).toHaveClass('flex')
|
||||
screen.getAllByRole('group').forEach((slide) => {
|
||||
expect(slide).toHaveAttribute('aria-roledescription', 'slide')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Props should be translated into Embla options and visible layout.
|
||||
describe('Props', () => {
|
||||
it('should configure embla with horizontal axis when orientation is omitted', () => {
|
||||
const plugin = mockPlugin()
|
||||
render(
|
||||
<Carousel opts={{ loop: true }} plugins={[plugin]}>
|
||||
<Carousel.Content />
|
||||
</Carousel>,
|
||||
)
|
||||
|
||||
expect(mockedUseEmblaCarousel).toHaveBeenCalledWith(
|
||||
{ loop: true, axis: 'x' },
|
||||
[plugin],
|
||||
)
|
||||
})
|
||||
|
||||
it('should configure embla with vertical axis and vertical content classes when orientation is vertical', () => {
|
||||
renderCarouselWithControls('vertical')
|
||||
|
||||
expect(mockedUseEmblaCarousel).toHaveBeenCalledWith(
|
||||
{ axis: 'y' },
|
||||
undefined,
|
||||
)
|
||||
expect(screen.getByTestId('carousel-content')).toHaveClass('flex-col')
|
||||
})
|
||||
})
|
||||
|
||||
// Users can move slides through previous and next controls.
|
||||
describe('User interactions', () => {
|
||||
it('should call scroll handlers when previous and next buttons are clicked', () => {
|
||||
mockCanScrollPrev = true
|
||||
mockCanScrollNext = true
|
||||
|
||||
renderCarouselWithControls()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Prev' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }))
|
||||
|
||||
expect(mockApi.scrollPrev).toHaveBeenCalledTimes(1)
|
||||
expect(mockApi.scrollNext).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call scrollTo with clicked index when a dot is clicked', () => {
|
||||
renderCarouselWithControls()
|
||||
const dots = screen.getAllByRole('button', { name: 'Dot' })
|
||||
|
||||
fireEvent.click(dots[2])
|
||||
|
||||
expect(mockApi.scrollTo).toHaveBeenCalledWith(2)
|
||||
})
|
||||
})
|
||||
|
||||
// Embla events should keep control states and selected index in sync.
|
||||
describe('State synchronization', () => {
|
||||
it('should update disabled states and active dot when select event is emitted', () => {
|
||||
renderCarouselWithControls()
|
||||
|
||||
mockCanScrollPrev = true
|
||||
mockCanScrollNext = true
|
||||
mockSelectedIndex = 2
|
||||
|
||||
act(() => {
|
||||
emitEmblaEvent('select')
|
||||
})
|
||||
|
||||
const dots = screen.getAllByRole('button', { name: 'Dot' })
|
||||
expect(screen.getByRole('button', { name: 'Prev' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled()
|
||||
expect(dots[2]).toHaveAttribute('data-state', 'active')
|
||||
})
|
||||
|
||||
it('should subscribe to embla events and unsubscribe from select on unmount', () => {
|
||||
const { unmount } = renderCarouselWithControls()
|
||||
|
||||
const selectCallback = mockApi.on.mock.calls.find(
|
||||
call => call[0] === 'select',
|
||||
)?.[1] as EmblaListener
|
||||
|
||||
expect(mockApi.on).toHaveBeenCalledWith('reInit', expect.any(Function))
|
||||
expect(mockApi.on).toHaveBeenCalledWith('select', expect.any(Function))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockApi.off).toHaveBeenCalledWith('select', selectCallback)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge-case behavior for missing providers or missing embla api values.
|
||||
describe('Edge cases', () => {
|
||||
it('should throw when useCarousel is used outside Carousel provider', () => {
|
||||
const InvalidConsumer = () => {
|
||||
useCarousel()
|
||||
return null
|
||||
}
|
||||
|
||||
expect(() => render(<InvalidConsumer />)).toThrowError(
|
||||
'useCarousel must be used within a <Carousel />',
|
||||
)
|
||||
})
|
||||
|
||||
it('should render with disabled controls and no dots when embla api is undefined', () => {
|
||||
mockedUseEmblaCarousel.mockReturnValue(
|
||||
[mockCarouselRef, undefined] as unknown as ReturnType<typeof useEmblaCarousel>,
|
||||
)
|
||||
|
||||
renderCarouselWithControls()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Prev' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: 'Dot' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore select callback when embla emits an undefined api', () => {
|
||||
renderCarouselWithControls()
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
emitEmblaEvent('select', undefined)
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import IndeterminateIcon from './indeterminate-icon'
|
||||
|
||||
describe('IndeterminateIcon', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<IndeterminateIcon />)
|
||||
expect(screen.getByTestId('indeterminate-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render an svg element', () => {
|
||||
const { container } = render(<IndeterminateIcon />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -32,10 +32,10 @@ describe('YearAndMonthPicker Options', () => {
|
||||
it('should render year options', () => {
|
||||
const props = createOptionsProps()
|
||||
|
||||
render(<Options {...props} />)
|
||||
const { container } = render(<Options {...props} />)
|
||||
|
||||
const allItems = screen.getAllByRole('listitem')
|
||||
expect(allItems).toHaveLength(212)
|
||||
const yearList = container.querySelectorAll('ul')[1]
|
||||
expect(yearList?.children).toHaveLength(200)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
import { Dialog, DialogBackdrop, DialogTitle } from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Button from '../button'
|
||||
@@ -81,7 +80,18 @@ export default function Drawer({
|
||||
)}
|
||||
{showClose && (
|
||||
<DialogTitle className="mb-4 flex cursor-pointer items-center" as="div">
|
||||
<XMarkIcon className="h-4 w-4 text-text-tertiary" onClick={onClose} />
|
||||
<span
|
||||
className="i-heroicons-x-mark h-4 w-4 text-text-tertiary"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ')
|
||||
onClose()
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
data-testid="close-icon"
|
||||
/>
|
||||
</DialogTitle>
|
||||
)}
|
||||
</div>
|
||||
|
||||
383
web/app/components/base/error-boundary/index.spec.tsx
Normal file
383
web/app/components/base/error-boundary/index.spec.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ErrorBoundary, { ErrorFallback, useAsyncError, useErrorHandler, withErrorBoundary } from './index'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
isDev: false,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_DEV() {
|
||||
return mockConfig.isDev
|
||||
},
|
||||
}))
|
||||
|
||||
type ThrowOnRenderProps = {
|
||||
message?: string
|
||||
shouldThrow: boolean
|
||||
}
|
||||
|
||||
const ThrowOnRender = ({ shouldThrow, message = 'render boom' }: ThrowOnRenderProps) => {
|
||||
if (shouldThrow)
|
||||
throw new Error(message)
|
||||
|
||||
return <div>Child content rendered</div>
|
||||
}
|
||||
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConfig.isDev = false
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
// Verify default render and default fallback behavior.
|
||||
describe('Rendering', () => {
|
||||
it('should render children when no error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender shouldThrow={false} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Child content rendered')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render default fallback with title and message when child throws', async () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Something went wrong')).toBeInTheDocument()
|
||||
expect(screen.getByText('An unexpected error occurred while rendering this component.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom title, message, and className in fallback', async () => {
|
||||
render(
|
||||
<ErrorBoundary
|
||||
className="custom-boundary"
|
||||
customMessage="Custom recovery message"
|
||||
customTitle="Custom crash title"
|
||||
isolate={false}
|
||||
>
|
||||
<ThrowOnRender shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Custom crash title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom recovery message')).toBeInTheDocument()
|
||||
|
||||
const fallbackRoot = document.querySelector('.custom-boundary')
|
||||
expect(fallbackRoot).toBeInTheDocument()
|
||||
expect(fallbackRoot).not.toHaveClass('min-h-[200px]')
|
||||
})
|
||||
})
|
||||
|
||||
// Validate explicit fallback prop variants.
|
||||
describe('Fallback props', () => {
|
||||
it('should render node fallback when fallback prop is a React node', async () => {
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Node fallback content</div>}>
|
||||
<ThrowOnRender shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Node fallback content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render function fallback with error message when fallback prop is a function', async () => {
|
||||
render(
|
||||
<ErrorBoundary
|
||||
fallback={error => (
|
||||
<div>
|
||||
Function fallback:
|
||||
{' '}
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<ThrowOnRender message="function fallback boom" shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Function fallback: function fallback boom')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Validate error reporting and details panel behavior.
|
||||
describe('Error reporting', () => {
|
||||
it('should call onError with error and errorInfo when child throws', async () => {
|
||||
const onError = vi.fn()
|
||||
|
||||
render(
|
||||
<ErrorBoundary onError={onError}>
|
||||
<ThrowOnRender shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
await screen.findByText('Something went wrong')
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'render boom' }),
|
||||
expect.objectContaining({ componentStack: expect.any(String) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should render details block when showDetails is true', async () => {
|
||||
render(
|
||||
<ErrorBoundary showDetails={true}>
|
||||
<ThrowOnRender message="details boom" shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Error Details (Development Only)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Error:')).toBeInTheDocument()
|
||||
expect(screen.getByText(/details boom/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should log boundary errors in development mode', async () => {
|
||||
mockConfig.isDev = true
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender message="dev boom" shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
await screen.findByText('Something went wrong')
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'ErrorBoundary caught an error:',
|
||||
expect.objectContaining({ message: 'dev boom' }),
|
||||
)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error Info:',
|
||||
expect.objectContaining({ componentStack: expect.any(String) }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Validate recovery controls and automatic reset triggers.
|
||||
describe('Recovery', () => {
|
||||
it('should hide recovery actions when enableRecovery is false', async () => {
|
||||
render(
|
||||
<ErrorBoundary enableRecovery={false}>
|
||||
<ThrowOnRender shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
await screen.findByText('Something went wrong')
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Try Again' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Reload Page' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset and render children when Try Again is clicked', async () => {
|
||||
const onReset = vi.fn()
|
||||
|
||||
const RecoveryHarness = () => {
|
||||
const [shouldThrow, setShouldThrow] = React.useState(true)
|
||||
return (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
onReset()
|
||||
setShouldThrow(false)
|
||||
}}
|
||||
>
|
||||
<ThrowOnRender shouldThrow={shouldThrow} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
render(<RecoveryHarness />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Try Again' }))
|
||||
|
||||
await screen.findByText('Child content rendered')
|
||||
expect(onReset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should reset after resetKeys change when boundary is in error state', async () => {
|
||||
const ResetKeysHarness = () => {
|
||||
const [shouldThrow, setShouldThrow] = React.useState(true)
|
||||
const [boundaryKey, setBoundaryKey] = React.useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShouldThrow(false)
|
||||
setBoundaryKey(1)
|
||||
}}
|
||||
>
|
||||
Recover with keys
|
||||
</button>
|
||||
<ErrorBoundary resetKeys={[boundaryKey]}>
|
||||
<ThrowOnRender shouldThrow={shouldThrow} />
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ResetKeysHarness />)
|
||||
await screen.findByText('Something went wrong')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Recover with keys' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Child content rendered')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should reset after children change when resetOnPropsChange is true', async () => {
|
||||
const ResetOnPropsHarness = () => {
|
||||
const [shouldThrow, setShouldThrow] = React.useState(true)
|
||||
const [childLabel, setChildLabel] = React.useState('first child')
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShouldThrow(false)
|
||||
setChildLabel('second child')
|
||||
}}
|
||||
>
|
||||
Replace children
|
||||
</button>
|
||||
<ErrorBoundary resetOnPropsChange={true}>
|
||||
{shouldThrow ? <ThrowOnRender shouldThrow={true} /> : <div>{childLabel}</div>}
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ResetOnPropsHarness />)
|
||||
await screen.findByText('Something went wrong')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Replace children' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('second child')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ErrorBoundary utility exports', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
// Validate imperative error hook behavior.
|
||||
describe('useErrorHandler', () => {
|
||||
it('should trigger error boundary fallback when setError is called', async () => {
|
||||
const HookConsumer = () => {
|
||||
const setError = useErrorHandler()
|
||||
return (
|
||||
<button onClick={() => setError(new Error('handler boom'))}>
|
||||
Trigger hook error
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Hook fallback shown</div>}>
|
||||
<HookConsumer />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Trigger hook error' }))
|
||||
|
||||
expect(await screen.findByText('Hook fallback shown')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Validate async error bridge hook behavior.
|
||||
describe('useAsyncError', () => {
|
||||
it('should trigger error boundary fallback when async error callback is called', async () => {
|
||||
const AsyncHookConsumer = () => {
|
||||
const throwAsyncError = useAsyncError()
|
||||
return (
|
||||
<button onClick={() => throwAsyncError(new Error('async hook boom'))}>
|
||||
Trigger async hook error
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Async fallback shown</div>}>
|
||||
<AsyncHookConsumer />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Trigger async hook error' }))
|
||||
|
||||
expect(await screen.findByText('Async fallback shown')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Validate HOC wrapper behavior and metadata.
|
||||
describe('withErrorBoundary', () => {
|
||||
it('should wrap component and render custom title when wrapped component throws', async () => {
|
||||
type WrappedProps = {
|
||||
shouldThrow: boolean
|
||||
}
|
||||
|
||||
const WrappedTarget = ({ shouldThrow }: WrappedProps) => {
|
||||
if (shouldThrow)
|
||||
throw new Error('wrapped boom')
|
||||
return <div>Wrapped content</div>
|
||||
}
|
||||
|
||||
const Wrapped = withErrorBoundary(WrappedTarget, {
|
||||
customTitle: 'Wrapped boundary title',
|
||||
})
|
||||
|
||||
render(<Wrapped shouldThrow={true} />)
|
||||
|
||||
expect(await screen.findByText('Wrapped boundary title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set displayName using wrapped component name', () => {
|
||||
const NamedComponent = () => <div>named content</div>
|
||||
const Wrapped = withErrorBoundary(NamedComponent)
|
||||
|
||||
expect(Wrapped.displayName).toBe('withErrorBoundary(NamedComponent)')
|
||||
})
|
||||
})
|
||||
|
||||
// Validate simple fallback helper component.
|
||||
describe('ErrorFallback', () => {
|
||||
it('should render message and call reset action when button is clicked', () => {
|
||||
const resetErrorBoundaryAction = vi.fn()
|
||||
|
||||
render(
|
||||
<ErrorFallback
|
||||
error={new Error('fallback helper message')}
|
||||
resetErrorBoundaryAction={resetErrorBoundaryAction}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Oops! Something went wrong')).toBeInTheDocument()
|
||||
expect(screen.getByText('fallback helper message')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
|
||||
|
||||
expect(resetErrorBoundaryAction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
69
web/app/components/base/features/context.spec.tsx
Normal file
69
web/app/components/base/features/context.spec.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useContext } from 'react'
|
||||
import { FeaturesContext, FeaturesProvider } from './context'
|
||||
|
||||
const TestConsumer = () => {
|
||||
const store = useContext(FeaturesContext)
|
||||
if (!store)
|
||||
return <div>no store</div>
|
||||
|
||||
const { features } = store.getState()
|
||||
return <div role="status">{features.moreLikeThis?.enabled ? 'enabled' : 'disabled'}</div>
|
||||
}
|
||||
|
||||
describe('FeaturesProvider', () => {
|
||||
it('should provide store to children when FeaturesProvider wraps them', () => {
|
||||
render(
|
||||
<FeaturesProvider>
|
||||
<TestConsumer />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('disabled')
|
||||
})
|
||||
|
||||
it('should provide initial features state when features prop is provided', () => {
|
||||
render(
|
||||
<FeaturesProvider features={{ moreLikeThis: { enabled: true } }}>
|
||||
<TestConsumer />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('enabled')
|
||||
})
|
||||
|
||||
it('should maintain the same store reference across re-renders', () => {
|
||||
const storeRefs: Array<ReturnType<typeof useContext>> = []
|
||||
|
||||
const StoreRefCollector = () => {
|
||||
const store = useContext(FeaturesContext)
|
||||
storeRefs.push(store)
|
||||
return null
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<FeaturesProvider>
|
||||
<StoreRefCollector />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<FeaturesProvider>
|
||||
<StoreRefCollector />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
|
||||
expect(storeRefs[0]).toBe(storeRefs[1])
|
||||
})
|
||||
|
||||
it('should handle empty features object', () => {
|
||||
render(
|
||||
<FeaturesProvider features={{}}>
|
||||
<TestConsumer />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('disabled')
|
||||
})
|
||||
})
|
||||
63
web/app/components/base/features/hooks.spec.ts
Normal file
63
web/app/components/base/features/hooks.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { FeaturesContext } from './context'
|
||||
import { useFeatures, useFeaturesStore } from './hooks'
|
||||
import { createFeaturesStore } from './store'
|
||||
|
||||
describe('useFeatures', () => {
|
||||
it('should return selected state from the store when useFeatures is called with selector', () => {
|
||||
const store = createFeaturesStore({
|
||||
features: { moreLikeThis: { enabled: true } },
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(FeaturesContext.Provider, { value: store }, children)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useFeatures(s => s.features.moreLikeThis?.enabled),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should throw error when used outside FeaturesContext.Provider', () => {
|
||||
// Act & Assert
|
||||
expect(() => {
|
||||
renderHook(() => useFeatures(s => s.features))
|
||||
}).toThrow('Missing FeaturesContext.Provider in the tree')
|
||||
})
|
||||
|
||||
it('should return undefined when feature does not exist', () => {
|
||||
const store = createFeaturesStore({ features: {} })
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(FeaturesContext.Provider, { value: store }, children)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useFeatures(s => (s.features as Record<string, unknown>).nonexistent as boolean | undefined),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
expect(result.current).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFeaturesStore', () => {
|
||||
it('should return the store from context when used within provider', () => {
|
||||
const store = createFeaturesStore()
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(FeaturesContext.Provider, { value: store }, children)
|
||||
|
||||
const { result } = renderHook(() => useFeaturesStore(), { wrapper })
|
||||
|
||||
expect(result.current).toBe(store)
|
||||
})
|
||||
|
||||
it('should return null when used outside provider', () => {
|
||||
const { result } = renderHook(() => useFeaturesStore())
|
||||
|
||||
expect(result.current).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import AnnotationCtrlButton from './annotation-ctrl-button'
|
||||
|
||||
const mockSetShowAnnotationFullModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowAnnotationFullModal: mockSetShowAnnotationFullModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockAnnotatedResponseUsage = 5
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: {
|
||||
usage: { get annotatedResponse() { return mockAnnotatedResponseUsage } },
|
||||
total: { annotatedResponse: 100 },
|
||||
},
|
||||
enableBilling: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockAddAnnotation = vi.fn().mockResolvedValue({
|
||||
id: 'annotation-1',
|
||||
account: { name: 'Test User' },
|
||||
})
|
||||
|
||||
vi.mock('@/service/annotation', () => ({
|
||||
addAnnotation: (...args: unknown[]) => mockAddAnnotation(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
describe('AnnotationCtrlButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAnnotatedResponseUsage = 5
|
||||
})
|
||||
|
||||
it('should render edit button when cached', () => {
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
cached={true}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onEdit when edit button is clicked', () => {
|
||||
const onEdit = vi.fn()
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
cached={true}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={vi.fn()}
|
||||
onEdit={onEdit}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onEdit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render add button when not cached and has answer', () => {
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
cached={false}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render any button when not cached and no answer', () => {
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
cached={false}
|
||||
query="test query"
|
||||
answer=""
|
||||
onAdded={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call addAnnotation and onAdded when add button is clicked', async () => {
|
||||
const onAdded = vi.fn()
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
messageId="msg-1"
|
||||
cached={false}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={onAdded}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddAnnotation).toHaveBeenCalledWith('test-app', {
|
||||
message_id: 'msg-1',
|
||||
question: 'test query',
|
||||
answer: 'test answer',
|
||||
})
|
||||
expect(onAdded).toHaveBeenCalledWith('annotation-1', 'Test User')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show annotation full modal when annotation limit is reached', () => {
|
||||
mockAnnotatedResponseUsage = 100
|
||||
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
cached={false}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(mockSetShowAnnotationFullModal).toHaveBeenCalled()
|
||||
expect(mockAddAnnotation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,415 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import ConfigParamModal from './config-param-modal'
|
||||
|
||||
let mockHooksReturn: {
|
||||
modelList: { provider: { provider: string }, models: { model: string }[] }[]
|
||||
defaultModel: { provider: { provider: string }, model: string } | undefined
|
||||
currentModel: boolean | undefined
|
||||
} = {
|
||||
modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
|
||||
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
|
||||
currentModel: true,
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: () => mockHooksReturn,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
ModelTypeEnum: {
|
||||
textEmbedding: 'text-embedding',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: ({ defaultModel, onSelect }: { defaultModel?: { provider: string, model: string }, onSelect: (val: { provider: string, model: string }) => void }) => (
|
||||
<div data-testid="model-selector" data-provider={defaultModel?.provider} data-model={defaultModel?.model}>
|
||||
Model Selector
|
||||
<button data-testid="select-model" onClick={() => onSelect({ provider: 'cohere', model: 'embed-english' })}>Select</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
ANNOTATION_DEFAULT: { score_threshold: 0.9 },
|
||||
}))
|
||||
|
||||
const defaultAnnotationConfig = {
|
||||
id: 'test-id',
|
||||
enabled: false,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
}
|
||||
|
||||
describe('ConfigParamModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHooksReturn = {
|
||||
modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
|
||||
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
|
||||
currentModel: true,
|
||||
}
|
||||
})
|
||||
|
||||
it('should not render when isShow is false', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={false}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/initSetup/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render init title when isInit is true', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
isInit={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/initSetup\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render config title when isInit is false', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
isInit={false}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/initSetup\.configTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render score slider', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render model selector', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel and confirm buttons', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
isInit={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/initSetup\.confirmBtn/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display score threshold value', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('0.90')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render configConfirmBtn when isInit is false', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
isInit={false}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/initSetup\.configConfirmBtn/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSave with embedding model and score when save is clicked', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click the confirm/save button
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
{ embedding_provider_name: 'openai', embedding_model_name: 'text-embedding-ada-002' },
|
||||
0.9,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast when embedding model is not set', () => {
|
||||
const configWithoutModel = {
|
||||
...defaultAnnotationConfig,
|
||||
embedding_model: undefined as unknown as typeof defaultAnnotationConfig.embedding_model,
|
||||
}
|
||||
|
||||
// Override hooks to return no default model and no valid current model
|
||||
mockHooksReturn = {
|
||||
modelList: [],
|
||||
defaultModel: undefined,
|
||||
currentModel: undefined,
|
||||
}
|
||||
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={configWithoutModel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
expect(Toast.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onHide when cancel is clicked and not loading', () => {
|
||||
const onHide = vi.fn()
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={onHide}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render slider with expected bounds and current value', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '80')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '100')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '90')
|
||||
})
|
||||
|
||||
it('should update embedding model when model selector is used', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click the select model button in mock
|
||||
fireEvent.click(screen.getByTestId('select-model'))
|
||||
|
||||
// Model selector should now show the new provider/model
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-provider', 'cohere')
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'embed-english')
|
||||
})
|
||||
|
||||
it('should call onSave with updated score from annotation config', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
annotationConfig={{
|
||||
...defaultAnnotationConfig,
|
||||
score_threshold: 0.95,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Save
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ embedding_provider_name: 'openai' }),
|
||||
0.95,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onSave with updated model after model selector change', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Change model
|
||||
fireEvent.click(screen.getByTestId('select-model'))
|
||||
|
||||
// Save
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
{ embedding_provider_name: 'cohere', embedding_model_name: 'embed-english' },
|
||||
0.9,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should use default model when annotation config has no embedding model', () => {
|
||||
const configWithoutModel = {
|
||||
...defaultAnnotationConfig,
|
||||
embedding_model: undefined as unknown as typeof defaultAnnotationConfig.embedding_model,
|
||||
}
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={configWithoutModel}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Model selector should be initialized with the default model
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-provider', 'openai')
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'text-embedding-ada-002')
|
||||
})
|
||||
|
||||
it('should use ANNOTATION_DEFAULT score_threshold when config has no score_threshold', () => {
|
||||
const configWithoutThreshold = {
|
||||
...defaultAnnotationConfig,
|
||||
score_threshold: 0,
|
||||
}
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={configWithoutThreshold}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '90')
|
||||
})
|
||||
|
||||
it('should set loading state while saving', async () => {
|
||||
let resolveOnSave: () => void
|
||||
const onSave = vi.fn().mockImplementation(() => new Promise<void>((resolve) => {
|
||||
resolveOnSave = resolve
|
||||
}))
|
||||
const onHide = vi.fn()
|
||||
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={onHide}
|
||||
onSave={onSave}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click save
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
// While loading, clicking cancel should not call onHide
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
|
||||
// Resolve the save
|
||||
resolveOnSave!()
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Item } from './config-param'
|
||||
|
||||
describe('ConfigParam Item', () => {
|
||||
it('should render title text', () => {
|
||||
render(
|
||||
<Item title="Score Threshold" tooltip="Tooltip text">
|
||||
<div>children</div>
|
||||
</Item>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Score Threshold')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<Item title="Title" tooltip="Tooltip">
|
||||
<div data-testid="child-content">Child</div>
|
||||
</Item>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('child-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tooltip icon', () => {
|
||||
render(
|
||||
<Item title="Title" tooltip="Tooltip text">
|
||||
<div>children</div>
|
||||
</Item>,
|
||||
)
|
||||
|
||||
// Tooltip component renders an icon next to the title
|
||||
expect(screen.getByText(/Title/)).toBeInTheDocument()
|
||||
// The Tooltip component is rendered as a sibling, confirming the tooltip prop is used
|
||||
expect(screen.getByText(/Title/).closest('div')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,420 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import AnnotationReply from './index'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
usePathname: () => '/app/test-app-id/configuration',
|
||||
}))
|
||||
|
||||
let mockIsShowAnnotationConfigInit = false
|
||||
let mockIsShowAnnotationFullModal = false
|
||||
const mockHandleEnableAnnotation = vi.fn().mockResolvedValue(undefined)
|
||||
const mockHandleDisableAnnotation = vi.fn().mockResolvedValue(undefined)
|
||||
const mockSetIsShowAnnotationConfigInit = vi.fn((v: boolean) => {
|
||||
mockIsShowAnnotationConfigInit = v
|
||||
})
|
||||
const mockSetIsShowAnnotationFullModal = vi.fn((v: boolean) => {
|
||||
mockIsShowAnnotationFullModal = v
|
||||
})
|
||||
|
||||
let capturedSetAnnotationConfig: ((config: Record<string, unknown>) => void) | null = null
|
||||
|
||||
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config', () => ({
|
||||
default: ({ setAnnotationConfig }: { setAnnotationConfig: (config: Record<string, unknown>) => void }) => {
|
||||
capturedSetAnnotationConfig = setAnnotationConfig
|
||||
return {
|
||||
handleEnableAnnotation: mockHandleEnableAnnotation,
|
||||
handleDisableAnnotation: mockHandleDisableAnnotation,
|
||||
get isShowAnnotationConfigInit() { return mockIsShowAnnotationConfigInit },
|
||||
setIsShowAnnotationConfigInit: mockSetIsShowAnnotationConfigInit,
|
||||
get isShowAnnotationFullModal() { return mockIsShowAnnotationFullModal },
|
||||
setIsShowAnnotationFullModal: mockSetIsShowAnnotationFullModal,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/annotation-full/modal', () => ({
|
||||
default: ({ show, onHide }: { show: boolean, onHide: () => void }) => (
|
||||
show
|
||||
? (
|
||||
<div data-testid="annotation-full-modal">
|
||||
<button data-testid="full-hide" onClick={onHide}>Hide</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
ANNOTATION_DEFAULT: { score_threshold: 0.9 },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
|
||||
modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
|
||||
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
|
||||
currentModel: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
ModelTypeEnum: {
|
||||
textEmbedding: 'text-embedding',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: () => (
|
||||
<div data-testid="model-selector">Model Selector</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { disabled?: boolean, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<AnnotationReply disabled={props.disabled} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AnnotationReply', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsShowAnnotationConfigInit = false
|
||||
mockIsShowAnnotationFullModal = false
|
||||
capturedSetAnnotationConfig = null
|
||||
})
|
||||
|
||||
it('should render the annotation reply title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.annotation\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when not enabled', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.annotation\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setIsShowAnnotationConfigInit when switch is toggled on', () => {
|
||||
renderWithProvider()
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should call handleDisableAnnotation when switch is toggled off', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(mockHandleDisableAnnotation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show score threshold and embedding model when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('0.9')).toBeInTheDocument()
|
||||
expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show dash when score threshold is not set', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show buttons when hovering over enabled feature', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(screen.getByText(/operation\.params/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.annotation\.cacheManagement/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setIsShowAnnotationConfigInit when params button is clicked', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.params/))
|
||||
|
||||
expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should navigate to annotations page when cache management is clicked', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/feature\.annotation\.cacheManagement/))
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/app/test-app-id/annotations')
|
||||
})
|
||||
|
||||
it('should show config param modal when isShowAnnotationConfigInit is true', () => {
|
||||
mockIsShowAnnotationConfigInit = true
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/initSetup\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide config modal when hide is clicked', () => {
|
||||
mockIsShowAnnotationConfigInit = true
|
||||
renderWithProvider()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/ }))
|
||||
|
||||
expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should call handleEnableAnnotation when config save is clicked', async () => {
|
||||
mockIsShowAnnotationConfigInit = true
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/initSetup\.confirmBtn/))
|
||||
|
||||
expect(mockHandleEnableAnnotation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show annotation full modal when isShowAnnotationFullModal is true', () => {
|
||||
mockIsShowAnnotationFullModal = true
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByTestId('annotation-full-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide annotation full modal when hide is clicked', () => {
|
||||
mockIsShowAnnotationFullModal = true
|
||||
renderWithProvider()
|
||||
|
||||
fireEvent.click(screen.getByTestId('full-hide'))
|
||||
|
||||
expect(mockSetIsShowAnnotationFullModal).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should call handleEnableAnnotation and hide config modal on save', async () => {
|
||||
mockIsShowAnnotationConfigInit = true
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/initSetup\.confirmBtn/))
|
||||
|
||||
// handleEnableAnnotation should be called with embedding model and score
|
||||
expect(mockHandleEnableAnnotation).toHaveBeenCalledWith(
|
||||
{ embedding_provider_name: 'openai', embedding_model_name: 'text-embedding-ada-002' },
|
||||
0.9,
|
||||
)
|
||||
|
||||
// After save resolves, config init should be hidden
|
||||
await vi.waitFor(() => {
|
||||
expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update features and call onChange when updateAnnotationReply is invoked', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange }, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// The captured setAnnotationConfig is the component's updateAnnotationReply callback
|
||||
expect(capturedSetAnnotationConfig).not.toBeNull()
|
||||
capturedSetAnnotationConfig!({
|
||||
enabled: true,
|
||||
score_threshold: 0.8,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'new-model',
|
||||
},
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update features without calling onChange when onChange is not provided', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Should not throw when onChange is not provided
|
||||
expect(capturedSetAnnotationConfig).not.toBeNull()
|
||||
expect(() => {
|
||||
capturedSetAnnotationConfig!({
|
||||
enabled: true,
|
||||
score_threshold: 0.7,
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should hide info display when hovering over enabled feature', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Before hover, info is visible
|
||||
expect(screen.getByText('0.9')).toBeInTheDocument()
|
||||
|
||||
const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
// After hover, buttons shown instead of info
|
||||
expect(screen.getByText(/operation\.params/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('0.9')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show info display again when mouse leaves', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.mouseLeave(card)
|
||||
|
||||
expect(screen.getByText('0.9')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass isInit prop to ConfigParamModal', () => {
|
||||
mockIsShowAnnotationConfigInit = true
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/initSetup\.confirmBtn/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/initSetup\.configConfirmBtn/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show annotation full modal when isShowAnnotationFullModal is false', () => {
|
||||
mockIsShowAnnotationFullModal = false
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.queryByTestId('annotation-full-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Slider from './index'
|
||||
|
||||
describe('BaseSlider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the slider component', () => {
|
||||
render(<Slider value={50} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display the formatted value in the thumb', () => {
|
||||
render(<Slider value={85} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('0.85')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use default min/max/step when not provided', () => {
|
||||
render(<Slider value={50} onChange={vi.fn()} />)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '0')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '100')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '50')
|
||||
})
|
||||
|
||||
it('should use custom min/max/step when provided', () => {
|
||||
render(<Slider value={90} min={80} max={100} step={5} onChange={vi.fn()} />)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '80')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '100')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '90')
|
||||
})
|
||||
|
||||
it('should handle NaN value as 0', () => {
|
||||
render(<Slider value={Number.NaN} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0')
|
||||
})
|
||||
|
||||
it('should pass disabled prop', () => {
|
||||
render(<Slider value={50} disabled onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ScoreSlider from './index'
|
||||
|
||||
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider', () => ({
|
||||
default: ({ value, onChange, min, max }: { value: number, onChange: (v: number) => void, min: number, max: number }) => (
|
||||
<input
|
||||
type="range"
|
||||
data-testid="slider"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={e => onChange(Number(e.target.value))}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ScoreSlider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the slider', () => {
|
||||
render(<ScoreSlider value={90} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display easy match and accurate match labels', () => {
|
||||
render(<ScoreSlider value={90} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('0.8')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.0')).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.annotation\.scoreThreshold\.easyMatch/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.annotation\.scoreThreshold\.accurateMatch/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with custom className', () => {
|
||||
const { container } = render(<ScoreSlider className="custom-class" value={90} onChange={vi.fn()} />)
|
||||
|
||||
// Verifying the component renders successfully with a custom className
|
||||
expect(screen.getByTestId('slider')).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should pass value to the slider', () => {
|
||||
render(<ScoreSlider value={95} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('slider')).toHaveValue('95')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PageType } from './type'
|
||||
|
||||
describe('PageType', () => {
|
||||
it('should have log and annotation values', () => {
|
||||
expect(PageType.log).toBe('log')
|
||||
expect(PageType.annotation).toBe('annotation')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { AnnotationReplyConfig } from '@/models/debug'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import useAnnotationConfig from './use-annotation-config'
|
||||
|
||||
let mockIsAnnotationFull = false
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: {
|
||||
usage: { annotatedResponse: mockIsAnnotationFull ? 100 : 5 },
|
||||
total: { annotatedResponse: 100 },
|
||||
},
|
||||
enableBilling: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/annotation', () => ({
|
||||
updateAnnotationStatus: vi.fn().mockResolvedValue({ job_id: 'test-job-id' }),
|
||||
queryAnnotationJobStatus: vi.fn().mockResolvedValue({ job_status: 'completed' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils', () => ({
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
describe('useAnnotationConfig', () => {
|
||||
const defaultConfig: AnnotationReplyConfig = {
|
||||
id: 'test-id',
|
||||
enabled: false,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsAnnotationFull = false
|
||||
})
|
||||
|
||||
it('should initialize with annotation config init hidden', () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
expect(result.current.isShowAnnotationConfigInit).toBe(false)
|
||||
expect(result.current.isShowAnnotationFullModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should show annotation config init modal', () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setIsShowAnnotationConfigInit(true)
|
||||
})
|
||||
|
||||
expect(result.current.isShowAnnotationConfigInit).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide annotation config init modal', () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setIsShowAnnotationConfigInit(true)
|
||||
})
|
||||
act(() => {
|
||||
result.current.setIsShowAnnotationConfigInit(false)
|
||||
})
|
||||
|
||||
expect(result.current.isShowAnnotationConfigInit).toBe(false)
|
||||
})
|
||||
|
||||
it('should enable annotation and update config', async () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleEnableAnnotation({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
}, 0.95)
|
||||
})
|
||||
|
||||
expect(setAnnotationConfig).toHaveBeenCalled()
|
||||
const updatedConfig = setAnnotationConfig.mock.calls[0][0]
|
||||
expect(updatedConfig.enabled).toBe(true)
|
||||
expect(updatedConfig.embedding_model.embedding_model_name).toBe('text-embedding-3-small')
|
||||
})
|
||||
|
||||
it('should disable annotation and update config', async () => {
|
||||
const enabledConfig = { ...defaultConfig, enabled: true }
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: enabledConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDisableAnnotation({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
})
|
||||
})
|
||||
|
||||
expect(setAnnotationConfig).toHaveBeenCalled()
|
||||
const updatedConfig = setAnnotationConfig.mock.calls[0][0]
|
||||
expect(updatedConfig.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should not disable when already disabled', async () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDisableAnnotation({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
})
|
||||
})
|
||||
|
||||
expect(setAnnotationConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set score threshold', () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setScore(0.85)
|
||||
})
|
||||
|
||||
expect(setAnnotationConfig).toHaveBeenCalled()
|
||||
const updatedConfig = setAnnotationConfig.mock.calls[0][0]
|
||||
expect(updatedConfig.score_threshold).toBe(0.85)
|
||||
})
|
||||
|
||||
it('should set score and embedding model together', () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setScore(0.95, {
|
||||
embedding_provider_name: 'cohere',
|
||||
embedding_model_name: 'embed-english',
|
||||
})
|
||||
})
|
||||
|
||||
expect(setAnnotationConfig).toHaveBeenCalled()
|
||||
const updatedConfig = setAnnotationConfig.mock.calls[0][0]
|
||||
expect(updatedConfig.score_threshold).toBe(0.95)
|
||||
expect(updatedConfig.embedding_model.embedding_provider_name).toBe('cohere')
|
||||
})
|
||||
|
||||
it('should show annotation full modal instead of config init when annotation is full', () => {
|
||||
mockIsAnnotationFull = true
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setIsShowAnnotationConfigInit(true)
|
||||
})
|
||||
|
||||
expect(result.current.isShowAnnotationFullModal).toBe(true)
|
||||
expect(result.current.isShowAnnotationConfigInit).toBe(false)
|
||||
})
|
||||
|
||||
it('should not enable annotation when annotation is full', async () => {
|
||||
mockIsAnnotationFull = true
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleEnableAnnotation({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
})
|
||||
})
|
||||
|
||||
expect(setAnnotationConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set default score_threshold when enabling without one', async () => {
|
||||
const configWithoutThreshold = { ...defaultConfig, score_threshold: undefined as unknown as number }
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: configWithoutThreshold,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleEnableAnnotation({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
}, 0.95)
|
||||
})
|
||||
|
||||
expect(setAnnotationConfig).toHaveBeenCalled()
|
||||
const updatedConfig = setAnnotationConfig.mock.calls[0][0]
|
||||
expect(updatedConfig.enabled).toBe(true)
|
||||
expect(updatedConfig.score_threshold).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { OnFeaturesChange } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { FeaturesProvider } from '../context'
|
||||
import Citation from './citation'
|
||||
|
||||
const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => {
|
||||
return render(
|
||||
<FeaturesProvider>
|
||||
<Citation disabled={props.disabled} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Citation', () => {
|
||||
it('should render the citation feature card', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.citation\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description text', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.citation\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not throw when onChange is not provided', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import ConversationOpener from './index'
|
||||
|
||||
const mockSetShowOpeningModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowOpeningModal: mockSetShowOpeningModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { disabled?: boolean, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<ConversationOpener disabled={props.disabled} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ConversationOpener', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the conversation opener title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.conversationOpener\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when not enabled', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.conversationOpener\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show opening statement when enabled and not hovering', () => {
|
||||
renderWithProvider({}, {
|
||||
opening: { enabled: true, opening_statement: 'Welcome to the app!' },
|
||||
})
|
||||
|
||||
expect(screen.getByText('Welcome to the app!')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show placeholder when enabled but no opening statement', () => {
|
||||
renderWithProvider({}, {
|
||||
opening: { enabled: true, opening_statement: '' },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/openingStatement\.placeholder/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show edit button when hovering over enabled feature', () => {
|
||||
renderWithProvider({}, {
|
||||
opening: { enabled: true, opening_statement: 'Hello' },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(screen.getByText(/openingStatement\.writeOpener/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open modal when edit button is clicked', () => {
|
||||
renderWithProvider({}, {
|
||||
opening: { enabled: true, opening_statement: 'Hello' },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/openingStatement\.writeOpener/))
|
||||
|
||||
expect(mockSetShowOpeningModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not open modal when disabled', () => {
|
||||
renderWithProvider({ disabled: true }, {
|
||||
opening: { enabled: true, opening_statement: 'Hello' },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/openingStatement\.writeOpener/))
|
||||
|
||||
expect(mockSetShowOpeningModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pass opening data to modal', () => {
|
||||
renderWithProvider({}, {
|
||||
opening: { enabled: true, opening_statement: 'Hello' },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/openingStatement\.writeOpener/))
|
||||
|
||||
const modalCall = mockSetShowOpeningModal.mock.calls[0][0]
|
||||
expect(modalCall.payload).toBeDefined()
|
||||
expect(modalCall.onSaveCallback).toBeDefined()
|
||||
expect(modalCall.onCancelCallback).toBeDefined()
|
||||
})
|
||||
|
||||
it('should invoke onSaveCallback and update features', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange }, {
|
||||
opening: { enabled: true, opening_statement: 'Hello' },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/openingStatement\.writeOpener/))
|
||||
|
||||
const modalCall = mockSetShowOpeningModal.mock.calls[0][0]
|
||||
modalCall.onSaveCallback({ enabled: true, opening_statement: 'Updated' })
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invoke onCancelCallback', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange }, {
|
||||
opening: { enabled: true, opening_statement: 'Hello' },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/openingStatement\.writeOpener/))
|
||||
|
||||
const modalCall = mockSetShowOpeningModal.mock.calls[0][0]
|
||||
modalCall.onCancelCallback()
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show info and hide when hovering over enabled feature', () => {
|
||||
renderWithProvider({}, {
|
||||
opening: { enabled: true, opening_statement: 'Welcome!' },
|
||||
})
|
||||
|
||||
// Before hover, opening statement visible
|
||||
expect(screen.getByText('Welcome!')).toBeInTheDocument()
|
||||
|
||||
const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
// After hover, button visible, statement hidden
|
||||
expect(screen.getByText(/openingStatement\.writeOpener/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseLeave(card)
|
||||
|
||||
// After leave, statement visible again
|
||||
expect(screen.getByText('Welcome!')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,510 @@
|
||||
import type { OpeningStatement } from '@/app/components/base/features/types'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import OpeningSettingModal from './modal'
|
||||
|
||||
const getPromptEditor = () => {
|
||||
const editor = document.querySelector('[data-lexical-editor="true"]')
|
||||
expect(editor).toBeInTheDocument()
|
||||
return editor as HTMLElement
|
||||
}
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
checkKeys: (_keys: string[]) => ({ isValid: true }),
|
||||
getNewVar: (key: string, type: string) => ({ key, name: key, type, required: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/config-prompt/confirm-add-var', () => ({
|
||||
default: ({ varNameArr, onConfirm, onCancel }: {
|
||||
varNameArr: string[]
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
<div data-testid="confirm-add-var">
|
||||
<span>{varNameArr.join(',')}</span>
|
||||
<button data-testid="confirm-add" onClick={onConfirm}>Confirm</button>
|
||||
<button data-testid="cancel-add" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('react-sortablejs', () => ({
|
||||
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
const defaultData: OpeningStatement = {
|
||||
enabled: true,
|
||||
opening_statement: 'Hello, how can I help?',
|
||||
suggested_questions: ['Question 1', 'Question 2'],
|
||||
}
|
||||
|
||||
const createMockInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
|
||||
variable: 'name',
|
||||
label: 'Name',
|
||||
type: InputVarType.textInput,
|
||||
required: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('OpeningSettingModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the modal title', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/feature\.conversationOpener\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the opening statement in the editor', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(getPromptEditor()).toHaveTextContent('Hello, how can I help?')
|
||||
})
|
||||
|
||||
it('should render suggested questions', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel and save buttons', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel is clicked', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when close icon is clicked', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
await userEvent.click(closeButton)
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when close icon receives Enter key', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
closeButton.focus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onCancel when close icon receives Space key', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
closeButton.focus()
|
||||
fireEvent.keyDown(closeButton, { key: ' ' })
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onSave with updated data when save is clicked', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
opening_statement: 'Hello, how can I help?',
|
||||
suggested_questions: ['Question 1', 'Question 2'],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should disable save when opening statement is empty', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: '' }}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/).closest('button')
|
||||
expect(saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should add a new suggested question', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Before adding: 2 existing questions
|
||||
expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByText(/variableConfig\.addOption/))
|
||||
|
||||
// After adding: the 2 existing questions still present plus 1 new empty one
|
||||
expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument()
|
||||
// The new empty question renders as an input with empty value
|
||||
const allInputs = screen.getAllByDisplayValue('')
|
||||
expect(allInputs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should delete a suggested question via save verification', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, suggested_questions: ['Question 1'] }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Question should be present initially
|
||||
expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
|
||||
|
||||
const deleteIconWrapper = screen.getByTestId('delete-question-Question 1').parentElement
|
||||
expect(deleteIconWrapper).toBeTruthy()
|
||||
await userEvent.click(deleteIconWrapper!)
|
||||
|
||||
// After deletion, Question 1 should be gone
|
||||
expect(screen.queryByDisplayValue('Question 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update a suggested question value', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByDisplayValue('Question 1')
|
||||
await userEvent.clear(input)
|
||||
await userEvent.type(input, 'Updated Question')
|
||||
|
||||
expect(input).toHaveValue('Updated Question')
|
||||
})
|
||||
|
||||
it('should show confirm dialog when variables are not in prompt', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(screen.getByTestId('confirm-add-var')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save without variable check when confirm cancel is clicked', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
await userEvent.click(screen.getByTestId('cancel-add'))
|
||||
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show question count', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Count is displayed as "2/10" across child elements
|
||||
expect(screen.getByText(/openingStatement\.openingQuestion/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onAutoAddPromptVariable when confirm add is clicked', async () => {
|
||||
const onAutoAddPromptVariable = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onAutoAddPromptVariable={onAutoAddPromptVariable}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
// Confirm add var dialog should appear
|
||||
await userEvent.click(screen.getByTestId('confirm-add'))
|
||||
|
||||
expect(onAutoAddPromptVariable).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not show add button when max questions reached', async () => {
|
||||
const questionsAtMax: OpeningStatement = {
|
||||
enabled: true,
|
||||
opening_statement: 'Hello',
|
||||
suggested_questions: Array.from({ length: 10 }, (_, i) => `Q${i + 1}`),
|
||||
}
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={questionsAtMax}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/variableConfig\.addOption/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply and remove focused styling on question input focus/blur', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByDisplayValue('Question 1') as HTMLInputElement
|
||||
const questionRow = input.parentElement
|
||||
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(questionRow).not.toHaveClass('border-components-input-border-active')
|
||||
|
||||
await userEvent.click(input)
|
||||
expect(questionRow).toHaveClass('border-components-input-border-active')
|
||||
|
||||
// Tab press to blur
|
||||
await userEvent.tab()
|
||||
expect(questionRow).not.toHaveClass('border-components-input-border-active')
|
||||
})
|
||||
|
||||
it('should apply and remove deleting styling on delete icon hover', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const questionInput = screen.getByDisplayValue('Question 1') as HTMLInputElement
|
||||
const questionRow = questionInput.parentElement
|
||||
const deleteIconWrapper = screen.getByTestId('delete-question-Question 1').parentElement
|
||||
|
||||
expect(questionRow).not.toHaveClass('border-components-input-border-destructive')
|
||||
expect(deleteIconWrapper).toBeTruthy()
|
||||
|
||||
await userEvent.hover(deleteIconWrapper!)
|
||||
expect(questionRow).toHaveClass('border-components-input-border-destructive')
|
||||
|
||||
await userEvent.unhover(deleteIconWrapper!)
|
||||
expect(questionRow).not.toHaveClass('border-components-input-border-destructive')
|
||||
})
|
||||
|
||||
it('should handle save with empty suggested questions', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, suggested_questions: [] }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
suggested_questions: [],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should not save when opening statement is only whitespace', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: ' ' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip variable check when variables match prompt variables', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
promptVariables={[{ key: 'name', name: 'Name', type: 'string', required: true }]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
// Variable is in promptVariables, so no confirm dialog
|
||||
expect(screen.queryByTestId('confirm-add-var')).not.toBeInTheDocument()
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip variable check when variables match workflow variables', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
workflowVariables={[createMockInputVar()]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
// Variable matches workflow variables, so no confirm dialog
|
||||
expect(screen.queryByTestId('confirm-add-var')).not.toBeInTheDocument()
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show confirm dialog when variables not in workflow variables', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{unknown}}' }}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
workflowVariables={[createMockInputVar()]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(screen.getByTestId('confirm-add-var')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use updated opening statement after prop changes', async () => {
|
||||
const onSave = vi.fn()
|
||||
const view = await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
view.rerender(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'New greeting!' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
await Promise.resolve()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
opening_statement: 'New greeting!',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render empty opening statement with placeholder in editor', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: '' }}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const editor = getPromptEditor()
|
||||
expect(editor.textContent?.trim()).toBe('')
|
||||
expect(screen.getByText('appDebug.openingStatement.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { OpeningStatement } from '@/app/components/base/features/types'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { RiAddLine, RiAsterisk, RiCloseLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
@@ -139,7 +138,7 @@ const OpeningSettingModal = ({
|
||||
)}
|
||||
key={index}
|
||||
>
|
||||
<RiDraggable className="handle h-4 w-4 cursor-grab text-text-quaternary" />
|
||||
<span className="handle i-ri-draggable h-4 w-4 cursor-grab text-text-quaternary" />
|
||||
<input
|
||||
type="input"
|
||||
value={question || ''}
|
||||
@@ -166,7 +165,7 @@ const OpeningSettingModal = ({
|
||||
onMouseEnter={() => setDeletingID(index)}
|
||||
onMouseLeave={() => setDeletingID(null)}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
<span className="i-ri-delete-bin-line h-3.5 w-3.5" data-testid={`delete-question-${question}`} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -175,10 +174,10 @@ const OpeningSettingModal = ({
|
||||
{tempSuggestedQuestions.length < MAX_QUESTION_NUM && (
|
||||
<div
|
||||
onClick={() => { setTempSuggestedQuestions([...tempSuggestedQuestions, '']) }}
|
||||
className="mt-1 flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-components-button-tertiary-bg px-3 text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover"
|
||||
className="mt-1 flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-components-button-tertiary-bg px-3 text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover"
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
<div className="system-sm-medium text-[13px]">{t('variableConfig.addOption', { ns: 'appDebug' })}</div>
|
||||
<span className="i-ri-add-line h-4 w-4" />
|
||||
<div className="text-[13px] system-sm-medium">{t('variableConfig.addOption', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -192,12 +191,26 @@ const OpeningSettingModal = ({
|
||||
className="!mt-14 !w-[640px] !max-w-none !bg-components-panel-bg-blur !p-6"
|
||||
>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="title-2xl-semi-bold text-text-primary">{t('feature.conversationOpener.title', { ns: 'appDebug' })}</div>
|
||||
<div className="cursor-pointer p-1" onClick={onCancel}><RiCloseLine className="h-4 w-4 text-text-tertiary" /></div>
|
||||
<div className="text-text-primary title-2xl-semi-bold">{t('feature.conversationOpener.title', { ns: 'appDebug' })}</div>
|
||||
<div
|
||||
className="cursor-pointer p-1"
|
||||
onClick={onCancel}
|
||||
data-testid="close-modal"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-8 flex gap-2">
|
||||
<div className="mt-1.5 h-8 w-8 shrink-0 rounded-lg border-components-panel-border bg-util-colors-orange-dark-orange-dark-500 p-1.5">
|
||||
<RiAsterisk className="h-5 w-5 text-text-primary-on-surface" />
|
||||
<span className="i-ri-asterisk h-5 w-5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<div className="grow rounded-2xl border-t border-divider-subtle bg-chat-bubble-bg p-3 shadow-xs">
|
||||
<PromptEditor
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import DialogWrapper from './dialog-wrapper'
|
||||
|
||||
describe('DialogWrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render children when show is true', () => {
|
||||
render(
|
||||
<DialogWrapper show>
|
||||
<div data-testid="content">Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render children when show is false', () => {
|
||||
render(
|
||||
<DialogWrapper show={false}>
|
||||
<div data-testid="content">Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply workflow styles by default', () => {
|
||||
render(
|
||||
<DialogWrapper show>
|
||||
<div data-testid="content">Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
const wrapper = screen.getByTestId('content').parentElement
|
||||
expect(wrapper).toHaveClass('rounded-l-2xl')
|
||||
expect(wrapper).not.toHaveClass('rounded-2xl')
|
||||
})
|
||||
|
||||
it('should apply non-workflow styles when inWorkflow is false', () => {
|
||||
render(
|
||||
<DialogWrapper show inWorkflow={false}>
|
||||
<div data-testid="content">Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
const content = screen.getByTestId('content')
|
||||
const panel = content.parentElement
|
||||
const layoutContainer = screen.getByTestId('dialog-layout-container')
|
||||
|
||||
expect(layoutContainer).toHaveClass('pr-2')
|
||||
expect(layoutContainer).toHaveClass('pt-[64px]')
|
||||
expect(layoutContainer).not.toHaveClass('pt-[112px]')
|
||||
|
||||
expect(panel).toHaveClass('rounded-2xl')
|
||||
expect(panel).toHaveClass('border-[0.5px]')
|
||||
expect(panel).not.toHaveClass('rounded-l-2xl')
|
||||
})
|
||||
|
||||
it('should accept custom className', () => {
|
||||
render(
|
||||
<DialogWrapper show className="custom-class">
|
||||
<div data-testid="content">Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
const wrapper = screen.getByTestId('content').parentElement
|
||||
expect(wrapper).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Close behavior', () => {
|
||||
it('should call onClose when escape is pressed', async () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DialogWrapper show onClose={onClose}>
|
||||
<div>Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not throw when escape is pressed without onClose', () => {
|
||||
render(
|
||||
<DialogWrapper show>
|
||||
<div>Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
expect(() => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -33,12 +33,12 @@ const DialogWrapper = ({
|
||||
</TransitionChild>
|
||||
|
||||
<div className="fixed inset-0">
|
||||
<div className={cn('flex min-h-full flex-col items-end justify-center pb-2', inWorkflow ? 'pt-[112px]' : 'pr-2 pt-[64px]')}>
|
||||
<div className={cn('flex min-h-full flex-col items-end justify-center pb-2', inWorkflow ? 'pt-[112px]' : 'pr-2 pt-[64px]')} data-testid="dialog-layout-container">
|
||||
<TransitionChild>
|
||||
<DialogPanel className={cn(
|
||||
'relative flex h-0 w-[420px] grow flex-col overflow-hidden border-components-panel-border bg-components-panel-bg-alt p-0 text-left align-middle shadow-xl transition-all',
|
||||
inWorkflow ? 'rounded-l-2xl border-b-[0.5px] border-l-[0.5px] border-t-[0.5px]' : 'rounded-2xl border-[0.5px]',
|
||||
'data-[closed]:scale-95 data-[closed]:opacity-0',
|
||||
'data-[closed]:scale-95 data-[closed]:opacity-0',
|
||||
'data-[enter]:scale-100 data-[enter]:opacity-100 data-[enter]:duration-300 data-[enter]:ease-out',
|
||||
'data-[leave]:scale-95 data-[leave]:opacity-0 data-[leave]:duration-200 data-[leave]:ease-in',
|
||||
className,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Features } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../context'
|
||||
import FeatureBar from './feature-bar'
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: {
|
||||
isChatMode?: boolean
|
||||
showFileUpload?: boolean
|
||||
disabled?: boolean
|
||||
onFeatureBarClick?: (state: boolean) => void
|
||||
hideEditEntrance?: boolean
|
||||
} = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<FeatureBar {...props} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('FeatureBar', () => {
|
||||
describe('Empty State', () => {
|
||||
it('should render empty state when no features are enabled', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.empty/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onFeatureBarClick when empty state is clicked', () => {
|
||||
const onFeatureBarClick = vi.fn()
|
||||
|
||||
renderWithProvider({ onFeatureBarClick })
|
||||
fireEvent.click(screen.getByText(/feature\.bar\.empty/))
|
||||
|
||||
expect(onFeatureBarClick).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Enabled Features', () => {
|
||||
it('should show enabled text when moreLikeThis is enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
moreLikeThis: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show manage button when features are enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
moreLikeThis: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.manage/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide manage button when hideEditEntrance is true', () => {
|
||||
renderWithProvider({ hideEditEntrance: true }, {
|
||||
moreLikeThis: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.queryByText(/feature\.bar\.manage/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onFeatureBarClick when manage button is clicked', () => {
|
||||
const onFeatureBarClick = vi.fn()
|
||||
|
||||
renderWithProvider({ onFeatureBarClick }, {
|
||||
moreLikeThis: { enabled: true },
|
||||
})
|
||||
fireEvent.click(screen.getByText(/feature\.bar\.manage/))
|
||||
|
||||
expect(onFeatureBarClick).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chat Mode Features', () => {
|
||||
it('should show enabled text when citation is enabled in chat mode', () => {
|
||||
renderWithProvider({ isChatMode: true }, {
|
||||
citation: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show empty state when citation is enabled but not in chat mode', () => {
|
||||
renderWithProvider({ isChatMode: false }, {
|
||||
citation: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.empty/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show enabled text when opening is enabled in chat mode', () => {
|
||||
renderWithProvider({ isChatMode: true }, {
|
||||
opening: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show enabled text when file is enabled with showFileUpload', () => {
|
||||
renderWithProvider({ showFileUpload: true }, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show empty state when file is enabled but showFileUpload is false', () => {
|
||||
renderWithProvider({ showFileUpload: false }, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.empty/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show enabled text when speech2text is enabled in chat mode', () => {
|
||||
renderWithProvider({ isChatMode: true }, {
|
||||
speech2text: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show enabled text when text2speech is enabled', () => {
|
||||
renderWithProvider({ isChatMode: true }, {
|
||||
text2speech: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show enabled text when moderation is enabled', () => {
|
||||
renderWithProvider({ isChatMode: true }, {
|
||||
moderation: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show enabled text when suggested is enabled', () => {
|
||||
renderWithProvider({ isChatMode: true }, {
|
||||
suggested: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show enabled text when annotationReply is enabled in chat mode', () => {
|
||||
renderWithProvider({ isChatMode: true }, {
|
||||
annotationReply: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import FeatureCard from './feature-card'
|
||||
|
||||
describe('FeatureCard', () => {
|
||||
const defaultProps = {
|
||||
icon: <div data-testid="icon">icon</div>,
|
||||
title: 'Test Feature',
|
||||
value: false,
|
||||
}
|
||||
|
||||
it('should render icon and title', () => {
|
||||
render(<FeatureCard {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Test Feature/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when provided', () => {
|
||||
render(<FeatureCard {...defaultProps} description="A test description" />)
|
||||
|
||||
expect(screen.getByText(/A test description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render description when not provided', () => {
|
||||
render(<FeatureCard {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByText(/description/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
render(<FeatureCard {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when switch is toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<FeatureCard {...defaultProps} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render tooltip when provided', () => {
|
||||
render(<FeatureCard {...defaultProps} tooltip="Helpful tip" />)
|
||||
|
||||
// Tooltip text is passed as prop, verifying the component renders with it
|
||||
expect(screen.getByText(/Test Feature/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render tooltip when not provided', () => {
|
||||
render(<FeatureCard {...defaultProps} />)
|
||||
|
||||
// Without tooltip, the title should still render
|
||||
expect(screen.getByText(/Test Feature/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children when provided', () => {
|
||||
render(
|
||||
<FeatureCard {...defaultProps}>
|
||||
<div data-testid="child-content">Child</div>
|
||||
</FeatureCard>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('child-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onMouseEnter when hovering', () => {
|
||||
const onMouseEnter = vi.fn()
|
||||
render(<FeatureCard {...defaultProps} onMouseEnter={onMouseEnter} />)
|
||||
|
||||
const card = screen.getByText(/Test Feature/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(onMouseEnter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onMouseLeave when mouse leaves', () => {
|
||||
const onMouseLeave = vi.fn()
|
||||
render(<FeatureCard {...defaultProps} onMouseLeave={onMouseLeave} />)
|
||||
|
||||
const card = screen.getByText(/Test Feature/).closest('[class]')!
|
||||
fireEvent.mouseLeave(card)
|
||||
|
||||
expect(onMouseLeave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle disabled state', () => {
|
||||
render(<FeatureCard {...defaultProps} disabled={true} />)
|
||||
|
||||
const switchElement = screen.getByRole('switch')
|
||||
expect(switchElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not call onChange when onChange is not provided', () => {
|
||||
render(<FeatureCard {...defaultProps} />)
|
||||
|
||||
// Should not throw when switch is clicked without onChange
|
||||
expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import FileUpload from './index'
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: () => ({ data: undefined }),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { disabled?: boolean, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<FileUpload disabled={props.disabled ?? false} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('FileUpload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the file upload title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.fileUpload\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when disabled', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.fileUpload\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show supported types when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image', 'document'],
|
||||
number_limits: 5,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('image,document')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show number limits when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show dash when no allowed file types', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show settings button when hovering', () => {
|
||||
renderWithProvider({}, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(screen.getByText(/operation\.settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open setting modal when settings is clicked', async () => {
|
||||
renderWithProvider({}, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/feature\.fileUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show supported types label when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.fileUpload\.supportedTypes/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.fileUpload\.numberLimit/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide info display when hovering over enabled feature', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
// Info display should be hidden, settings button should appear
|
||||
expect(screen.queryByText(/feature\.fileUpload\.supportedTypes/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show info display again when mouse leaves', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.mouseLeave(card)
|
||||
|
||||
expect(screen.getByText(/feature\.fileUpload\.supportedTypes/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close setting modal when cancel is clicked', async () => {
|
||||
renderWithProvider({}, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/feature\.fileUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/feature\.fileUpload\.modalTitle/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import SettingContent from './setting-content'
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', () => ({
|
||||
default: ({ payload, onChange }: { payload: Record<string, unknown>, onChange: (p: Record<string, unknown>) => void }) => (
|
||||
<div data-testid="file-upload-setting">
|
||||
<span data-testid="payload">{JSON.stringify(payload)}</span>
|
||||
<button
|
||||
data-testid="change-setting"
|
||||
onClick={() => onChange({
|
||||
...payload,
|
||||
allowed_file_types: ['document'],
|
||||
})}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
data-testid="clear-file-types"
|
||||
onClick={() => onChange({
|
||||
...payload,
|
||||
allowed_file_types: [],
|
||||
})}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/types', () => ({
|
||||
SupportUploadFileTypes: {
|
||||
image: 'image',
|
||||
},
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.jpg'],
|
||||
number_limits: 5,
|
||||
},
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { imageUpload?: boolean, onClose?: () => void, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<SettingContent
|
||||
imageUpload={props.imageUpload}
|
||||
onClose={props.onClose ?? vi.fn()}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SettingContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render file upload modal title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.fileUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render image upload modal title when imageUpload is true', () => {
|
||||
renderWithProvider({ imageUpload: true })
|
||||
|
||||
expect(screen.getByText(/feature\.imageUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render FileUploadSetting component with payload from file feature', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByTestId('file-upload-setting')).toBeInTheDocument()
|
||||
const payload = screen.getByTestId('payload')
|
||||
expect(payload.textContent).toContain('"allowed_file_upload_methods":["local_file"]')
|
||||
expect(payload.textContent).toContain('"allowed_file_types":["image"]')
|
||||
expect(payload.textContent).toContain('"allowed_file_extensions":[".jpg"]')
|
||||
expect(payload.textContent).toContain('"max_length":5')
|
||||
})
|
||||
|
||||
it('should use fallback payload values when file feature is undefined', () => {
|
||||
renderWithProvider({}, { file: undefined })
|
||||
|
||||
const payload = screen.getByTestId('payload')
|
||||
expect(payload.textContent).toContain('"allowed_file_upload_methods":["local_file","remote_url"]')
|
||||
expect(payload.textContent).toContain('"allowed_file_types":["image"]')
|
||||
expect(payload.textContent).toContain('"max_length":3')
|
||||
})
|
||||
|
||||
it('should render cancel and save buttons', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClose when close icon is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeIconButton = screen.getByTestId('close-setting-modal')
|
||||
expect(closeIconButton).toBeInTheDocument()
|
||||
if (!closeIconButton)
|
||||
throw new Error('Close icon button should exist')
|
||||
|
||||
fireEvent.click(closeIconButton)
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose when close icon receives Enter key', async () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeIconButton = screen.getByTestId('close-setting-modal')
|
||||
closeIconButton.focus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when close icon receives Space key', async () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeIconButton = screen.getByTestId('close-setting-modal')
|
||||
closeIconButton.focus()
|
||||
fireEvent.keyDown(closeIconButton, { key: ' ' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when cancel button is clicked to close', () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
// Use the cancel button to test the close behavior
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onChange when save is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not throw when save is clicked without onChange', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should disable save button when allowed file types are empty', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-file-types'))
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /operation\.save/ })
|
||||
expect(saveButton).toBeDisabled()
|
||||
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update temp payload when FileUploadSetting onChange is called', () => {
|
||||
renderWithProvider()
|
||||
|
||||
// Click the change button in mock FileUploadSetting to trigger setTempPayload
|
||||
fireEvent.click(screen.getByTestId('change-setting'))
|
||||
|
||||
// The payload should be updated with the new allowed_file_types
|
||||
const payload = screen.getByTestId('payload')
|
||||
expect(payload.textContent).toContain('document')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import type { UploadFileSetting } from '@/app/components/workflow/types'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
@@ -58,8 +57,22 @@ const SettingContent = ({
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="system-xl-semibold text-text-primary">{!imageUpload ? t('feature.fileUpload.modalTitle', { ns: 'appDebug' }) : t('feature.imageUpload.modalTitle', { ns: 'appDebug' })}</div>
|
||||
<div className="cursor-pointer p-1" onClick={onClose}><RiCloseLine className="h-4 w-4 text-text-tertiary" /></div>
|
||||
<div className="text-text-primary system-xl-semibold">{!imageUpload ? t('feature.fileUpload.modalTitle', { ns: 'appDebug' }) : t('feature.imageUpload.modalTitle', { ns: 'appDebug' })}</div>
|
||||
<div
|
||||
className="cursor-pointer p-1"
|
||||
onClick={onClose}
|
||||
data-testid="close-setting-modal"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<FileUploadSetting
|
||||
isMultiple
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Features } from '../../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import FileUploadSettings from './setting-modal'
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: () => ({ data: undefined }),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.jpg'],
|
||||
number_limits: 5,
|
||||
},
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (ui: React.ReactNode) => {
|
||||
return render(
|
||||
<FeaturesProvider features={defaultFeatures}>
|
||||
{ui}
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('FileUploadSettings (setting-modal)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render children in trigger', () => {
|
||||
renderWithProvider(
|
||||
<FileUploadSettings open={false} onOpen={vi.fn()}>
|
||||
<button>Upload Settings</button>
|
||||
</FileUploadSettings>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Upload Settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render SettingContent in portal', async () => {
|
||||
renderWithProvider(
|
||||
<FileUploadSettings open={true} onOpen={vi.fn()}>
|
||||
<button>Upload Settings</button>
|
||||
</FileUploadSettings>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/feature\.fileUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onOpen with toggle function when trigger is clicked', () => {
|
||||
const onOpen = vi.fn()
|
||||
renderWithProvider(
|
||||
<FileUploadSettings open={false} onOpen={onOpen}>
|
||||
<button>Upload Settings</button>
|
||||
</FileUploadSettings>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Upload Settings'))
|
||||
|
||||
expect(onOpen).toHaveBeenCalled()
|
||||
// The toggle function should flip the open state
|
||||
const toggleFn = onOpen.mock.calls[0][0]
|
||||
expect(typeof toggleFn).toBe('function')
|
||||
expect(toggleFn(false)).toBe(true)
|
||||
expect(toggleFn(true)).toBe(false)
|
||||
})
|
||||
|
||||
it('should not call onOpen when disabled', () => {
|
||||
const onOpen = vi.fn()
|
||||
renderWithProvider(
|
||||
<FileUploadSettings open={false} onOpen={onOpen} disabled>
|
||||
<button>Upload Settings</button>
|
||||
</FileUploadSettings>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Upload Settings'))
|
||||
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onOpen with false when cancel is clicked', async () => {
|
||||
const onOpen = vi.fn()
|
||||
renderWithProvider(
|
||||
<FileUploadSettings open={true} onOpen={onOpen}>
|
||||
<button>Upload Settings</button>
|
||||
</FileUploadSettings>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/ }))
|
||||
|
||||
expect(onOpen).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should call onChange and close when save is clicked', async () => {
|
||||
const onChange = vi.fn()
|
||||
const onOpen = vi.fn()
|
||||
renderWithProvider(
|
||||
<FileUploadSettings open={true} onOpen={onOpen} onChange={onChange}>
|
||||
<button>Upload Settings</button>
|
||||
</FileUploadSettings>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.save/ }))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
expect(onOpen).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should pass imageUpload prop to SettingContent', async () => {
|
||||
renderWithProvider(
|
||||
<FileUploadSettings open={true} onOpen={vi.fn()} imageUpload>
|
||||
<button>Upload Settings</button>
|
||||
</FileUploadSettings>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/feature\.imageUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { OnFeaturesChange } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { FeaturesProvider } from '../context'
|
||||
import FollowUp from './follow-up'
|
||||
|
||||
const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => {
|
||||
return render(
|
||||
<FeaturesProvider>
|
||||
<FollowUp disabled={props.disabled} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('FollowUp', () => {
|
||||
it('should render the follow-up feature card', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description text', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not throw when onChange is not provided', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import ImageUpload from './index'
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: () => ({ data: undefined }),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { disabled?: boolean, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<ImageUpload disabled={props.disabled ?? false} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ImageUpload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the image upload title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.imageUpload\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render LEGACY badge', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText('LEGACY')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when disabled', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.imageUpload\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show supported types when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('image')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show number limits when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show settings button when hovering', () => {
|
||||
renderWithProvider({}, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(screen.getByText(/operation\.settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open image upload setting modal when settings is clicked', async () => {
|
||||
renderWithProvider({}, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/feature\.imageUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show supported types and number limit labels when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.imageUpload\.supportedTypes/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.imageUpload\.numberLimit/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide info display when hovering', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(screen.queryByText(/feature\.imageUpload\.supportedTypes/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show info display again when mouse leaves', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.mouseLeave(card)
|
||||
|
||||
expect(screen.getByText(/feature\.imageUpload\.supportedTypes/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show dash when no file types configured', () => {
|
||||
renderWithProvider({}, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close setting modal when cancel is clicked', async () => {
|
||||
renderWithProvider({}, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/feature\.imageUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/feature\.imageUpload\.modalTitle/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Features } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../context'
|
||||
import NewFeaturePanel from './index'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/app/test-app-id/configuration',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useDefaultModel: (type: string) => {
|
||||
if (type === 'speech2text' || type === 'tts')
|
||||
return { data: { provider: 'openai', model: 'whisper-1' } }
|
||||
return { data: null }
|
||||
},
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
|
||||
modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
|
||||
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
|
||||
currentModel: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
ModelTypeEnum: {
|
||||
speech2text: 'speech2text',
|
||||
tts: 'tts',
|
||||
textEmbedding: 'text-embedding',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: () => <div data-testid="model-selector">Model Selector</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useCodeBasedExtensions: () => ({ data: undefined }),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderPanel = (props: Partial<{
|
||||
show: boolean
|
||||
isChatMode: boolean
|
||||
disabled: boolean
|
||||
onChange: () => void
|
||||
onClose: () => void
|
||||
inWorkflow: boolean
|
||||
showFileUpload: boolean
|
||||
}> = {}) => {
|
||||
return render(
|
||||
<FeaturesProvider features={defaultFeatures}>
|
||||
<NewFeaturePanel
|
||||
show={props.show ?? true}
|
||||
isChatMode={props.isChatMode ?? true}
|
||||
disabled={props.disabled ?? false}
|
||||
onChange={props.onChange}
|
||||
onClose={props.onClose ?? vi.fn()}
|
||||
inWorkflow={props.inWorkflow}
|
||||
showFileUpload={props.showFileUpload}
|
||||
/>
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('NewFeaturePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should not render when show is false', () => {
|
||||
renderPanel({ show: false })
|
||||
|
||||
expect(screen.queryByText(/common\.features/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render header with title and description when show is true', () => {
|
||||
renderPanel({ show: true })
|
||||
|
||||
expect(screen.getByText(/common\.featuresDescription/)).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/common\.features/).length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chat Mode Features', () => {
|
||||
it('should render conversation opener in chat mode', () => {
|
||||
renderPanel({ isChatMode: true })
|
||||
|
||||
expect(screen.getByText(/feature\.conversationOpener\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render follow-up in chat mode', () => {
|
||||
renderPanel({ isChatMode: true })
|
||||
|
||||
expect(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render citation in chat mode', () => {
|
||||
renderPanel({ isChatMode: true })
|
||||
|
||||
expect(screen.getByText(/feature\.citation\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render speech-to-text in chat mode when model is available', () => {
|
||||
renderPanel({ isChatMode: true })
|
||||
|
||||
expect(screen.getByText(/feature\.speechToText\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text-to-speech in chat mode when model is available', () => {
|
||||
renderPanel({ isChatMode: true })
|
||||
|
||||
expect(screen.getByText(/feature\.textToSpeech\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render moderation in chat mode', () => {
|
||||
renderPanel({ isChatMode: true })
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.title/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Upload', () => {
|
||||
it('should render file upload in chat mode with showFileUpload', () => {
|
||||
renderPanel({ isChatMode: true, showFileUpload: true })
|
||||
|
||||
expect(screen.getByText(/feature\.fileUpload\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render image upload in chat mode', () => {
|
||||
renderPanel({ isChatMode: true, showFileUpload: true })
|
||||
|
||||
expect(screen.queryByText(/feature\.imageUpload\.title/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render image upload in non-chat mode with showFileUpload', () => {
|
||||
renderPanel({ isChatMode: false, showFileUpload: true })
|
||||
|
||||
expect(screen.queryByText(/feature\.fileUpload\.title/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.imageUpload\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render file upload when showFileUpload is false', () => {
|
||||
renderPanel({ isChatMode: true, showFileUpload: false })
|
||||
|
||||
expect(screen.queryByText(/feature\.fileUpload\.title/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/feature\.imageUpload\.title/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show file upload tip in chat mode with showFileUpload', () => {
|
||||
renderPanel({ isChatMode: true, showFileUpload: true })
|
||||
|
||||
expect(screen.getByText(/common\.fileUploadTip/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show image upload legacy tip in non-chat mode with showFileUpload', () => {
|
||||
renderPanel({ isChatMode: false, showFileUpload: true })
|
||||
|
||||
expect(screen.getByText(/common\.ImageUploadLegacyTip/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MoreLikeThis Feature', () => {
|
||||
it('should render MoreLikeThis in non-chat, non-workflow mode', () => {
|
||||
renderPanel({ isChatMode: false, inWorkflow: false })
|
||||
|
||||
expect(screen.getByText(/feature\.moreLikeThis\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render MoreLikeThis in chat mode', () => {
|
||||
renderPanel({ isChatMode: true, inWorkflow: false })
|
||||
|
||||
expect(screen.queryByText(/feature\.moreLikeThis\.title/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render MoreLikeThis in workflow mode', () => {
|
||||
renderPanel({ isChatMode: false, inWorkflow: true })
|
||||
|
||||
expect(screen.queryByText(/feature\.moreLikeThis\.title/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Annotation Reply Feature', () => {
|
||||
it('should render AnnotationReply in chat mode when not in workflow', () => {
|
||||
renderPanel({ isChatMode: true, inWorkflow: false })
|
||||
|
||||
expect(screen.getByText(/feature\.annotation\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render AnnotationReply in workflow mode', () => {
|
||||
renderPanel({ isChatMode: true, inWorkflow: true })
|
||||
|
||||
expect(screen.queryByText(/feature\.annotation\.title/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should not show file upload tip when showFileUpload is false', () => {
|
||||
renderPanel({ isChatMode: true, showFileUpload: false })
|
||||
|
||||
expect(screen.queryByText(/common\.fileUploadTip/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { I18nText } from '@/i18n-config/language'
|
||||
import type { CodeBasedExtensionForm } from '@/models/common'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import FormGeneration from './form-generation'
|
||||
|
||||
const i18n = (en: string, zh = en): I18nText =>
|
||||
({ 'en-US': en, 'zh-Hans': zh }) as unknown as I18nText
|
||||
|
||||
const createForm = (overrides: Partial<CodeBasedExtensionForm> = {}): CodeBasedExtensionForm => ({
|
||||
type: 'text-input',
|
||||
variable: 'api_key',
|
||||
label: i18n('API Key', 'API 密钥'),
|
||||
placeholder: 'Enter API key',
|
||||
required: true,
|
||||
options: [],
|
||||
default: '',
|
||||
max_length: 100,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('FormGeneration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render text-input form fields', () => {
|
||||
const form = createForm()
|
||||
render(<FormGeneration forms={[form]} value={{}} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('API Key')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Enter API key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when text input value changes', () => {
|
||||
const onChange = vi.fn()
|
||||
const form = createForm()
|
||||
render(<FormGeneration forms={[form]} value={{}} onChange={onChange} />)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Enter API key'), {
|
||||
target: { value: 'my-key' },
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ api_key: 'my-key' })
|
||||
})
|
||||
|
||||
it('should render paragraph form fields', () => {
|
||||
const form = createForm({
|
||||
type: 'paragraph',
|
||||
variable: 'description',
|
||||
label: i18n('Description', '描述'),
|
||||
placeholder: 'Enter description',
|
||||
})
|
||||
render(<FormGeneration forms={[form]} value={{}} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Description')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Enter description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render select form fields', () => {
|
||||
const form = createForm({
|
||||
type: 'select',
|
||||
variable: 'model',
|
||||
label: i18n('Model', '模型'),
|
||||
options: [
|
||||
{ label: i18n('GPT-4'), value: 'gpt-4' },
|
||||
{ label: i18n('GPT-3.5'), value: 'gpt-3.5' },
|
||||
],
|
||||
})
|
||||
render(<FormGeneration forms={[form]} value={{}} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Model')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple forms', () => {
|
||||
const forms = [
|
||||
createForm({ variable: 'key1', label: i18n('Field 1', '字段1') }),
|
||||
createForm({ variable: 'key2', label: i18n('Field 2', '字段2'), type: 'paragraph' }),
|
||||
]
|
||||
render(<FormGeneration forms={forms} value={{}} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Field 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Field 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display existing values', () => {
|
||||
const form = createForm()
|
||||
render(
|
||||
<FormGeneration
|
||||
forms={[form]}
|
||||
value={{ api_key: 'existing-key' }}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when paragraph textarea value changes', () => {
|
||||
const onChange = vi.fn()
|
||||
const form = createForm({
|
||||
type: 'paragraph',
|
||||
variable: 'description',
|
||||
label: i18n('Description', '描述'),
|
||||
placeholder: 'Enter description',
|
||||
})
|
||||
render(<FormGeneration forms={[form]} value={{}} onChange={onChange} />)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Enter description'), {
|
||||
target: { value: 'my description' },
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ description: 'my description' })
|
||||
})
|
||||
|
||||
it('should call onChange when select option is chosen', () => {
|
||||
const onChange = vi.fn()
|
||||
const form = createForm({
|
||||
type: 'select',
|
||||
variable: 'model',
|
||||
label: i18n('Model', '模型'),
|
||||
options: [
|
||||
{ label: i18n('GPT-4'), value: 'gpt-4' },
|
||||
{ label: i18n('GPT-3.5'), value: 'gpt-3.5' },
|
||||
],
|
||||
})
|
||||
render(<FormGeneration forms={[form]} value={{}} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/placeholder\.select/))
|
||||
fireEvent.click(screen.getByText('GPT-4'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ model: 'gpt-4' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,427 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import Moderation from './index'
|
||||
|
||||
const mockSetShowModerationSettingModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowModerationSettingModal: mockSetShowModerationSettingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useCodeBasedExtensions: () => ({ data: { data: [] } }),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { disabled?: boolean, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<Moderation disabled={props.disabled} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Moderation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the moderation title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when not enabled', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open moderation setting modal when enabled without type', () => {
|
||||
renderWithProvider()
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(mockSetShowModerationSettingModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show provider info when enabled with openai_moderation type', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'openai_moderation',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.provider\.openai/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show provider info when enabled with keywords type', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.provider\.keywords/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show allEnabled when both inputs and outputs are enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.allEnabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show inputEnabled when only inputs are enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.inputEnabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show outputEnabled when only outputs are enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: false, preset_response: '' },
|
||||
outputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.outputEnabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show settings button when hovering over enabled feature', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(screen.getByText(/operation\.settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open moderation modal when settings button is clicked', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
expect(mockSetShowModerationSettingModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not open modal when disabled', () => {
|
||||
renderWithProvider({ disabled: true }, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
expect(mockSetShowModerationSettingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show api provider label when type is api', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'api',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/apiBasedExtension\.selector\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable moderation and call onChange when switch is toggled off', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange }, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open modal with default config when enabling without existing type', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(mockSetShowModerationSettingModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should invoke onSaveCallback from modal and update features', () => {
|
||||
renderWithProvider()
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
// Extract the onSaveCallback from the modal call
|
||||
const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0]
|
||||
expect(modalCall.onSaveCallback).toBeDefined()
|
||||
expect(modalCall.onCancelCallback).toBeDefined()
|
||||
})
|
||||
|
||||
it('should invoke onCancelCallback from settings modal', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange }, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0]
|
||||
modalCall.onCancelCallback()
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invoke onSaveCallback from settings modal', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange }, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0]
|
||||
modalCall.onSaveCallback({ enabled: true, type: 'keywords', config: {} })
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show code-based extension label for custom type', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'custom-ext',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// For unknown types, falls back to codeBasedExtensionList label or '-'
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open setting modal when clicking settings button while disabled', () => {
|
||||
renderWithProvider({ disabled: true }, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
// disabled check in handleOpenModerationSettingModal should prevent call
|
||||
expect(mockSetShowModerationSettingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invoke onSaveCallback from enable modal and update features', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0]
|
||||
// Execute the onSaveCallback
|
||||
modalCall.onSaveCallback({ enabled: true, type: 'keywords', config: {} })
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invoke onCancelCallback from enable modal and set enabled false', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0]
|
||||
// Execute the onCancelCallback
|
||||
modalCall.onCancelCallback()
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not show modal when enabling with existing type', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: false,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
// When type already exists, handleChange's first if-branch is skipped
|
||||
// because features.moderation.type is already 'keywords'
|
||||
// It should NOT call setShowModerationSettingModal for init
|
||||
expect(mockSetShowModerationSettingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should hide info display when hovering over enabled feature', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')!
|
||||
|
||||
// Info is visible before hover
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.provider\.keywords/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
// Info hidden, settings button shown
|
||||
expect(screen.getByText(/operation\.settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show info display again when mouse leaves', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.mouseLeave(card)
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.provider\.keywords/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ModerationContentConfig } from '@/models/debug'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ModerationContent from './moderation-content'
|
||||
|
||||
const defaultConfig: ModerationContentConfig = {
|
||||
enabled: false,
|
||||
preset_response: '',
|
||||
}
|
||||
|
||||
const renderComponent = (props: Partial<{
|
||||
title: string
|
||||
info: string
|
||||
showPreset: boolean
|
||||
config: ModerationContentConfig
|
||||
onConfigChange: (config: ModerationContentConfig) => void
|
||||
}> = {}) => {
|
||||
const onConfigChange = props.onConfigChange ?? vi.fn()
|
||||
return render(
|
||||
<ModerationContent
|
||||
title={props.title ?? 'Test Title'}
|
||||
info={props.info}
|
||||
showPreset={props.showPreset}
|
||||
config={props.config ?? defaultConfig}
|
||||
onConfigChange={onConfigChange}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ModerationContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the title', () => {
|
||||
renderComponent({ title: 'Input Content' })
|
||||
|
||||
expect(screen.getByText('Input Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render info text when provided', () => {
|
||||
renderComponent({ info: 'Some info text' })
|
||||
|
||||
expect(screen.getByText('Some info text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render info when not provided', () => {
|
||||
renderComponent()
|
||||
|
||||
// When info is not provided, only the title "Test Title" should be shown
|
||||
expect(screen.getByText(/Test Title/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Some info text/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onConfigChange with enabled true when switch is toggled on', () => {
|
||||
const onConfigChange = vi.fn()
|
||||
renderComponent({ onConfigChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onConfigChange).toHaveBeenCalledWith({ ...defaultConfig, enabled: true })
|
||||
})
|
||||
|
||||
it('should show preset textarea when enabled and showPreset is true', () => {
|
||||
renderComponent({
|
||||
config: { enabled: true, preset_response: '' },
|
||||
showPreset: true,
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.content\.preset/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show preset textarea when showPreset is false', () => {
|
||||
renderComponent({
|
||||
config: { enabled: true, preset_response: '' },
|
||||
showPreset: false,
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onConfigChange when preset_response is changed', () => {
|
||||
const onConfigChange = vi.fn()
|
||||
renderComponent({
|
||||
config: { enabled: true, preset_response: '' },
|
||||
onConfigChange,
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test response' } })
|
||||
|
||||
expect(onConfigChange).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
preset_response: 'test response',
|
||||
})
|
||||
})
|
||||
|
||||
it('should truncate preset_response to 100 characters', () => {
|
||||
const onConfigChange = vi.fn()
|
||||
const longText = 'a'.repeat(150)
|
||||
renderComponent({
|
||||
config: { enabled: true, preset_response: '' },
|
||||
onConfigChange,
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: longText } })
|
||||
|
||||
expect(onConfigChange).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
preset_response: 'a'.repeat(100),
|
||||
})
|
||||
})
|
||||
|
||||
it('should display character count', () => {
|
||||
renderComponent({
|
||||
config: { enabled: true, preset_response: 'hello' },
|
||||
})
|
||||
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
expect(screen.getByText('100')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,787 @@
|
||||
import type { ModerationConfig } from '@/models/debug'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ModerationSettingModal from './moderation-setting-modal'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockCodeBasedExtensions: { data: { data: Record<string, unknown>[] } } = { data: { data: [] } }
|
||||
let mockModelProvidersData: {
|
||||
data: { data: Record<string, unknown>[] }
|
||||
isPending: boolean
|
||||
refetch: ReturnType<typeof vi.fn>
|
||||
} = {
|
||||
data: {
|
||||
data: [{
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: 'paid',
|
||||
quota_configurations: [{ quota_type: 'paid', is_valid: true }],
|
||||
},
|
||||
custom_configuration: { status: 'active' },
|
||||
}],
|
||||
},
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useCodeBasedExtensions: () => mockCodeBasedExtensions,
|
||||
useModelProviders: () => mockModelProvidersData,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
CustomConfigurationStatusEnum: { active: 'active' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/constants', () => ({
|
||||
ACCOUNT_SETTING_TAB: { PROVIDER: 'provider' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({
|
||||
default: ({ onChange }: { value: string, onChange: (v: string) => void }) => (
|
||||
<div data-testid="api-selector">
|
||||
<button data-testid="select-api" onClick={() => onChange('api-ext-1')}>Select API</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const defaultData: ModerationConfig = {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
keywords: 'bad\nword',
|
||||
inputs_config: { enabled: true, preset_response: 'Input blocked' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
|
||||
describe('ModerationSettingModal', () => {
|
||||
const onSave = vi.fn()
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCodeBasedExtensions = { data: { data: [] } }
|
||||
mockModelProvidersData = {
|
||||
data: {
|
||||
data: [{
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: 'paid',
|
||||
quota_configurations: [{ quota_type: 'paid', is_valid: true }],
|
||||
},
|
||||
custom_configuration: { status: 'active' },
|
||||
}],
|
||||
},
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should render the modal title', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render provider options', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.provider\.openai/)).toBeInTheDocument()
|
||||
// Keywords text appears both as provider option and section label
|
||||
expect(screen.getAllByText(/feature\.moderation\.modal\.provider\.keywords/).length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText(/apiBasedExtension\.selector\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show keywords textarea when keywords type is selected', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) as HTMLTextAreaElement
|
||||
expect(textarea).toBeInTheDocument()
|
||||
expect(textarea).toHaveValue('bad\nword')
|
||||
})
|
||||
|
||||
it('should render cancel and save buttons', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel is clicked', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={onCancel}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error when saving without inputs or outputs enabled', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: 'test',
|
||||
inputs_config: { enabled: false, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should show error when keywords type has no keywords', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: '',
|
||||
inputs_config: { enabled: true, preset_response: 'blocked' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onSave with formatted data when valid', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: 'bad\nword',
|
||||
inputs_config: { enabled: true, preset_response: 'blocked' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'keywords',
|
||||
enabled: true,
|
||||
config: expect.objectContaining({
|
||||
keywords: 'bad\nword',
|
||||
inputs_config: expect.objectContaining({ enabled: true }),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should show api selector when api type is selected', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'api', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('api-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch provider type when clicked', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click on openai_moderation provider
|
||||
fireEvent.click(screen.getByText(/feature\.moderation\.modal\.provider\.openai/))
|
||||
|
||||
// The keywords textarea should no longer be visible since type changed
|
||||
expect(screen.queryByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update keywords on textarea change', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) as HTMLTextAreaElement
|
||||
fireEvent.change(textarea, { target: { value: 'new\nkeywords' } })
|
||||
|
||||
expect(textarea).toHaveValue('new\nkeywords')
|
||||
})
|
||||
|
||||
it('should render moderation content sections', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.content\.input/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.content\.output/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error when inputs enabled but no preset_response for keywords type', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: 'test',
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should show error when api type has no api_based_extension_id', async () => {
|
||||
const data: ModerationConfig = {
|
||||
enabled: true,
|
||||
type: 'api',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should save with api_based_extension_id in formatted data for api type', async () => {
|
||||
const data: ModerationConfig = {
|
||||
enabled: true,
|
||||
type: 'api',
|
||||
config: {
|
||||
api_based_extension_id: 'ext-1',
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
// api type doesn't require preset_response, so save should succeed
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'api',
|
||||
config: expect.objectContaining({
|
||||
api_based_extension_id: 'ext-1',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should show error when outputs enabled but no preset_response for keywords type', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: 'test',
|
||||
inputs_config: { enabled: false, preset_response: '' },
|
||||
outputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should toggle input moderation content', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const switches = screen.getAllByRole('switch')
|
||||
expect(screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(1)
|
||||
|
||||
fireEvent.click(switches[0])
|
||||
|
||||
expect(screen.queryAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should toggle output moderation content', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const switches = screen.getAllByRole('switch')
|
||||
expect(screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(1)
|
||||
|
||||
fireEvent.click(switches[1])
|
||||
|
||||
expect(screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should select api extension via api selector', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'api', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-api'))
|
||||
|
||||
// Trigger save and confirm the chosen extension id is passed through
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({ api_based_extension_id: 'api-ext-1' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should save with openai_moderation type when configured', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{
|
||||
enabled: true,
|
||||
type: 'openai_moderation',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: 'blocked' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'openai_moderation',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle keyword truncation to 100 chars per line and 100 lines', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/)
|
||||
// Create a long keyword that exceeds 100 chars
|
||||
const longWord = 'a'.repeat(150)
|
||||
fireEvent.change(textarea, { target: { value: longWord } })
|
||||
|
||||
// Value should be truncated to 100 chars
|
||||
expect((textarea as HTMLTextAreaElement).value.length).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
it('should save with formatted outputs_config when both enabled', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: 'test',
|
||||
inputs_config: { enabled: true, preset_response: 'input blocked' },
|
||||
outputs_config: { enabled: true, preset_response: 'output blocked' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
inputs_config: expect.objectContaining({ enabled: true }),
|
||||
outputs_config: expect.objectContaining({ enabled: true }),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should switch from keywords to api type', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click api provider
|
||||
fireEvent.click(screen.getByText(/apiBasedExtension\.selector\.title/))
|
||||
|
||||
// API selector should now be visible, keywords textarea should be hidden
|
||||
expect(screen.getByTestId('api-selector')).toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty lines in keywords', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) as HTMLTextAreaElement
|
||||
fireEvent.change(textarea, { target: { value: 'word1\n\nword2\n\n' } })
|
||||
|
||||
expect(textarea.value).toBe('word1\n\nword2\n')
|
||||
})
|
||||
|
||||
it('should show OpenAI not configured warning when OpenAI provider is not set up', async () => {
|
||||
mockModelProvidersData = {
|
||||
data: {
|
||||
data: [{
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: 'free',
|
||||
quota_configurations: [],
|
||||
},
|
||||
custom_configuration: { status: 'no-configure' },
|
||||
}],
|
||||
},
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'openai_moderation', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.openaiNotConfig\.before/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open settings modal when provider link is clicked in OpenAI warning', async () => {
|
||||
mockModelProvidersData = {
|
||||
data: {
|
||||
data: [{
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: 'free',
|
||||
quota_configurations: [],
|
||||
},
|
||||
custom_configuration: { status: 'no-configure' },
|
||||
}],
|
||||
},
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'openai_moderation', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/settings\.provider/))
|
||||
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not save when OpenAI type is selected but not configured', async () => {
|
||||
mockModelProvidersData = {
|
||||
data: {
|
||||
data: [{
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: 'free',
|
||||
quota_configurations: [],
|
||||
},
|
||||
custom_configuration: { status: 'no-configure' },
|
||||
}],
|
||||
},
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'openai_moderation', config: { inputs_config: { enabled: true, preset_response: 'blocked' }, outputs_config: { enabled: false, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render code-based extension providers', async () => {
|
||||
mockCodeBasedExtensions = {
|
||||
data: {
|
||||
data: [{
|
||||
name: 'custom-ext',
|
||||
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
|
||||
form_schema: [
|
||||
{ variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: 'Enter URL', options: [], max_length: 200 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Custom Extension')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show form generation when code-based extension is selected', async () => {
|
||||
mockCodeBasedExtensions = {
|
||||
data: {
|
||||
data: [{
|
||||
name: 'custom-ext',
|
||||
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
|
||||
form_schema: [
|
||||
{ variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: 'Enter URL', options: [], max_length: 200 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'custom-ext', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('API URL')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Enter URL')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should initialize config from form schema when switching to code-based extension', async () => {
|
||||
mockCodeBasedExtensions = {
|
||||
data: {
|
||||
data: [{
|
||||
name: 'custom-ext',
|
||||
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
|
||||
form_schema: [
|
||||
{ variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: 'https://default.com', placeholder: '', options: [], max_length: 200 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click on the custom extension provider
|
||||
fireEvent.click(screen.getByText('Custom Extension'))
|
||||
|
||||
// The form input should use the default value from form schema
|
||||
expect(screen.getByDisplayValue('https://default.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error when required form schema field is empty on save', async () => {
|
||||
mockCodeBasedExtensions = {
|
||||
data: {
|
||||
data: [{
|
||||
name: 'custom-ext',
|
||||
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
|
||||
form_schema: [
|
||||
{ variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: '', options: [], max_length: 200 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'custom-ext', config: { inputs_config: { enabled: true, preset_response: 'blocked' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should save with code-based extension config when valid', async () => {
|
||||
mockCodeBasedExtensions = {
|
||||
data: {
|
||||
data: [{
|
||||
name: 'custom-ext',
|
||||
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
|
||||
form_schema: [
|
||||
{ variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: '', options: [], max_length: 200 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'custom-ext', config: { api_url: 'https://example.com', inputs_config: { enabled: true, preset_response: 'blocked' }, outputs_config: { enabled: false, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'custom-ext',
|
||||
config: expect.objectContaining({
|
||||
api_url: 'https://example.com',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should show doc link for api type', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'api', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/apiBasedExtension\.link/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import type { CodeBasedExtensionItem } from '@/models/common'
|
||||
import type { ModerationConfig, ModerationContentConfig } from '@/models/debug'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
|
||||
import { InfoCircle } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
|
||||
@@ -238,8 +235,21 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
className="!mt-14 !w-[600px] !max-w-none !p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="title-2xl-semi-bold text-text-primary">{t('feature.moderation.modal.title', { ns: 'appDebug' })}</div>
|
||||
<div className="cursor-pointer p-1" onClick={onCancel}><RiCloseLine className="h-4 w-4 text-text-tertiary" /></div>
|
||||
<div className="text-text-primary title-2xl-semi-bold">{t('feature.moderation.modal.title', { ns: 'appDebug' })}</div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="cursor-pointer p-1"
|
||||
onClick={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<div className="text-sm font-medium leading-9 text-text-primary">
|
||||
@@ -251,9 +261,9 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
<div
|
||||
key={provider.key}
|
||||
className={cn(
|
||||
'system-sm-regular flex h-8 cursor-default items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 text-text-secondary',
|
||||
'flex h-8 cursor-default items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 text-text-secondary system-sm-regular',
|
||||
localeData.type !== provider.key && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
|
||||
localeData.type === provider.key && 'system-sm-medium border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs',
|
||||
localeData.type === provider.key && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs system-sm-medium',
|
||||
localeData.type === 'openai_moderation' && provider.key === 'openai_moderation' && !isOpenAIProviderConfigured && 'text-text-disabled',
|
||||
)}
|
||||
onClick={() => handleDataTypeChange(provider.key)}
|
||||
@@ -272,7 +282,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
{
|
||||
!isLoading && !isOpenAIProviderConfigured && localeData.type === 'openai_moderation' && (
|
||||
<div className="mt-2 flex items-center rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 py-2">
|
||||
<InfoCircle className="mr-1 h-4 w-4 text-[#F79009]" />
|
||||
<span className="i-custom-vender-line-general-info-circle mr-1 h-4 w-4 text-[#F79009]" />
|
||||
<div className="flex items-center text-xs font-medium text-gray-700">
|
||||
{t('feature.moderation.modal.openaiNotConfig.before', { ns: 'appDebug' })}
|
||||
<span
|
||||
@@ -324,7 +334,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center text-xs text-text-tertiary hover:text-primary-600"
|
||||
>
|
||||
<BookOpen01 className="mr-1 h-3 w-3 text-text-tertiary group-hover:text-primary-600" />
|
||||
<span className="i-custom-vender-line-education-book-open-01 mr-1 h-3 w-3 text-text-tertiary group-hover:text-primary-600" />
|
||||
{t('apiBasedExtension.link', { ns: 'common' })}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { OnFeaturesChange } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { FeaturesProvider } from '../context'
|
||||
import MoreLikeThis from './more-like-this'
|
||||
|
||||
const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => {
|
||||
return render(
|
||||
<FeaturesProvider>
|
||||
<MoreLikeThis disabled={props.disabled} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('MoreLikeThis', () => {
|
||||
it('should render the more-like-this feature card', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.moreLikeThis\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description text', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.moreLikeThis\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not throw when onChange is not provided', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render tooltip for the feature', () => {
|
||||
renderWithProvider()
|
||||
|
||||
// MoreLikeThis has a tooltip prop, verifying the feature renders with title
|
||||
expect(screen.getByText(/feature\.moreLikeThis\.title/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { OnFeaturesChange } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { FeaturesProvider } from '../context'
|
||||
import SpeechToText from './speech-to-text'
|
||||
|
||||
const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => {
|
||||
return render(
|
||||
<FeaturesProvider>
|
||||
<SpeechToText disabled={props.disabled ?? false} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SpeechToText', () => {
|
||||
it('should render the speech-to-text feature card', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.speechToText\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description text', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.speechToText\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not throw when onChange is not provided', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TtsAutoPlay } from '@/types/app'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import TextToSpeech from './index'
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
languages: [
|
||||
{ value: 'en-US', name: 'English', example: 'Hello world' },
|
||||
{ value: 'zh-Hans', name: '中文', example: '你好' },
|
||||
],
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { disabled?: boolean, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<TextToSpeech disabled={props.disabled ?? false} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('TextToSpeech', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the text-to-speech title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.textToSpeech\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when disabled', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.textToSpeech\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show language and voice info when enabled and not hovering', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US', voice: 'alloy' },
|
||||
})
|
||||
|
||||
expect(screen.getByText('English')).toBeInTheDocument()
|
||||
expect(screen.getByText('alloy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show default display text when voice is not set', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US' },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/voice\.defaultDisplay/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show voice settings button when hovering', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true },
|
||||
})
|
||||
|
||||
// Simulate mouse enter on the feature card
|
||||
const card = screen.getByText(/feature\.textToSpeech\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show autoPlay enabled text when autoPlay is enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US', autoPlay: TtsAutoPlay.enabled },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.autoPlayEnabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show autoPlay disabled text when autoPlay is not enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US' },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.autoPlayDisabled/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,349 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { TtsAutoPlay } from '@/types/app'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import ParamConfigContent from './param-config-content'
|
||||
|
||||
let mockLanguages = [
|
||||
{ value: 'en-US', name: 'English', example: 'Hello world' },
|
||||
{ value: 'zh-Hans', name: '中文', example: '你好' },
|
||||
]
|
||||
|
||||
let mockPathname = '/app/test-app-id/configuration'
|
||||
|
||||
let mockVoiceItems: { value: string, name: string }[] | undefined = [
|
||||
{ value: 'alloy', name: 'Alloy' },
|
||||
{ value: 'echo', name: 'Echo' },
|
||||
]
|
||||
|
||||
const mockUseAppVoices = vi.fn((_appId: string, _language?: string) => ({
|
||||
data: mockVoiceItems,
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => mockPathname,
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
get languages() {
|
||||
return mockLanguages
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useAppVoices: (appId: string, language?: string) => mockUseAppVoices(appId, language),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: true, language: 'en-US', voice: 'alloy', autoPlay: TtsAutoPlay.disabled },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { onClose?: () => void, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<ParamConfigContent
|
||||
onClose={props.onClose ?? vi.fn()}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ParamConfigContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPathname = '/app/test-app-id/configuration'
|
||||
mockLanguages = [
|
||||
{ value: 'en-US', name: 'English', example: 'Hello world' },
|
||||
{ value: 'zh-Hans', name: '中文', example: '你好' },
|
||||
]
|
||||
mockVoiceItems = [
|
||||
{ value: 'alloy', name: 'Alloy' },
|
||||
{ value: 'echo', name: 'Echo' },
|
||||
]
|
||||
})
|
||||
|
||||
// Rendering states and static UI sections.
|
||||
describe('Rendering', () => {
|
||||
it('should render voice settings title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render language label', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.language/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render voice label', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.voice/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render autoPlay toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.autoPlay/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tooltip icon for language', () => {
|
||||
renderWithProvider()
|
||||
|
||||
const languageLabel = screen.getByText(/voice\.voiceSettings\.language/)
|
||||
expect(languageLabel).toBeInTheDocument()
|
||||
const tooltip = languageLabel.parentElement as HTMLElement
|
||||
expect(tooltip.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display language listbox button', () => {
|
||||
renderWithProvider()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should display current voice in listbox button', () => {
|
||||
renderWithProvider()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
|
||||
expect(voiceButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render audition button when language has example', () => {
|
||||
renderWithProvider()
|
||||
|
||||
const auditionButton = screen.queryByTestId('audition-button')
|
||||
expect(auditionButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render audition button when language has no example', () => {
|
||||
mockLanguages = [
|
||||
{ value: 'en-US', name: 'English', example: '' },
|
||||
{ value: 'zh-Hans', name: '中文', example: '' },
|
||||
]
|
||||
|
||||
renderWithProvider()
|
||||
|
||||
const auditionButton = screen.queryByTestId('audition-button')
|
||||
expect(auditionButton).toBeNull()
|
||||
})
|
||||
|
||||
it('should render with no language set and use first as default', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: '', voice: '', autoPlay: TtsAutoPlay.disabled },
|
||||
})
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render with no voice set and use first as default', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US', voice: 'nonexistent', autoPlay: TtsAutoPlay.disabled },
|
||||
})
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
|
||||
expect(voiceButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User-triggered behavior and callbacks.
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClose when close button is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i })
|
||||
await userEvent.click(closeButton)
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose when close button receives Enter key', async () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i })
|
||||
await userEvent.click(closeButton)
|
||||
onClose.mockClear()
|
||||
closeButton.focus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onClose when close button receives unrelated key', async () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i })
|
||||
closeButton.focus()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toggle autoPlay switch and call onChange', async () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
await userEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set autoPlay to disabled when toggled off from enabled state', async () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider(
|
||||
{ onChange },
|
||||
{ text2speech: { enabled: true, language: 'en-US', voice: 'alloy', autoPlay: TtsAutoPlay.enabled } },
|
||||
)
|
||||
|
||||
const autoPlaySwitch = screen.getByRole('switch')
|
||||
expect(autoPlaySwitch).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
await userEvent.click(autoPlaySwitch)
|
||||
|
||||
expect(autoPlaySwitch).toHaveAttribute('aria-checked', 'false')
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call feature update without onChange callback', async () => {
|
||||
renderWithProvider()
|
||||
|
||||
await userEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open language listbox and show options', async () => {
|
||||
renderWithProvider()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.'))
|
||||
expect(languageButton).toBeDefined()
|
||||
await userEvent.click(languageButton!)
|
||||
|
||||
const options = await screen.findAllByRole('option')
|
||||
expect(options.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('should handle language change', async () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.'))
|
||||
expect(languageButton).toBeDefined()
|
||||
await userEvent.click(languageButton!)
|
||||
const options = await screen.findAllByRole('option')
|
||||
expect(options.length).toBeGreaterThan(1)
|
||||
await userEvent.click(options[1])
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle voice change', async () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
|
||||
expect(voiceButton).toBeDefined()
|
||||
await userEvent.click(voiceButton!)
|
||||
const options = await screen.findAllByRole('option')
|
||||
expect(options.length).toBeGreaterThan(1)
|
||||
await userEvent.click(options[1])
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show selected language option in listbox', async () => {
|
||||
renderWithProvider()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.'))
|
||||
expect(languageButton).toBeDefined()
|
||||
await userEvent.click(languageButton!)
|
||||
const options = await screen.findAllByRole('option')
|
||||
expect(options.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const selectedOption = options.find(opt => opt.textContent?.includes('voice.language.enUS'))
|
||||
expect(selectedOption).toBeDefined()
|
||||
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('should show selected voice option in listbox', async () => {
|
||||
renderWithProvider()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
|
||||
expect(voiceButton).toBeDefined()
|
||||
await userEvent.click(voiceButton!)
|
||||
const options = await screen.findAllByRole('option')
|
||||
expect(options.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const selectedOption = options.find(opt => opt.textContent?.includes('Alloy'))
|
||||
expect(selectedOption).toBeDefined()
|
||||
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
// Fallback and boundary scenarios.
|
||||
describe('Edge Cases', () => {
|
||||
it('should show placeholder and disable voice selection when no languages are available', () => {
|
||||
mockLanguages = []
|
||||
mockVoiceItems = undefined
|
||||
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US', voice: 'alloy', autoPlay: TtsAutoPlay.disabled },
|
||||
})
|
||||
|
||||
const placeholderTexts = screen.getAllByText(/placeholder\.select/)
|
||||
expect(placeholderTexts.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
const disabledButtons = screen
|
||||
.getAllByRole('button')
|
||||
.filter(button => button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true')
|
||||
|
||||
expect(disabledButtons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should call useAppVoices with empty appId when pathname has no app segment', () => {
|
||||
mockPathname = '/configuration'
|
||||
|
||||
renderWithProvider()
|
||||
|
||||
expect(mockUseAppVoices).toHaveBeenCalledWith('', 'en-US')
|
||||
})
|
||||
|
||||
it('should render language text when selected language value is empty string', () => {
|
||||
mockLanguages = [{ value: '' as string, name: 'Unknown Language', example: '' }]
|
||||
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: '', voice: '', autoPlay: TtsAutoPlay.disabled },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/voice\.language\./)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,6 @@
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react'
|
||||
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { produce } from 'immer'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
@@ -67,11 +65,25 @@ const VoiceParamConfig = ({
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="system-xl-semibold text-text-primary">{t('voice.voiceSettings.title', { ns: 'appDebug' })}</div>
|
||||
<div className="cursor-pointer p-1" onClick={onClose}><RiCloseLine className="h-4 w-4 text-text-tertiary" /></div>
|
||||
<div className="text-text-primary system-xl-semibold">{t('voice.voiceSettings.title', { ns: 'appDebug' })}</div>
|
||||
<div
|
||||
className="cursor-pointer p-1"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('appDebug:voice.voiceSettings.close')}
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<div className="system-sm-semibold mb-1 flex items-center py-1 text-text-secondary">
|
||||
<div className="mb-1 flex items-center py-1 text-text-secondary system-sm-semibold">
|
||||
{t('voice.voiceSettings.language', { ns: 'appDebug' })}
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
@@ -103,10 +115,7 @@ const VoiceParamConfig = ({
|
||||
: localLanguagePlaceholder}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronDownIcon
|
||||
className="h-4 w-4 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="i-heroicons-chevron-down-20-solid h-4 w-4 text-text-tertiary" aria-hidden="true" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
@@ -137,7 +146,7 @@ const VoiceParamConfig = ({
|
||||
<span
|
||||
className={cn('absolute inset-y-0 right-0 flex items-center pr-4 text-text-secondary')}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="i-heroicons-check-20-solid h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
@@ -150,7 +159,7 @@ const VoiceParamConfig = ({
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<div className="system-sm-semibold mb-1 py-1 text-text-secondary">
|
||||
<div className="mb-1 py-1 text-text-secondary system-sm-semibold">
|
||||
{t('voice.voiceSettings.voice', { ns: 'appDebug' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -173,10 +182,7 @@ const VoiceParamConfig = ({
|
||||
{voiceItem?.name ?? localVoicePlaceholder}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronDownIcon
|
||||
className="h-4 w-4 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="i-heroicons-chevron-down-20-solid h-4 w-4 text-text-tertiary" aria-hidden="true" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
@@ -203,7 +209,7 @@ const VoiceParamConfig = ({
|
||||
<span
|
||||
className={cn('absolute inset-y-0 right-0 flex items-center pr-4 text-text-secondary')}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="i-heroicons-check-20-solid h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
@@ -215,7 +221,7 @@ const VoiceParamConfig = ({
|
||||
</div>
|
||||
</Listbox>
|
||||
{languageItem?.example && (
|
||||
<div className="h-8 shrink-0 rounded-lg bg-components-button-tertiary-bg p-1">
|
||||
<div className="h-8 shrink-0 rounded-lg bg-components-button-tertiary-bg p-1" data-testid="audition-button">
|
||||
<AudioBtn
|
||||
value={languageItem?.example}
|
||||
isAudition
|
||||
@@ -227,7 +233,7 @@ const VoiceParamConfig = ({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="system-sm-semibold mb-1 py-1 text-text-secondary">
|
||||
<div className="mb-1 py-1 text-text-secondary system-sm-semibold">
|
||||
{t('voice.voiceSettings.autoPlay', { ns: 'appDebug' })}
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Features } from '../../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import VoiceSettings from './voice-settings'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/app/test-app-id/configuration',
|
||||
useParams: () => ({ appId: 'test-app-id' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useAppVoices: () => ({
|
||||
data: [{ name: 'alloy', value: 'alloy' }],
|
||||
}),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: true, language: 'en-US', voice: 'alloy' },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (ui: React.ReactNode) => {
|
||||
return render(
|
||||
<FeaturesProvider features={defaultFeatures}>
|
||||
{ui}
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('VoiceSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render children in trigger', () => {
|
||||
renderWithProvider(
|
||||
<VoiceSettings open={false} onOpen={vi.fn()}>
|
||||
<button>Settings</button>
|
||||
</VoiceSettings>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ParamConfigContent in portal', () => {
|
||||
renderWithProvider(
|
||||
<VoiceSettings open={true} onOpen={vi.fn()}>
|
||||
<button>Settings</button>
|
||||
</VoiceSettings>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onOpen with toggle function when trigger is clicked', () => {
|
||||
const onOpen = vi.fn()
|
||||
renderWithProvider(
|
||||
<VoiceSettings open={false} onOpen={onOpen}>
|
||||
<button>Settings</button>
|
||||
</VoiceSettings>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Settings'))
|
||||
|
||||
expect(onOpen).toHaveBeenCalled()
|
||||
// The toggle function should flip the open state
|
||||
const toggleFn = onOpen.mock.calls[0][0]
|
||||
expect(typeof toggleFn).toBe('function')
|
||||
expect(toggleFn(false)).toBe(true)
|
||||
expect(toggleFn(true)).toBe(false)
|
||||
})
|
||||
|
||||
it('should not call onOpen when disabled and trigger is clicked', () => {
|
||||
const onOpen = vi.fn()
|
||||
renderWithProvider(
|
||||
<VoiceSettings open={false} onOpen={onOpen} disabled>
|
||||
<button>Settings</button>
|
||||
</VoiceSettings>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Settings'))
|
||||
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onOpen with false when close is clicked', () => {
|
||||
const onOpen = vi.fn()
|
||||
renderWithProvider(
|
||||
<VoiceSettings open={true} onOpen={onOpen}>
|
||||
<button>Settings</button>
|
||||
</VoiceSettings>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /voice\.voiceSettings\.close/ }))
|
||||
|
||||
expect(onOpen).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
180
web/app/components/base/features/store.spec.ts
Normal file
180
web/app/components/base/features/store.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
import { createFeaturesStore } from './store'
|
||||
|
||||
describe('createFeaturesStore', () => {
|
||||
describe('Default State', () => {
|
||||
it('should create a store with moreLikeThis disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.moreLikeThis?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should create a store with opening disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.opening?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should create a store with suggested disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.suggested?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should create a store with text2speech disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.text2speech?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should create a store with speech2text disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.speech2text?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should create a store with citation disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.citation?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should create a store with moderation disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.moderation?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should create a store with annotationReply disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.annotationReply?.enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Image Initialization', () => {
|
||||
it('should initialize file image enabled as false', () => {
|
||||
const store = createFeaturesStore()
|
||||
const { features } = store.getState()
|
||||
|
||||
expect(features.file?.image?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should initialize file image detail as high resolution', () => {
|
||||
const store = createFeaturesStore()
|
||||
const { features } = store.getState()
|
||||
|
||||
expect(features.file?.image?.detail).toBe(Resolution.high)
|
||||
})
|
||||
|
||||
it('should initialize file image number_limits as 3', () => {
|
||||
const store = createFeaturesStore()
|
||||
const { features } = store.getState()
|
||||
|
||||
expect(features.file?.image?.number_limits).toBe(3)
|
||||
})
|
||||
|
||||
it('should initialize file image transfer_methods with local and remote options', () => {
|
||||
const store = createFeaturesStore()
|
||||
const { features } = store.getState()
|
||||
|
||||
expect(features.file?.image?.transfer_methods).toEqual([
|
||||
TransferMethod.local_file,
|
||||
TransferMethod.remote_url,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Feature Merging', () => {
|
||||
it('should merge initial moreLikeThis enabled state', () => {
|
||||
const store = createFeaturesStore({
|
||||
features: {
|
||||
moreLikeThis: { enabled: true },
|
||||
},
|
||||
})
|
||||
const { features } = store.getState()
|
||||
|
||||
expect(features.moreLikeThis?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should merge initial opening enabled state', () => {
|
||||
const store = createFeaturesStore({
|
||||
features: {
|
||||
opening: { enabled: true },
|
||||
},
|
||||
})
|
||||
const { features } = store.getState()
|
||||
|
||||
expect(features.opening?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should preserve additional properties when merging', () => {
|
||||
const store = createFeaturesStore({
|
||||
features: {
|
||||
opening: { enabled: true, opening_statement: 'Hello!' },
|
||||
},
|
||||
})
|
||||
const { features } = store.getState()
|
||||
|
||||
expect(features.opening?.enabled).toBe(true)
|
||||
expect(features.opening?.opening_statement).toBe('Hello!')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setFeatures', () => {
|
||||
it('should update moreLikeThis feature via setFeatures', () => {
|
||||
const store = createFeaturesStore()
|
||||
|
||||
store.getState().setFeatures({
|
||||
moreLikeThis: { enabled: true },
|
||||
})
|
||||
|
||||
expect(store.getState().features.moreLikeThis?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should update multiple features via setFeatures', () => {
|
||||
const store = createFeaturesStore()
|
||||
|
||||
store.getState().setFeatures({
|
||||
moreLikeThis: { enabled: true },
|
||||
opening: { enabled: true },
|
||||
})
|
||||
|
||||
expect(store.getState().features.moreLikeThis?.enabled).toBe(true)
|
||||
expect(store.getState().features.opening?.enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('showFeaturesModal', () => {
|
||||
it('should initialize showFeaturesModal as false', () => {
|
||||
const store = createFeaturesStore()
|
||||
|
||||
expect(store.getState().showFeaturesModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should toggle showFeaturesModal to true', () => {
|
||||
const store = createFeaturesStore()
|
||||
|
||||
store.getState().setShowFeaturesModal(true)
|
||||
|
||||
expect(store.getState().showFeaturesModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should toggle showFeaturesModal to false', () => {
|
||||
const store = createFeaturesStore()
|
||||
store.getState().setShowFeaturesModal(true)
|
||||
|
||||
store.getState().setShowFeaturesModal(false)
|
||||
|
||||
expect(store.getState().showFeaturesModal).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
69
web/app/components/base/file-uploader/audio-preview.spec.tsx
Normal file
69
web/app/components/base/file-uploader/audio-preview.spec.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AudioPreview from './audio-preview'
|
||||
|
||||
describe('AudioPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render audio element with correct source', () => {
|
||||
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
|
||||
|
||||
const audio = document.querySelector('audio')
|
||||
expect(audio).toBeInTheDocument()
|
||||
expect(audio).toHaveAttribute('title', 'Test Audio')
|
||||
})
|
||||
|
||||
it('should render source element with correct src and type', () => {
|
||||
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
|
||||
|
||||
const source = document.querySelector('source')
|
||||
expect(source).toHaveAttribute('src', 'https://example.com/audio.mp3')
|
||||
expect(source).toHaveAttribute('type', 'audio/mpeg')
|
||||
})
|
||||
|
||||
it('should render close button with icon', () => {
|
||||
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
|
||||
|
||||
const closeIcon = screen.getByTestId('close-btn')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCancel when close button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={onCancel} />)
|
||||
|
||||
const closeIcon = screen.getByTestId('close-btn')
|
||||
fireEvent.click(closeIcon.parentElement!)
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop propagation when backdrop is clicked', () => {
|
||||
const { baseElement } = render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
|
||||
|
||||
const backdrop = baseElement.querySelector('[tabindex="-1"]')
|
||||
const event = new MouseEvent('click', { bubbles: true })
|
||||
const stopPropagation = vi.spyOn(event, 'stopPropagation')
|
||||
backdrop!.dispatchEvent(event)
|
||||
|
||||
expect(stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when Escape key is pressed', () => {
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={onCancel} />)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render in a portal attached to document.body', () => {
|
||||
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
|
||||
|
||||
const audio = document.querySelector('audio')
|
||||
expect(audio?.closest('[tabindex="-1"]')?.parentElement).toBe(document.body)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FC } from 'react'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
@@ -36,7 +35,7 @@ const AudioPreview: FC<AudioPreviewProps> = ({
|
||||
className="absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-gray-500" />
|
||||
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="close-btn" />
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
71
web/app/components/base/file-uploader/constants.spec.ts
Normal file
71
web/app/components/base/file-uploader/constants.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
AUDIO_SIZE_LIMIT,
|
||||
FILE_SIZE_LIMIT,
|
||||
FILE_URL_REGEX,
|
||||
IMG_SIZE_LIMIT,
|
||||
MAX_FILE_UPLOAD_LIMIT,
|
||||
VIDEO_SIZE_LIMIT,
|
||||
} from './constants'
|
||||
|
||||
describe('file-uploader constants', () => {
|
||||
describe('size limit constants', () => {
|
||||
it('should set IMG_SIZE_LIMIT to 10 MB', () => {
|
||||
expect(IMG_SIZE_LIMIT).toBe(10 * 1024 * 1024)
|
||||
})
|
||||
|
||||
it('should set FILE_SIZE_LIMIT to 15 MB', () => {
|
||||
expect(FILE_SIZE_LIMIT).toBe(15 * 1024 * 1024)
|
||||
})
|
||||
|
||||
it('should set AUDIO_SIZE_LIMIT to 50 MB', () => {
|
||||
expect(AUDIO_SIZE_LIMIT).toBe(50 * 1024 * 1024)
|
||||
})
|
||||
|
||||
it('should set VIDEO_SIZE_LIMIT to 100 MB', () => {
|
||||
expect(VIDEO_SIZE_LIMIT).toBe(100 * 1024 * 1024)
|
||||
})
|
||||
|
||||
it('should set MAX_FILE_UPLOAD_LIMIT to 10', () => {
|
||||
expect(MAX_FILE_UPLOAD_LIMIT).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FILE_URL_REGEX', () => {
|
||||
it('should match http URLs', () => {
|
||||
expect(FILE_URL_REGEX.test('http://example.com')).toBe(true)
|
||||
expect(FILE_URL_REGEX.test('http://example.com/path/file.txt')).toBe(true)
|
||||
})
|
||||
|
||||
it('should match https URLs', () => {
|
||||
expect(FILE_URL_REGEX.test('https://example.com')).toBe(true)
|
||||
expect(FILE_URL_REGEX.test('https://example.com/path/file.pdf')).toBe(true)
|
||||
})
|
||||
|
||||
it('should match ftp URLs', () => {
|
||||
expect(FILE_URL_REGEX.test('ftp://files.example.com')).toBe(true)
|
||||
expect(FILE_URL_REGEX.test('ftp://files.example.com/data.csv')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject URLs without a valid protocol', () => {
|
||||
expect(FILE_URL_REGEX.test('example.com')).toBe(false)
|
||||
expect(FILE_URL_REGEX.test('www.example.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject empty strings', () => {
|
||||
expect(FILE_URL_REGEX.test('')).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject unsupported protocols', () => {
|
||||
expect(FILE_URL_REGEX.test('file:///local/path')).toBe(false)
|
||||
expect(FILE_URL_REGEX.test('ssh://host')).toBe(false)
|
||||
expect(FILE_URL_REGEX.test('data:text/plain;base64,abc')).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject partial protocol strings', () => {
|
||||
expect(FILE_URL_REGEX.test('http:')).toBe(false)
|
||||
expect(FILE_URL_REGEX.test('http:/')).toBe(false)
|
||||
expect(FILE_URL_REGEX.test('https:')).toBe(false)
|
||||
expect(FILE_URL_REGEX.test('ftp:')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FileContextProvider } from '../store'
|
||||
import FileFromLinkOrLocal from './index'
|
||||
|
||||
let mockFiles: FileEntity[] = []
|
||||
|
||||
function createStubFile(id: string): FileEntity {
|
||||
return { id, name: `${id}.txt`, size: 0, type: '', progress: 100, transferMethod: 'local_file' as FileEntity['transferMethod'], supportFileType: 'document' }
|
||||
}
|
||||
|
||||
const mockHandleLoadFileFromLink = vi.fn()
|
||||
vi.mock('../hooks', () => ({
|
||||
useFile: () => ({
|
||||
handleLoadFileFromLink: mockHandleLoadFileFromLink,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => ({
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: [],
|
||||
number_limits: 5,
|
||||
...overrides,
|
||||
} as FileUpload)
|
||||
|
||||
function renderAndOpen(props: Partial<React.ComponentProps<typeof FileFromLinkOrLocal>> = {}) {
|
||||
const trigger = props.trigger ?? ((open: boolean) => <button data-testid="trigger">{open ? 'Close' : 'Open'}</button>)
|
||||
const result = render(
|
||||
<FileContextProvider value={mockFiles}>
|
||||
<FileFromLinkOrLocal
|
||||
trigger={trigger}
|
||||
fileConfig={props.fileConfig ?? createFileConfig()}
|
||||
{...props}
|
||||
/>
|
||||
</FileContextProvider>,
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
return result
|
||||
}
|
||||
|
||||
describe('FileFromLinkOrLocal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFiles = []
|
||||
})
|
||||
|
||||
it('should render trigger element', () => {
|
||||
const trigger = (open: boolean) => (
|
||||
<button data-testid="trigger">
|
||||
Open
|
||||
{open ? 'close' : 'open'}
|
||||
</button>
|
||||
)
|
||||
render(
|
||||
<FileContextProvider value={mockFiles}>
|
||||
<FileFromLinkOrLocal trigger={trigger} fileConfig={createFileConfig()} />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render URL input when showFromLink is true', () => {
|
||||
renderAndOpen({ showFromLink: true })
|
||||
|
||||
expect(screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render upload button when showFromLocal is true', () => {
|
||||
renderAndOpen({ showFromLocal: true })
|
||||
|
||||
expect(screen.getByText(/fileUploader\.uploadFromComputer/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render OR divider when both link and local are shown', () => {
|
||||
renderAndOpen({ showFromLink: true, showFromLocal: true })
|
||||
|
||||
expect(screen.getByText('OR')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render OR divider when only link is shown', () => {
|
||||
renderAndOpen({ showFromLink: true, showFromLocal: false })
|
||||
|
||||
expect(screen.queryByText('OR')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error when invalid URL is submitted', () => {
|
||||
renderAndOpen({ showFromLink: true })
|
||||
|
||||
const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/)
|
||||
fireEvent.change(input, { target: { value: 'invalid-url' } })
|
||||
|
||||
const okButton = screen.getByText(/operation\.ok/)
|
||||
fireEvent.click(okButton)
|
||||
|
||||
expect(screen.getByText(/fileUploader\.pasteFileLinkInvalid/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should clear error when input changes', () => {
|
||||
renderAndOpen({ showFromLink: true })
|
||||
|
||||
const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/)
|
||||
fireEvent.change(input, { target: { value: 'invalid-url' } })
|
||||
fireEvent.click(screen.getByText(/operation\.ok/))
|
||||
|
||||
expect(screen.getByText(/fileUploader\.pasteFileLinkInvalid/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(input, { target: { value: 'https://example.com' } })
|
||||
expect(screen.queryByText(/fileUploader\.pasteFileLinkInvalid/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable ok button when url is empty', () => {
|
||||
renderAndOpen({ showFromLink: true })
|
||||
|
||||
const okButton = screen.getByText(/operation\.ok/)
|
||||
expect(okButton.closest('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable inputs when file limit is reached', () => {
|
||||
mockFiles = ['1', '2', '3', '4', '5'].map(createStubFile)
|
||||
renderAndOpen({ fileConfig: createFileConfig({ number_limits: 5 }), showFromLink: true, showFromLocal: true })
|
||||
|
||||
const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/)
|
||||
expect(input).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not submit when url is empty', () => {
|
||||
renderAndOpen({ showFromLink: true })
|
||||
|
||||
const okButton = screen.getByText(/operation\.ok/)
|
||||
fireEvent.click(okButton)
|
||||
|
||||
expect(screen.queryByText(/fileUploader\.pasteFileLinkInvalid/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call handleLoadFileFromLink when valid URL is submitted', () => {
|
||||
renderAndOpen({ showFromLink: true })
|
||||
|
||||
const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/)
|
||||
fireEvent.change(input, { target: { value: 'https://example.com/file.pdf' } })
|
||||
fireEvent.click(screen.getByText(/operation\.ok/))
|
||||
|
||||
expect(mockHandleLoadFileFromLink).toHaveBeenCalledWith('https://example.com/file.pdf')
|
||||
})
|
||||
|
||||
it('should clear URL input after successful submission', () => {
|
||||
renderAndOpen({ showFromLink: true })
|
||||
|
||||
const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/) as HTMLInputElement
|
||||
fireEvent.change(input, { target: { value: 'https://example.com/file.pdf' } })
|
||||
fireEvent.click(screen.getByText(/operation\.ok/))
|
||||
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
|
||||
it('should toggle open state when trigger is clicked', () => {
|
||||
const trigger = (open: boolean) => <button data-testid="trigger">{open ? 'Close' : 'Open'}</button>
|
||||
render(
|
||||
<FileContextProvider value={mockFiles}>
|
||||
<FileFromLinkOrLocal trigger={trigger} fileConfig={createFileConfig()} showFromLink />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
const triggerButton = screen.getByTestId('trigger')
|
||||
expect(triggerButton).toHaveTextContent('Open')
|
||||
|
||||
fireEvent.click(triggerButton)
|
||||
|
||||
expect(triggerButton).toHaveTextContent('Close')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import FileImageRender from './file-image-render'
|
||||
|
||||
describe('FileImageRender', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render an image with the given URL', () => {
|
||||
render(<FileImageRender imageUrl="https://example.com/image.png" />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/image.png')
|
||||
})
|
||||
|
||||
it('should use default alt text when alt is not provided', () => {
|
||||
render(<FileImageRender imageUrl="https://example.com/image.png" />)
|
||||
|
||||
expect(screen.getByAltText('Preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use custom alt text when provided', () => {
|
||||
render(<FileImageRender imageUrl="https://example.com/image.png" alt="Custom alt" />)
|
||||
|
||||
expect(screen.getByAltText('Custom alt')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className to container', () => {
|
||||
const { container } = render(
|
||||
<FileImageRender imageUrl="https://example.com/image.png" className="custom-class" />,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should call onLoad when image loads', () => {
|
||||
const onLoad = vi.fn()
|
||||
render(<FileImageRender imageUrl="https://example.com/image.png" onLoad={onLoad} />)
|
||||
|
||||
fireEvent.load(screen.getByRole('img'))
|
||||
|
||||
expect(onLoad).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onError when image fails to load', () => {
|
||||
const onError = vi.fn()
|
||||
render(<FileImageRender imageUrl="https://example.com/broken.png" onError={onError} />)
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
expect(onError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should add cursor-pointer to image when showDownloadAction is true', () => {
|
||||
render(<FileImageRender imageUrl="https://example.com/image.png" showDownloadAction />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveClass('cursor-pointer')
|
||||
})
|
||||
|
||||
it('should not add cursor-pointer when showDownloadAction is false', () => {
|
||||
render(<FileImageRender imageUrl="https://example.com/image.png" />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).not.toHaveClass('cursor-pointer')
|
||||
})
|
||||
})
|
||||
179
web/app/components/base/file-uploader/file-input.spec.tsx
Normal file
179
web/app/components/base/file-uploader/file-input.spec.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { FileEntity } from './types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import FileInput from './file-input'
|
||||
import { FileContextProvider } from './store'
|
||||
|
||||
const mockHandleLocalFileUpload = vi.fn()
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
useFile: () => ({
|
||||
handleLocalFileUpload: mockHandleLocalFileUpload,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => ({
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: [],
|
||||
number_limits: 5,
|
||||
...overrides,
|
||||
} as FileUpload)
|
||||
|
||||
function createStubFile(id: string): FileEntity {
|
||||
return { id, name: `${id}.txt`, size: 0, type: '', progress: 100, transferMethod: 'local_file' as FileEntity['transferMethod'], supportFileType: 'document' }
|
||||
}
|
||||
|
||||
function renderWithProvider(ui: React.ReactElement, fileIds: string[] = []) {
|
||||
return render(
|
||||
<FileContextProvider value={fileIds.map(createStubFile)}>
|
||||
{ui}
|
||||
</FileContextProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('FileInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render a file input element', () => {
|
||||
renderWithProvider(<FileInput fileConfig={createFileConfig()} />)
|
||||
|
||||
const input = document.querySelector('input[type="file"]')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set accept attribute based on allowed file types', () => {
|
||||
renderWithProvider(<FileInput fileConfig={createFileConfig({ allowed_file_types: ['image'] })} />)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
expect(input.accept).toBe('.JPG,.JPEG,.PNG,.GIF,.WEBP,.SVG')
|
||||
})
|
||||
|
||||
it('should use custom extensions when file type is custom', () => {
|
||||
renderWithProvider(
|
||||
<FileInput fileConfig={createFileConfig({
|
||||
allowed_file_types: ['custom'] as unknown as FileUpload['allowed_file_types'],
|
||||
allowed_file_extensions: ['.csv', '.xlsx'],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
expect(input.accept).toBe('.csv,.xlsx')
|
||||
})
|
||||
|
||||
it('should allow multiple files when number_limits > 1', () => {
|
||||
renderWithProvider(<FileInput fileConfig={createFileConfig({ number_limits: 3 })} />)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
expect(input.multiple).toBe(true)
|
||||
})
|
||||
|
||||
it('should not allow multiple files when number_limits is 1', () => {
|
||||
renderWithProvider(<FileInput fileConfig={createFileConfig({ number_limits: 1 })} />)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
expect(input.multiple).toBe(false)
|
||||
})
|
||||
|
||||
it('should be disabled when file limit is reached', () => {
|
||||
renderWithProvider(
|
||||
<FileInput fileConfig={createFileConfig({ number_limits: 3 })} />,
|
||||
['1', '2', '3'],
|
||||
)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
expect(input.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should not be disabled when file limit is not reached', () => {
|
||||
renderWithProvider(
|
||||
<FileInput fileConfig={createFileConfig({ number_limits: 3 })} />,
|
||||
['1'],
|
||||
)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
expect(input.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should call handleLocalFileUpload when files are selected', () => {
|
||||
renderWithProvider(<FileInput fileConfig={createFileConfig()} />)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const file = new File(['content'], 'test.jpg', { type: 'image/jpeg' })
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
|
||||
expect(mockHandleLocalFileUpload).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should respect number_limits when uploading multiple files', () => {
|
||||
renderWithProvider(
|
||||
<FileInput fileConfig={createFileConfig({ number_limits: 3 })} />,
|
||||
['1', '2'],
|
||||
)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const file1 = new File(['content'], 'test1.jpg', { type: 'image/jpeg' })
|
||||
const file2 = new File(['content'], 'test2.jpg', { type: 'image/jpeg' })
|
||||
|
||||
Object.defineProperty(input, 'files', {
|
||||
value: [file1, file2],
|
||||
})
|
||||
fireEvent.change(input)
|
||||
|
||||
// Only 1 file should be uploaded (2 existing + 1 = 3 = limit)
|
||||
expect(mockHandleLocalFileUpload).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleLocalFileUpload).toHaveBeenCalledWith(file1)
|
||||
})
|
||||
|
||||
it('should upload first file only when number_limits is not set', () => {
|
||||
renderWithProvider(<FileInput fileConfig={createFileConfig({ number_limits: undefined })} />)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const file = new File(['content'], 'test.jpg', { type: 'image/jpeg' })
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
|
||||
expect(mockHandleLocalFileUpload).toHaveBeenCalledWith(file)
|
||||
})
|
||||
|
||||
it('should not upload when targetFiles is null', () => {
|
||||
renderWithProvider(<FileInput fileConfig={createFileConfig()} />)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: null } })
|
||||
|
||||
expect(mockHandleLocalFileUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle empty allowed_file_types', () => {
|
||||
renderWithProvider(<FileInput fileConfig={createFileConfig({ allowed_file_types: undefined })} />)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
expect(input.accept).toBe('')
|
||||
})
|
||||
|
||||
it('should handle custom type with undefined allowed_file_extensions', () => {
|
||||
renderWithProvider(
|
||||
<FileInput fileConfig={createFileConfig({
|
||||
allowed_file_types: ['custom'] as unknown as FileUpload['allowed_file_types'],
|
||||
allowed_file_extensions: undefined,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
expect(input.accept).toBe('')
|
||||
})
|
||||
|
||||
it('should clear input value on click', () => {
|
||||
renderWithProvider(<FileInput fileConfig={createFileConfig()} />)
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
Object.defineProperty(input, 'value', { writable: true, value: 'some-file' })
|
||||
fireEvent.click(input)
|
||||
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
})
|
||||
142
web/app/components/base/file-uploader/file-list-in-log.spec.tsx
Normal file
142
web/app/components/base/file-uploader/file-list-in-log.spec.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { FileEntity } from './types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import FileListInLog from './file-list-in-log'
|
||||
|
||||
const createFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: `file-${Math.random()}`,
|
||||
name: 'test.txt',
|
||||
size: 1024,
|
||||
type: 'text/plain',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'document',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('FileListInLog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return null when fileList is empty', () => {
|
||||
const { container } = render(<FileListInLog fileList={[]} />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render collapsed view by default', () => {
|
||||
const fileList = [{ varName: 'files', list: [createFile()] }]
|
||||
render(<FileListInLog fileList={fileList} />)
|
||||
|
||||
expect(screen.getByText(/runDetail\.fileListDetail/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render expanded view when isExpanded is true', () => {
|
||||
const fileList = [{ varName: 'files', list: [createFile()] }]
|
||||
render(<FileListInLog fileList={fileList} isExpanded />)
|
||||
|
||||
expect(screen.getByText(/runDetail\.fileListLabel/)).toBeInTheDocument()
|
||||
expect(screen.getByText('files')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle between collapsed and expanded on click', () => {
|
||||
const fileList = [{ varName: 'files', list: [createFile()] }]
|
||||
render(<FileListInLog fileList={fileList} />)
|
||||
|
||||
expect(screen.getByText(/runDetail\.fileListDetail/)).toBeInTheDocument()
|
||||
|
||||
const detailLink = screen.getByText(/runDetail\.fileListDetail/)
|
||||
fireEvent.click(detailLink.parentElement!)
|
||||
|
||||
expect(screen.getByText(/runDetail\.fileListLabel/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render image files with an img element in collapsed view', () => {
|
||||
const fileList = [{
|
||||
varName: 'files',
|
||||
list: [createFile({
|
||||
name: 'photo.png',
|
||||
supportFileType: 'image',
|
||||
url: 'https://example.com/photo.png',
|
||||
})],
|
||||
}]
|
||||
render(<FileListInLog fileList={fileList} />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toBeInTheDocument()
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/photo.png')
|
||||
})
|
||||
|
||||
it('should render non-image files with an SVG icon in collapsed view', () => {
|
||||
const fileList = [{
|
||||
varName: 'files',
|
||||
list: [createFile({
|
||||
name: 'doc.pdf',
|
||||
supportFileType: 'document',
|
||||
})],
|
||||
}]
|
||||
render(<FileListInLog fileList={fileList} />)
|
||||
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file details in expanded view', () => {
|
||||
const file = createFile({ name: 'report.txt' })
|
||||
const fileList = [{ varName: 'files', list: [file] }]
|
||||
render(<FileListInLog fileList={fileList} isExpanded />)
|
||||
|
||||
expect(screen.getByText('report.txt')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple var groups in expanded view', () => {
|
||||
const fileList = [
|
||||
{ varName: 'images', list: [createFile({ name: 'a.jpg' })] },
|
||||
{ varName: 'documents', list: [createFile({ name: 'b.pdf' })] },
|
||||
]
|
||||
render(<FileListInLog fileList={fileList} isExpanded />)
|
||||
|
||||
expect(screen.getByText('images')).toBeInTheDocument()
|
||||
expect(screen.getByText('documents')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply noBorder class when noBorder is true', () => {
|
||||
const fileList = [{ varName: 'files', list: [createFile()] }]
|
||||
const { container } = render(<FileListInLog fileList={fileList} noBorder />)
|
||||
|
||||
expect(container.firstChild).not.toHaveClass('border-t')
|
||||
})
|
||||
|
||||
it('should apply noPadding class when noPadding is true', () => {
|
||||
const fileList = [{ varName: 'files', list: [createFile()] }]
|
||||
const { container } = render(<FileListInLog fileList={fileList} noPadding />)
|
||||
|
||||
expect(container.firstChild).toHaveClass('!p-0')
|
||||
})
|
||||
|
||||
it('should render image file with empty url when both base64Url and url are undefined', () => {
|
||||
const fileList = [{
|
||||
varName: 'files',
|
||||
list: [createFile({
|
||||
name: 'photo.png',
|
||||
supportFileType: 'image',
|
||||
base64Url: undefined,
|
||||
url: undefined,
|
||||
})],
|
||||
}]
|
||||
render(<FileListInLog fileList={fileList} />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should collapse when label is clicked in expanded view', () => {
|
||||
const fileList = [{ varName: 'files', list: [createFile()] }]
|
||||
render(<FileListInLog fileList={fileList} isExpanded />)
|
||||
|
||||
const label = screen.getByText(/runDetail\.fileListLabel/)
|
||||
fireEvent.click(label)
|
||||
|
||||
expect(screen.getByText(/runDetail\.fileListDetail/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { FileAppearanceTypeEnum } from './types'
|
||||
import { render } from '@testing-library/react'
|
||||
import FileTypeIcon from './file-type-icon'
|
||||
|
||||
describe('FileTypeIcon', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('icon rendering per file type', () => {
|
||||
const fileTypeToColor: Array<{ type: keyof typeof FileAppearanceTypeEnum, color: string }> = [
|
||||
{ type: 'pdf', color: 'text-[#EA3434]' },
|
||||
{ type: 'image', color: 'text-[#00B2EA]' },
|
||||
{ type: 'video', color: 'text-[#844FDA]' },
|
||||
{ type: 'audio', color: 'text-[#FF3093]' },
|
||||
{ type: 'document', color: 'text-[#6F8BB5]' },
|
||||
{ type: 'code', color: 'text-[#BCC0D1]' },
|
||||
{ type: 'markdown', color: 'text-[#309BEC]' },
|
||||
{ type: 'custom', color: 'text-[#BCC0D1]' },
|
||||
{ type: 'excel', color: 'text-[#01AC49]' },
|
||||
{ type: 'word', color: 'text-[#2684FF]' },
|
||||
{ type: 'ppt', color: 'text-[#FF650F]' },
|
||||
{ type: 'gif', color: 'text-[#00B2EA]' },
|
||||
]
|
||||
|
||||
it.each(fileTypeToColor)(
|
||||
'should render $type icon with correct color',
|
||||
({ type, color }) => {
|
||||
const { container } = render(<FileTypeIcon type={type} />)
|
||||
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
expect(icon).toHaveClass(color)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('should render document icon when type is unknown', () => {
|
||||
const { container } = render(<FileTypeIcon type={'nonexistent' as unknown as keyof typeof FileAppearanceTypeEnum} />)
|
||||
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toBeInTheDocument()
|
||||
expect(icon).toHaveClass('text-[#6F8BB5]')
|
||||
})
|
||||
|
||||
describe('size variants', () => {
|
||||
const sizeMap: Array<{ size: 'sm' | 'md' | 'lg' | 'xl', expectedClass: string }> = [
|
||||
{ size: 'sm', expectedClass: 'size-4' },
|
||||
{ size: 'md', expectedClass: 'size-[18px]' },
|
||||
{ size: 'lg', expectedClass: 'size-5' },
|
||||
{ size: 'xl', expectedClass: 'size-6' },
|
||||
]
|
||||
|
||||
it.each(sizeMap)(
|
||||
'should apply $expectedClass when size is $size',
|
||||
({ size, expectedClass }) => {
|
||||
const { container } = render(<FileTypeIcon type="pdf" size={size} />)
|
||||
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass(expectedClass)
|
||||
},
|
||||
)
|
||||
|
||||
it('should default to sm size when no size is provided', () => {
|
||||
const { container } = render(<FileTypeIcon type="pdf" />)
|
||||
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('size-4')
|
||||
})
|
||||
})
|
||||
|
||||
it('should apply custom className when provided', () => {
|
||||
const { container } = render(<FileTypeIcon type="pdf" className="extra-class" />)
|
||||
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('extra-class')
|
||||
})
|
||||
|
||||
it('should always include shrink-0 class', () => {
|
||||
const { container } = render(<FileTypeIcon type="document" />)
|
||||
|
||||
const icon = container.querySelector('svg')
|
||||
expect(icon).toHaveClass('shrink-0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,407 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { PreviewMode } from '@/app/components/base/features/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import FileInAttachmentItem from './file-item'
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatFileSize: (size: number) => `${size}B`,
|
||||
}))
|
||||
|
||||
const createFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: 'file-1',
|
||||
name: 'document.pdf',
|
||||
size: 2048,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'document',
|
||||
uploadedId: 'uploaded-1',
|
||||
url: 'https://example.com/document.pdf',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('FileInAttachmentItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render file name and extension', () => {
|
||||
render(<FileInAttachmentItem file={createFile()} />)
|
||||
|
||||
expect(screen.getByText(/document\.pdf/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/^pdf$/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file size', () => {
|
||||
render(<FileInAttachmentItem file={createFile({ size: 2048 })} />)
|
||||
|
||||
expect(screen.getByText(/2048B/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render FileTypeIcon for non-image files', () => {
|
||||
const { container } = render(<FileInAttachmentItem file={createFile()} />)
|
||||
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render FileImageRender for image files', () => {
|
||||
render(
|
||||
<FileInAttachmentItem file={createFile({
|
||||
supportFileType: 'image',
|
||||
base64Url: 'data:image/png;base64,abc',
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toBeInTheDocument()
|
||||
expect(img).toHaveAttribute('src', 'data:image/png;base64,abc')
|
||||
})
|
||||
|
||||
it('should render delete button when showDeleteAction is true', () => {
|
||||
render(<FileInAttachmentItem file={createFile()} showDeleteAction />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should not render delete button when showDeleteAction is false', () => {
|
||||
render(<FileInAttachmentItem file={createFile()} showDeleteAction={false} />)
|
||||
|
||||
// With showDeleteAction=false, showDownloadAction defaults to true,
|
||||
// so there should be exactly 1 button (the download button)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should call onRemove when delete button is clicked', () => {
|
||||
const onRemove = vi.fn()
|
||||
// Disable download to isolate the delete button
|
||||
render(<FileInAttachmentItem file={createFile()} showDeleteAction showDownloadAction={false} onRemove={onRemove} />)
|
||||
|
||||
const deleteBtn = screen.getByRole('button')
|
||||
fireEvent.click(deleteBtn)
|
||||
|
||||
expect(onRemove).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should render download button when showDownloadAction is true', () => {
|
||||
render(<FileInAttachmentItem file={createFile()} showDownloadAction />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should render progress circle when file is uploading', () => {
|
||||
const { container } = render(<FileInAttachmentItem file={createFile({ progress: 50, uploadedId: undefined })} />)
|
||||
|
||||
// ProgressCircle renders an SVG with a <circle> and <path> element
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
const circle = container.querySelector('circle')
|
||||
expect(circle).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render replay icon when upload failed', () => {
|
||||
const { container } = render(<FileInAttachmentItem file={createFile({ progress: -1 })} />)
|
||||
|
||||
// ReplayLine renders an SVG with data-icon="ReplayLine"
|
||||
const replayIcon = container.querySelector('[data-icon="ReplayLine"]')
|
||||
expect(replayIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onReUpload when replay icon is clicked', () => {
|
||||
const onReUpload = vi.fn()
|
||||
const { container } = render(<FileInAttachmentItem file={createFile({ progress: -1 })} onReUpload={onReUpload} />)
|
||||
|
||||
const replayIcon = container.querySelector('[data-icon="ReplayLine"]')
|
||||
const replayBtn = replayIcon!.closest('button')
|
||||
fireEvent.click(replayBtn!)
|
||||
|
||||
expect(onReUpload).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should indicate error state when progress is -1', () => {
|
||||
const { container } = render(<FileInAttachmentItem file={createFile({ progress: -1 })} />)
|
||||
|
||||
// Error state is confirmed by the presence of the replay icon
|
||||
const replayIcon = container.querySelector('[data-icon="ReplayLine"]')
|
||||
expect(replayIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render eye icon for previewable image files', () => {
|
||||
render(
|
||||
<FileInAttachmentItem
|
||||
file={createFile({
|
||||
supportFileType: 'image',
|
||||
url: 'https://example.com/img.png',
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
// canPreview + image renders an extra button for the eye icon
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('should show image preview when eye icon is clicked', () => {
|
||||
render(
|
||||
<FileInAttachmentItem
|
||||
file={createFile({
|
||||
supportFileType: 'image',
|
||||
url: 'https://example.com/img.png',
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
// The eye button is rendered before the download button for image files
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// Click the eye button (the first action button for image preview)
|
||||
fireEvent.click(buttons[0])
|
||||
|
||||
// ImagePreview renders a portal with an img element
|
||||
const previewImages = document.querySelectorAll('img')
|
||||
// There should be at least 2 images: the file thumbnail + the preview
|
||||
expect(previewImages.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('should close image preview when close is clicked', () => {
|
||||
render(
|
||||
<FileInAttachmentItem
|
||||
file={createFile({
|
||||
supportFileType: 'image',
|
||||
url: 'https://example.com/img.png',
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0])
|
||||
|
||||
// ImagePreview renders via createPortal with class "image-preview-container"
|
||||
const previewContainer = document.querySelector('.image-preview-container')!
|
||||
expect(previewContainer).toBeInTheDocument()
|
||||
|
||||
// Close button is the last clickable div with an SVG in the preview container
|
||||
const closeIcon = screen.getByTestId('image-preview-close-button')
|
||||
fireEvent.click(closeIcon.parentElement!)
|
||||
|
||||
// Preview should be removed
|
||||
expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call downloadUrl when download button is clicked', async () => {
|
||||
const { downloadUrl } = await import('@/utils/download')
|
||||
render(<FileInAttachmentItem file={createFile()} showDownloadAction />)
|
||||
|
||||
// Download button is the only action button when showDeleteAction is not set
|
||||
const downloadBtn = screen.getByRole('button')
|
||||
fireEvent.click(downloadBtn)
|
||||
|
||||
expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({
|
||||
fileName: expect.stringMatching(/document\.pdf/i),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should open new page when previewMode is NewPage and clicked', () => {
|
||||
const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
render(
|
||||
<FileInAttachmentItem
|
||||
file={createFile({ url: 'https://example.com/doc.pdf' })}
|
||||
canPreview
|
||||
previewMode={PreviewMode.NewPage}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click the file name text to trigger the row click handler
|
||||
fireEvent.click(screen.getByText(/document\.pdf/i))
|
||||
|
||||
expect(windowOpen).toHaveBeenCalledWith('https://example.com/doc.pdf', '_blank')
|
||||
windowOpen.mockRestore()
|
||||
})
|
||||
|
||||
it('should fallback to base64Url when url is empty for NewPage preview', () => {
|
||||
const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
render(
|
||||
<FileInAttachmentItem
|
||||
file={createFile({ url: undefined, base64Url: 'data:image/png;base64,abc' })}
|
||||
canPreview
|
||||
previewMode={PreviewMode.NewPage}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/document\.pdf/i))
|
||||
|
||||
expect(windowOpen).toHaveBeenCalledWith('data:image/png;base64,abc', '_blank')
|
||||
windowOpen.mockRestore()
|
||||
})
|
||||
|
||||
it('should open empty string when both url and base64Url are empty for NewPage preview', () => {
|
||||
const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
render(
|
||||
<FileInAttachmentItem
|
||||
file={createFile({ url: undefined, base64Url: undefined })}
|
||||
canPreview
|
||||
previewMode={PreviewMode.NewPage}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/document\.pdf/i))
|
||||
|
||||
expect(windowOpen).toHaveBeenCalledWith('', '_blank')
|
||||
windowOpen.mockRestore()
|
||||
})
|
||||
|
||||
it('should not open new page when previewMode is not NewPage', () => {
|
||||
const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
render(
|
||||
<FileInAttachmentItem
|
||||
file={createFile()}
|
||||
canPreview
|
||||
previewMode={PreviewMode.CurrentPage}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/document\.pdf/i))
|
||||
|
||||
expect(windowOpen).not.toHaveBeenCalled()
|
||||
windowOpen.mockRestore()
|
||||
})
|
||||
|
||||
it('should use url for image render fallback when base64Url is empty', () => {
|
||||
render(
|
||||
<FileInAttachmentItem file={createFile({
|
||||
supportFileType: 'image',
|
||||
base64Url: undefined,
|
||||
url: 'https://example.com/img.png',
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/img.png')
|
||||
})
|
||||
|
||||
it('should render image element even when both urls are empty', () => {
|
||||
render(
|
||||
<FileInAttachmentItem file={createFile({
|
||||
supportFileType: 'image',
|
||||
base64Url: undefined,
|
||||
url: undefined,
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render eye icon when canPreview is false for image files', () => {
|
||||
render(
|
||||
<FileInAttachmentItem
|
||||
file={createFile({
|
||||
supportFileType: 'image',
|
||||
url: 'https://example.com/img.png',
|
||||
})}
|
||||
canPreview={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Without canPreview, only the download button should render
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should download using base64Url when url is not available', async () => {
|
||||
const { downloadUrl } = await import('@/utils/download')
|
||||
render(
|
||||
<FileInAttachmentItem
|
||||
file={createFile({ url: undefined, base64Url: 'data:application/pdf;base64,abc' })}
|
||||
showDownloadAction
|
||||
/>,
|
||||
)
|
||||
|
||||
const downloadBtn = screen.getByRole('button')
|
||||
fireEvent.click(downloadBtn)
|
||||
|
||||
expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: 'data:application/pdf;base64,abc',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should not render file size when size is 0', () => {
|
||||
render(<FileInAttachmentItem file={createFile({ size: 0 })} />)
|
||||
|
||||
expect(screen.queryByText(/0B/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render extension when ext is empty', () => {
|
||||
render(<FileInAttachmentItem file={createFile({ name: 'noext' })} />)
|
||||
|
||||
// The file name should still show
|
||||
expect(screen.getByText(/noext/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show image preview with empty url when url is undefined', () => {
|
||||
render(
|
||||
<FileInAttachmentItem
|
||||
file={createFile({
|
||||
supportFileType: 'image',
|
||||
url: undefined,
|
||||
base64Url: undefined,
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// Click the eye preview button
|
||||
fireEvent.click(buttons[0])
|
||||
|
||||
// setImagePreviewUrl(url || '') = setImagePreviewUrl('')
|
||||
// Empty string is falsy, so preview should NOT render
|
||||
expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should download with empty url when both url and base64Url are undefined', async () => {
|
||||
const { downloadUrl } = await import('@/utils/download')
|
||||
render(
|
||||
<FileInAttachmentItem
|
||||
file={createFile({ url: undefined, base64Url: undefined })}
|
||||
showDownloadAction
|
||||
/>,
|
||||
)
|
||||
|
||||
const downloadBtn = screen.getByRole('button')
|
||||
fireEvent.click(downloadBtn)
|
||||
|
||||
expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: '',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should call downloadUrl with empty url when both url and base64Url are falsy', async () => {
|
||||
const { downloadUrl } = await import('@/utils/download')
|
||||
render(
|
||||
<FileInAttachmentItem
|
||||
file={createFile({ url: '', base64Url: '' })}
|
||||
showDownloadAction
|
||||
/>,
|
||||
)
|
||||
|
||||
const downloadBtn = screen.getByRole('button')
|
||||
fireEvent.click(downloadBtn)
|
||||
|
||||
expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: '',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import FileUploaderInAttachmentWrapper from './index'
|
||||
|
||||
const mockHandleRemoveFile = vi.fn()
|
||||
const mockHandleReUploadFile = vi.fn()
|
||||
vi.mock('../hooks', () => ({
|
||||
useFile: () => ({
|
||||
handleRemoveFile: mockHandleRemoveFile,
|
||||
handleReUploadFile: mockHandleReUploadFile,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatFileSize: (size: number) => `${size}B`,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => ({
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
allowed_file_extensions: [],
|
||||
number_limits: 5,
|
||||
...overrides,
|
||||
} as unknown as FileUpload)
|
||||
|
||||
const createFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: 'file-1',
|
||||
name: 'test.txt',
|
||||
size: 1024,
|
||||
type: 'text/plain',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'document',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('FileUploaderInAttachmentWrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render without crashing', () => {
|
||||
render(
|
||||
<FileUploaderInAttachmentWrapper
|
||||
onChange={vi.fn()}
|
||||
fileConfig={createFileConfig()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// FileContextProvider wraps children with a Zustand context — verify children render
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render upload buttons when not disabled', () => {
|
||||
render(
|
||||
<FileUploaderInAttachmentWrapper
|
||||
onChange={vi.fn()}
|
||||
fileConfig={createFileConfig()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not render upload buttons when disabled', () => {
|
||||
render(
|
||||
<FileUploaderInAttachmentWrapper
|
||||
onChange={vi.fn()}
|
||||
fileConfig={createFileConfig()}
|
||||
isDisabled
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/fileUploader\.uploadFromComputer/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file items for each file', () => {
|
||||
const files = [
|
||||
createFile({ id: 'f1', name: 'a.txt' }),
|
||||
createFile({ id: 'f2', name: 'b.txt' }),
|
||||
]
|
||||
|
||||
render(
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={files}
|
||||
onChange={vi.fn()}
|
||||
fileConfig={createFileConfig()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/a\.txt/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/b\.txt/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render local upload button for local_file method', () => {
|
||||
render(
|
||||
<FileUploaderInAttachmentWrapper
|
||||
onChange={vi.fn()}
|
||||
fileConfig={createFileConfig({
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
} as unknown as Partial<FileUpload>)}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/fileUploader\.uploadFromComputer/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render link upload option for remote_url method', () => {
|
||||
render(
|
||||
<FileUploaderInAttachmentWrapper
|
||||
onChange={vi.fn()}
|
||||
fileConfig={createFileConfig({
|
||||
allowed_file_upload_methods: [TransferMethod.remote_url],
|
||||
} as unknown as Partial<FileUpload>)}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/fileUploader\.pasteFileLink/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call handleRemoveFile when remove button is clicked', () => {
|
||||
const files = [createFile({ id: 'f1', name: 'a.txt' })]
|
||||
|
||||
render(
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={files}
|
||||
onChange={vi.fn()}
|
||||
fileConfig={createFileConfig()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find the file item row, then locate the delete button within it
|
||||
const fileNameEl = screen.getByText(/a\.txt/i)
|
||||
const fileRow = fileNameEl.closest('[title="a.txt"]')?.parentElement?.parentElement
|
||||
const deleteBtn = fileRow?.querySelector('button:last-of-type')
|
||||
fireEvent.click(deleteBtn!)
|
||||
|
||||
expect(mockHandleRemoveFile).toHaveBeenCalledWith('f1')
|
||||
})
|
||||
|
||||
it('should apply open style on remote_url trigger when portal is open', () => {
|
||||
render(
|
||||
<FileUploaderInAttachmentWrapper
|
||||
onChange={vi.fn()}
|
||||
fileConfig={createFileConfig({
|
||||
allowed_file_upload_methods: [TransferMethod.remote_url],
|
||||
} as unknown as Partial<FileUpload>)}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click the remote_url button to open the portal
|
||||
const linkButton = screen.getByText(/fileUploader\.pasteFileLink/)
|
||||
fireEvent.click(linkButton)
|
||||
|
||||
// The button should still be in the document
|
||||
expect(linkButton.closest('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable upload buttons when file limit is reached', () => {
|
||||
const files = [
|
||||
createFile({ id: 'f1' }),
|
||||
createFile({ id: 'f2' }),
|
||||
createFile({ id: 'f3' }),
|
||||
createFile({ id: 'f4' }),
|
||||
createFile({ id: 'f5' }),
|
||||
]
|
||||
|
||||
render(
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={files}
|
||||
onChange={vi.fn()}
|
||||
fileConfig={createFileConfig({ number_limits: 5 })}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const disabledButtons = buttons.filter(btn => btn.hasAttribute('disabled'))
|
||||
expect(disabledButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should call handleReUploadFile when reupload button is clicked', () => {
|
||||
const files = [createFile({ id: 'f1', name: 'a.txt', progress: -1 })]
|
||||
|
||||
const { container } = render(
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={files}
|
||||
onChange={vi.fn()}
|
||||
fileConfig={createFileConfig()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// ReplayLine is inside ActionButton (a <button>) with data-icon attribute
|
||||
const replayIcon = container.querySelector('svg[data-icon="ReplayLine"]')
|
||||
const replayBtn = replayIcon!.closest('button')
|
||||
fireEvent.click(replayBtn!)
|
||||
|
||||
expect(mockHandleReUploadFile).toHaveBeenCalledWith('f1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import FileImageItem from './file-image-item'
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
const createFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: 'file-1',
|
||||
name: 'photo.png',
|
||||
size: 4096,
|
||||
type: 'image/png',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'image',
|
||||
uploadedId: 'uploaded-1',
|
||||
base64Url: 'data:image/png;base64,abc',
|
||||
url: 'https://example.com/photo.png',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('FileImageItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render an image with the base64 URL', () => {
|
||||
render(<FileImageItem file={createFile()} />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toBeInTheDocument()
|
||||
expect(img).toHaveAttribute('src', 'data:image/png;base64,abc')
|
||||
})
|
||||
|
||||
it('should use url when base64Url is not available', () => {
|
||||
render(<FileImageItem file={createFile({ base64Url: undefined })} />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/photo.png')
|
||||
})
|
||||
|
||||
it('should render delete button when showDeleteAction is true', () => {
|
||||
render(<FileImageItem file={createFile()} showDeleteAction />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should call onRemove when delete button is clicked', () => {
|
||||
const onRemove = vi.fn()
|
||||
render(<FileImageItem file={createFile()} showDeleteAction onRemove={onRemove} />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0])
|
||||
|
||||
expect(onRemove).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should render progress circle when file is uploading', () => {
|
||||
const { container } = render(
|
||||
<FileImageItem file={createFile({ progress: 50, uploadedId: undefined })} />,
|
||||
)
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
const progressSvg = Array.from(svgs).find(svg => svg.querySelector('circle'))
|
||||
expect(progressSvg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render replay icon when upload failed', () => {
|
||||
const { container } = render(<FileImageItem file={createFile({ progress: -1 })} />)
|
||||
|
||||
// ReplayLine renders as an SVG icon with data-icon attribute
|
||||
const replaySvg = container.querySelector('svg[data-icon="ReplayLine"]')
|
||||
expect(replaySvg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onReUpload when replay icon is clicked', () => {
|
||||
const onReUpload = vi.fn()
|
||||
const { container } = render(
|
||||
<FileImageItem file={createFile({ progress: -1 })} onReUpload={onReUpload} />,
|
||||
)
|
||||
|
||||
const replaySvg = container.querySelector('svg[data-icon="ReplayLine"]')
|
||||
fireEvent.click(replaySvg!)
|
||||
|
||||
expect(onReUpload).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should show image preview when clicked and canPreview is true', () => {
|
||||
render(<FileImageItem file={createFile()} canPreview />)
|
||||
|
||||
// Click the wrapper div (parent of the img element)
|
||||
const img = screen.getByRole('img')
|
||||
fireEvent.click(img.parentElement!)
|
||||
|
||||
// ImagePreview renders via createPortal with class "image-preview-container", not role="dialog"
|
||||
expect(document.querySelector('.image-preview-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show image preview when canPreview is false', () => {
|
||||
render(<FileImageItem file={createFile()} canPreview={false} />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
fireEvent.click(img.parentElement!)
|
||||
|
||||
expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close image preview when close is clicked', () => {
|
||||
render(<FileImageItem file={createFile()} canPreview />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
fireEvent.click(img.parentElement!)
|
||||
// ImagePreview renders via createPortal with class "image-preview-container"
|
||||
const previewContainer = document.querySelector('.image-preview-container')!
|
||||
expect(previewContainer).toBeInTheDocument()
|
||||
|
||||
// Close button is the last clickable div with an SVG in the preview container
|
||||
const closeIcon = screen.getByTestId('image-preview-close-button')
|
||||
fireEvent.click(closeIcon.parentElement!)
|
||||
|
||||
expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render download overlay when showDownloadAction is true', () => {
|
||||
const { container } = render(<FileImageItem file={createFile()} showDownloadAction />)
|
||||
|
||||
// The download icon SVG should be present
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should call downloadUrl when download button is clicked', async () => {
|
||||
const { downloadUrl } = await import('@/utils/download')
|
||||
const { container } = render(<FileImageItem file={createFile()} showDownloadAction />)
|
||||
|
||||
// Find the RiDownloadLine SVG (it doesn't have data-icon attribute, unlike ReplayLine)
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
const downloadSvg = Array.from(svgs).find(
|
||||
svg => !svg.hasAttribute('data-icon') && !svg.querySelector('circle'),
|
||||
)
|
||||
fireEvent.click(downloadSvg!.parentElement!)
|
||||
|
||||
expect(downloadUrl).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not render delete button when showDeleteAction is false', () => {
|
||||
render(<FileImageItem file={createFile()} />)
|
||||
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should use url when both base64Url and url fallback for image render', () => {
|
||||
render(<FileImageItem file={createFile({ base64Url: undefined, url: 'https://example.com/img.png' })} />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/img.png')
|
||||
})
|
||||
|
||||
it('should render image element even when both base64Url and url are undefined', () => {
|
||||
render(<FileImageItem file={createFile({ base64Url: undefined, url: undefined })} />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use url with attachment param for download_url when url is available', async () => {
|
||||
const { downloadUrl } = await import('@/utils/download')
|
||||
const file = createFile({ url: 'https://example.com/photo.png' })
|
||||
const { container } = render(<FileImageItem file={file} showDownloadAction />)
|
||||
|
||||
// The download SVG should be rendered
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
expect(svgs.length).toBeGreaterThanOrEqual(1)
|
||||
const downloadSvg = Array.from(svgs).find(
|
||||
svg => !svg.hasAttribute('data-icon') && !svg.querySelector('circle'),
|
||||
)
|
||||
fireEvent.click(downloadSvg!.parentElement!)
|
||||
expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: expect.stringContaining('as_attachment=true'),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should use base64Url for download_url when url is not available', async () => {
|
||||
const { downloadUrl } = await import('@/utils/download')
|
||||
const file = createFile({ url: undefined, base64Url: 'data:image/png;base64,abc' })
|
||||
const { container } = render(<FileImageItem file={file} showDownloadAction />)
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
const downloadSvg = Array.from(svgs).find(
|
||||
svg => !svg.hasAttribute('data-icon') && !svg.querySelector('circle'),
|
||||
)
|
||||
fireEvent.click(downloadSvg!.parentElement!)
|
||||
|
||||
expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: 'data:image/png;base64,abc',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should set preview url using base64Url when available', () => {
|
||||
render(<FileImageItem file={createFile({ base64Url: 'data:image/png;base64,abc', url: 'https://example.com/photo.png' })} canPreview />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
fireEvent.click(img.parentElement!)
|
||||
|
||||
expect(document.querySelector('.image-preview-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set preview url using url when base64Url is not available', () => {
|
||||
render(<FileImageItem file={createFile({ base64Url: undefined, url: 'https://example.com/photo.png' })} canPreview />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
fireEvent.click(img.parentElement!)
|
||||
|
||||
expect(document.querySelector('.image-preview-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set preview url to empty string when both base64Url and url are undefined', () => {
|
||||
render(<FileImageItem file={createFile({ base64Url: undefined, url: undefined })} canPreview />)
|
||||
|
||||
const img = screen.getByRole('img')
|
||||
fireEvent.click(img.parentElement!)
|
||||
|
||||
// Preview won't show because imagePreviewUrl is empty string (falsy)
|
||||
expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call downloadUrl with correct params when download button is clicked', async () => {
|
||||
const { downloadUrl } = await import('@/utils/download')
|
||||
const file = createFile({ url: 'https://example.com/photo.png', name: 'photo.png' })
|
||||
const { container } = render(<FileImageItem file={file} showDownloadAction />)
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
const downloadSvg = Array.from(svgs).find(
|
||||
svg => !svg.hasAttribute('data-icon') && !svg.querySelector('circle'),
|
||||
)
|
||||
fireEvent.click(downloadSvg!.parentElement!)
|
||||
|
||||
expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: expect.stringContaining('as_attachment=true'),
|
||||
fileName: 'photo.png',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,337 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import FileItem from './file-item'
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatFileSize: (size: number) => `${size}B`,
|
||||
}))
|
||||
|
||||
vi.mock('../dynamic-pdf-preview', () => ({
|
||||
default: ({ url, onCancel }: { url: string, onCancel: () => void }) => (
|
||||
<div data-testid="pdf-preview" data-url={url}>
|
||||
<button data-testid="pdf-close" onClick={onCancel}>Close PDF</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: 'file-1',
|
||||
name: 'document.pdf',
|
||||
size: 2048,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'document',
|
||||
uploadedId: 'uploaded-1',
|
||||
url: 'https://example.com/document.pdf',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('FileItem (chat-input)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render file name', () => {
|
||||
render(<FileItem file={createFile()} />)
|
||||
|
||||
expect(screen.getByText(/document\.pdf/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file extension and size', () => {
|
||||
const { container } = render(<FileItem file={createFile()} />)
|
||||
|
||||
// Extension and size are rendered as text nodes in the metadata div
|
||||
expect(container.textContent).toContain('pdf')
|
||||
expect(container.textContent).toContain('2048B')
|
||||
})
|
||||
|
||||
it('should render FileTypeIcon', () => {
|
||||
const { container } = render(<FileItem file={createFile()} />)
|
||||
|
||||
const fileTypeIcon = container.querySelector('svg')
|
||||
expect(fileTypeIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete button when showDeleteAction is true', () => {
|
||||
render(<FileItem file={createFile()} showDeleteAction />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should call onRemove when delete button is clicked', () => {
|
||||
const onRemove = vi.fn()
|
||||
render(<FileItem file={createFile()} showDeleteAction onRemove={onRemove} />)
|
||||
const delete_button = screen.getByTestId('delete-button')
|
||||
fireEvent.click(delete_button)
|
||||
expect(onRemove).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should render progress circle when file is uploading', () => {
|
||||
const { container } = render(
|
||||
<FileItem file={createFile({ progress: 50, uploadedId: undefined })} />,
|
||||
)
|
||||
|
||||
const progressSvg = container.querySelector('svg circle')
|
||||
expect(progressSvg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render replay icon when upload failed', () => {
|
||||
render(<FileItem file={createFile({ progress: -1 })} />)
|
||||
|
||||
const replayIcon = screen.getByTestId('replay-icon')
|
||||
expect(replayIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onReUpload when replay icon is clicked', () => {
|
||||
const onReUpload = vi.fn()
|
||||
render(
|
||||
<FileItem file={createFile({ progress: -1 })} onReUpload={onReUpload} />,
|
||||
)
|
||||
|
||||
const replayIcon = screen.getByTestId('replay-icon')
|
||||
fireEvent.click(replayIcon!)
|
||||
|
||||
expect(onReUpload).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should have error styling when upload failed', () => {
|
||||
const { container } = render(<FileItem file={createFile({ progress: -1 })} />)
|
||||
const fileItemContainer = container.firstChild as HTMLElement
|
||||
expect(fileItemContainer).toHaveClass('border-state-destructive-border')
|
||||
expect(fileItemContainer).toHaveClass('bg-state-destructive-hover-alt')
|
||||
})
|
||||
|
||||
it('should show audio preview when audio file name is clicked', async () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({
|
||||
name: 'audio.mp3',
|
||||
type: 'audio/mpeg',
|
||||
url: 'https://example.com/audio.mp3',
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/audio\.mp3/i))
|
||||
|
||||
const audioElement = document.querySelector('audio')
|
||||
expect(audioElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show video preview when video file name is clicked', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({
|
||||
name: 'video.mp4',
|
||||
type: 'video/mp4',
|
||||
url: 'https://example.com/video.mp4',
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/video\.mp4/i))
|
||||
|
||||
const videoElement = document.querySelector('video')
|
||||
expect(videoElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show pdf preview when pdf file name is clicked', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({
|
||||
name: 'doc.pdf',
|
||||
type: 'application/pdf',
|
||||
url: 'https://example.com/doc.pdf',
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/doc\.pdf/i))
|
||||
|
||||
expect(screen.getByTestId('pdf-preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close audio preview', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({
|
||||
name: 'audio.mp3',
|
||||
type: 'audio/mpeg',
|
||||
url: 'https://example.com/audio.mp3',
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/audio\.mp3/i))
|
||||
expect(document.querySelector('audio')).toBeInTheDocument()
|
||||
|
||||
const deleteButton = screen.getByTestId('close-btn')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(document.querySelector('audio')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render download button when showDownloadAction is true and url exists', () => {
|
||||
render(<FileItem file={createFile()} showDownloadAction />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should call downloadUrl when download button is clicked', async () => {
|
||||
const { downloadUrl } = await import('@/utils/download')
|
||||
render(<FileItem file={createFile()} showDownloadAction />)
|
||||
|
||||
const downloadBtn = screen.getByTestId('download-button')
|
||||
fireEvent.click(downloadBtn)
|
||||
|
||||
expect(downloadUrl).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not render download button when showDownloadAction is false', () => {
|
||||
render(<FileItem file={createFile()} showDownloadAction={false} />)
|
||||
|
||||
const buttons = screen.queryAllByRole('button')
|
||||
expect(buttons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should not show preview when canPreview is false', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({
|
||||
name: 'audio.mp3',
|
||||
type: 'audio/mpeg',
|
||||
})}
|
||||
canPreview={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/audio\.mp3/i))
|
||||
|
||||
expect(document.querySelector('audio')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close video preview', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({
|
||||
name: 'video.mp4',
|
||||
type: 'video/mp4',
|
||||
url: 'https://example.com/video.mp4',
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/video\.mp4/i))
|
||||
expect(document.querySelector('video')).toBeInTheDocument()
|
||||
|
||||
const closeBtn = screen.getByTestId('video-preview-close-btn')
|
||||
fireEvent.click(closeBtn)
|
||||
|
||||
expect(document.querySelector('video')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close pdf preview', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({
|
||||
name: 'doc.pdf',
|
||||
type: 'application/pdf',
|
||||
url: 'https://example.com/doc.pdf',
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/doc\.pdf/i))
|
||||
expect(screen.getByTestId('pdf-preview')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('pdf-close'))
|
||||
expect(screen.queryByTestId('pdf-preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use createObjectURL when no url or base64Url but has originalFile', () => {
|
||||
const mockUrl = 'blob:http://localhost/test-blob'
|
||||
const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue(mockUrl)
|
||||
|
||||
const file = createFile({
|
||||
name: 'audio.mp3',
|
||||
type: 'audio/mpeg',
|
||||
url: undefined,
|
||||
base64Url: undefined,
|
||||
originalFile: new File(['content'], 'audio.mp3', { type: 'audio/mpeg' }),
|
||||
})
|
||||
render(<FileItem file={file} canPreview />)
|
||||
|
||||
fireEvent.click(screen.getByText(/audio\.mp3/i))
|
||||
|
||||
expect(document.querySelector('audio')).toBeInTheDocument()
|
||||
expect(createObjectURLSpy).toHaveBeenCalled()
|
||||
createObjectURLSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should not use createObjectURL when no originalFile and no urls', () => {
|
||||
const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL')
|
||||
const file = createFile({
|
||||
name: 'audio.mp3',
|
||||
type: 'audio/mpeg',
|
||||
url: undefined,
|
||||
base64Url: undefined,
|
||||
originalFile: undefined,
|
||||
})
|
||||
render(<FileItem file={file} canPreview />)
|
||||
|
||||
fireEvent.click(screen.getByText(/audio\.mp3/i))
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
createObjectURLSpy.mockRestore()
|
||||
expect(document.querySelector('audio')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render download button when download_url is falsy', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({ url: undefined, base64Url: undefined })}
|
||||
showDownloadAction
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.queryAllByRole('button')
|
||||
expect(buttons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should render download button when base64Url is available as download_url', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({ url: undefined, base64Url: 'data:application/pdf;base64,abc' })}
|
||||
showDownloadAction
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should not render extension separator when ext is empty', () => {
|
||||
render(<FileItem file={createFile({ name: 'noext' })} />)
|
||||
|
||||
expect(screen.getByText(/noext/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render file size when size is 0', () => {
|
||||
render(<FileItem file={createFile({ size: 0 })} />)
|
||||
|
||||
expect(screen.queryByText(/0B/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,10 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiDownloadLine,
|
||||
} from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Button from '@/app/components/base/button'
|
||||
import AudioPreview from '@/app/components/base/file-uploader/audio-preview'
|
||||
import PdfPreview from '@/app/components/base/file-uploader/dynamic-pdf-preview'
|
||||
import VideoPreview from '@/app/components/base/file-uploader/video-preview'
|
||||
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
@@ -62,20 +57,21 @@ const FileItem = ({
|
||||
<Button
|
||||
className="absolute -right-1.5 -top-1.5 z-[11] hidden h-5 w-5 rounded-full p-0 group-hover/file-item:flex"
|
||||
onClick={() => onRemove?.(id)}
|
||||
data-testid="delete-button"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-components-button-secondary-text" />
|
||||
<span className="i-ri-close-line h-4 w-4 text-components-button-secondary-text" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<div
|
||||
className="system-xs-medium mb-1 line-clamp-2 h-8 cursor-pointer break-all text-text-tertiary"
|
||||
className="mb-1 line-clamp-2 h-8 cursor-pointer break-all text-text-tertiary system-xs-medium"
|
||||
title={name}
|
||||
onClick={() => canPreview && setPreviewUrl(tmp_preview_url || '')}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
<div className="relative flex items-center justify-between">
|
||||
<div className="system-2xs-medium-uppercase flex items-center text-text-tertiary">
|
||||
<div className="flex items-center text-text-tertiary system-2xs-medium-uppercase">
|
||||
<FileTypeIcon
|
||||
size="sm"
|
||||
type={getFileAppearanceType(name, type)}
|
||||
@@ -102,8 +98,9 @@ const FileItem = ({
|
||||
e.stopPropagation()
|
||||
downloadUrl({ url: download_url || '', fileName: name, target: '_blank' })
|
||||
}}
|
||||
data-testid="download-button"
|
||||
>
|
||||
<RiDownloadLine className="h-3.5 w-3.5 text-text-tertiary" />
|
||||
<span className="i-ri-download-line h-3.5 w-3.5 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
@@ -118,10 +115,7 @@ const FileItem = ({
|
||||
}
|
||||
{
|
||||
uploadError && (
|
||||
<ReplayLine
|
||||
className="h-4 w-4 text-text-tertiary"
|
||||
onClick={() => onReUpload?.(id)}
|
||||
/>
|
||||
<span className="i-custom-vender-other-replay-line h-4 w-4 cursor-pointer text-text-tertiary" onClick={() => onReUpload?.(id)} data-testid="replay-icon" role="button" tabIndex={0} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { FileContextProvider } from '../store'
|
||||
import { FileList, FileListInChatInput } from './file-list'
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useFile: () => ({
|
||||
handleRemoveFile: vi.fn(),
|
||||
handleReUploadFile: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatFileSize: (size: number) => `${size}B`,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
const createFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: `file-${Math.random()}`,
|
||||
name: 'document.pdf',
|
||||
size: 1024,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'document',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('FileList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render FileImageItem for image files', () => {
|
||||
const files = [createFile({
|
||||
name: 'photo.png',
|
||||
type: 'image/png',
|
||||
supportFileType: 'image',
|
||||
base64Url: 'data:image/png;base64,abc',
|
||||
})]
|
||||
render(<FileList files={files} />)
|
||||
|
||||
expect(screen.getByRole('img')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render FileItem for non-image files', () => {
|
||||
const files = [createFile({
|
||||
name: 'document.pdf',
|
||||
supportFileType: 'document',
|
||||
})]
|
||||
render(<FileList files={files} />)
|
||||
|
||||
expect(screen.getByText(/document\.pdf/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both image and non-image files', () => {
|
||||
const files = [
|
||||
createFile({
|
||||
name: 'photo.png',
|
||||
type: 'image/png',
|
||||
supportFileType: 'image',
|
||||
base64Url: 'data:image/png;base64,abc',
|
||||
}),
|
||||
createFile({ name: 'doc.pdf', supportFileType: 'document' }),
|
||||
]
|
||||
render(<FileList files={files} />)
|
||||
|
||||
expect(screen.getByRole('img')).toBeInTheDocument()
|
||||
expect(screen.getByText(/doc\.pdf/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty list when no files', () => {
|
||||
const { container } = render(<FileList files={[]} />)
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('img')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<FileList files={[]} className="custom-class" />)
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should render multiple files', () => {
|
||||
const files = [
|
||||
createFile({ name: 'a.pdf' }),
|
||||
createFile({ name: 'b.pdf' }),
|
||||
createFile({ name: 'c.pdf' }),
|
||||
]
|
||||
render(<FileList files={files} />)
|
||||
|
||||
expect(screen.getByText(/a\.pdf/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/b\.pdf/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/c\.pdf/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileListInChatInput', () => {
|
||||
let mockStoreFiles: FileEntity[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStoreFiles = []
|
||||
})
|
||||
|
||||
it('should render FileList with files from store', () => {
|
||||
mockStoreFiles = [createFile({ name: 'test.pdf' })]
|
||||
const fileConfig = { enabled: true, allowed_file_types: ['document'] } as FileUpload
|
||||
|
||||
render(
|
||||
<FileContextProvider value={mockStoreFiles}>
|
||||
<FileListInChatInput fileConfig={fileConfig} />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/test\.pdf/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render empty FileList when store has no files', () => {
|
||||
const fileConfig = { enabled: true, allowed_file_types: ['document'] } as FileUpload
|
||||
|
||||
render(
|
||||
<FileContextProvider value={mockStoreFiles}>
|
||||
<FileListInChatInput fileConfig={fileConfig} />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.queryAllByRole('img')).toHaveLength(0)
|
||||
expect(screen.queryByText(/\.pdf/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FileContextProvider } from '../store'
|
||||
import FileUploaderInChatInput from './index'
|
||||
|
||||
vi.mock('@/types/app', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/types/app')>()
|
||||
return {
|
||||
...actual,
|
||||
TransferMethod: {
|
||||
local_file: 'local_file',
|
||||
remote_url: 'remote_url',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useFile: () => ({
|
||||
handleLoadFileFromLink: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
function renderWithProvider(ui: React.ReactElement) {
|
||||
return render(
|
||||
<FileContextProvider>
|
||||
{ui}
|
||||
</FileContextProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => ({
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_upload_methods: ['local_file', 'remote_url'],
|
||||
allowed_file_extensions: [],
|
||||
number_limits: 5,
|
||||
...overrides,
|
||||
} as unknown as FileUpload)
|
||||
|
||||
describe('FileUploaderInChatInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render an attachment icon SVG', () => {
|
||||
renderWithProvider(<FileUploaderInChatInput fileConfig={createFileConfig()} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render FileFromLinkOrLocal when not readonly', () => {
|
||||
renderWithProvider(<FileUploaderInChatInput fileConfig={createFileConfig()} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should render only the trigger button when readonly', () => {
|
||||
renderWithProvider(<FileUploaderInChatInput fileConfig={createFileConfig()} readonly />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should render button with attachment icon for local_file upload method', () => {
|
||||
renderWithProvider(
|
||||
<FileUploaderInChatInput fileConfig={createFileConfig({
|
||||
allowed_file_upload_methods: ['local_file'],
|
||||
} as unknown as Partial<FileUpload>)}
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render button with attachment icon for remote_url upload method', () => {
|
||||
renderWithProvider(
|
||||
<FileUploaderInChatInput fileConfig={createFileConfig({
|
||||
allowed_file_upload_methods: ['remote_url'],
|
||||
} as unknown as Partial<FileUpload>)}
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply open state styling when trigger is activated', () => {
|
||||
renderWithProvider(<FileUploaderInChatInput fileConfig={createFileConfig()} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
867
web/app/components/base/file-uploader/hooks.spec.ts
Normal file
867
web/app/components/base/file-uploader/hooks.spec.ts
Normal file
@@ -0,0 +1,867 @@
|
||||
import type { FileEntity } from './types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useFile, useFileSizeLimit } from './hooks'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ token: undefined }),
|
||||
}))
|
||||
|
||||
// Exception: hook requires toast context that isn't available without a provider wrapper
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetFiles = vi.fn()
|
||||
let mockStoreFiles: FileEntity[] = []
|
||||
vi.mock('./store', () => ({
|
||||
useFileStore: () => ({
|
||||
getState: () => ({
|
||||
files: mockStoreFiles,
|
||||
setFiles: mockSetFiles,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockFileUpload = vi.fn()
|
||||
const mockIsAllowedFileExtension = vi.fn().mockReturnValue(true)
|
||||
const mockGetSupportFileType = vi.fn().mockReturnValue('document')
|
||||
vi.mock('./utils', () => ({
|
||||
fileUpload: (...args: unknown[]) => mockFileUpload(...args),
|
||||
getFileUploadErrorMessage: vi.fn().mockReturnValue('Upload error'),
|
||||
getSupportFileType: (...args: unknown[]) => mockGetSupportFileType(...args),
|
||||
isAllowedFileExtension: (...args: unknown[]) => mockIsAllowedFileExtension(...args),
|
||||
}))
|
||||
|
||||
const mockUploadRemoteFileInfo = vi.fn()
|
||||
vi.mock('@/service/common', () => ({
|
||||
uploadRemoteFileInfo: (...args: unknown[]) => mockUploadRemoteFileInfo(...args),
|
||||
}))
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: () => 'mock-uuid',
|
||||
}))
|
||||
|
||||
describe('useFileSizeLimit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return default limits when no config is provided', () => {
|
||||
const { result } = renderHook(() => useFileSizeLimit())
|
||||
|
||||
expect(result.current.imgSizeLimit).toBe(10 * 1024 * 1024)
|
||||
expect(result.current.docSizeLimit).toBe(15 * 1024 * 1024)
|
||||
expect(result.current.audioSizeLimit).toBe(50 * 1024 * 1024)
|
||||
expect(result.current.videoSizeLimit).toBe(100 * 1024 * 1024)
|
||||
expect(result.current.maxFileUploadLimit).toBe(10)
|
||||
})
|
||||
|
||||
it('should use config values when provided', () => {
|
||||
const config: FileUploadConfigResponse = {
|
||||
image_file_size_limit: 20,
|
||||
file_size_limit: 30,
|
||||
audio_file_size_limit: 100,
|
||||
video_file_size_limit: 200,
|
||||
workflow_file_upload_limit: 20,
|
||||
} as FileUploadConfigResponse
|
||||
|
||||
const { result } = renderHook(() => useFileSizeLimit(config))
|
||||
|
||||
expect(result.current.imgSizeLimit).toBe(20 * 1024 * 1024)
|
||||
expect(result.current.docSizeLimit).toBe(30 * 1024 * 1024)
|
||||
expect(result.current.audioSizeLimit).toBe(100 * 1024 * 1024)
|
||||
expect(result.current.videoSizeLimit).toBe(200 * 1024 * 1024)
|
||||
expect(result.current.maxFileUploadLimit).toBe(20)
|
||||
})
|
||||
|
||||
it('should fall back to defaults when config values are zero', () => {
|
||||
const config = {
|
||||
image_file_size_limit: 0,
|
||||
file_size_limit: 0,
|
||||
audio_file_size_limit: 0,
|
||||
video_file_size_limit: 0,
|
||||
workflow_file_upload_limit: 0,
|
||||
} as FileUploadConfigResponse
|
||||
|
||||
const { result } = renderHook(() => useFileSizeLimit(config))
|
||||
|
||||
expect(result.current.imgSizeLimit).toBe(10 * 1024 * 1024)
|
||||
expect(result.current.docSizeLimit).toBe(15 * 1024 * 1024)
|
||||
expect(result.current.audioSizeLimit).toBe(50 * 1024 * 1024)
|
||||
expect(result.current.videoSizeLimit).toBe(100 * 1024 * 1024)
|
||||
expect(result.current.maxFileUploadLimit).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFile', () => {
|
||||
const defaultFileConfig: FileUpload = {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image', 'document'],
|
||||
allowed_file_extensions: [],
|
||||
number_limits: 5,
|
||||
} as FileUpload
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStoreFiles = []
|
||||
mockIsAllowedFileExtension.mockReturnValue(true)
|
||||
mockGetSupportFileType.mockReturnValue('document')
|
||||
})
|
||||
|
||||
it('should return all file handler functions', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
expect(result.current.handleAddFile).toBeDefined()
|
||||
expect(result.current.handleUpdateFile).toBeDefined()
|
||||
expect(result.current.handleRemoveFile).toBeDefined()
|
||||
expect(result.current.handleReUploadFile).toBeDefined()
|
||||
expect(result.current.handleLoadFileFromLink).toBeDefined()
|
||||
expect(result.current.handleLoadFileFromLinkSuccess).toBeDefined()
|
||||
expect(result.current.handleLoadFileFromLinkError).toBeDefined()
|
||||
expect(result.current.handleClearFiles).toBeDefined()
|
||||
expect(result.current.handleLocalFileUpload).toBeDefined()
|
||||
expect(result.current.handleClipboardPasteFile).toBeDefined()
|
||||
expect(result.current.handleDragFileEnter).toBeDefined()
|
||||
expect(result.current.handleDragFileOver).toBeDefined()
|
||||
expect(result.current.handleDragFileLeave).toBeDefined()
|
||||
expect(result.current.handleDropFile).toBeDefined()
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
})
|
||||
|
||||
it('should add a file via handleAddFile', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
result.current.handleAddFile({
|
||||
id: 'test-id',
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
progress: 0,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
} as FileEntity)
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update a file via handleUpdateFile', () => {
|
||||
mockStoreFiles = [{ id: 'file-1', name: 'a.txt', progress: 0 }] as FileEntity[]
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
result.current.handleUpdateFile({ id: 'file-1', name: 'a.txt', progress: 50 } as FileEntity)
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not update file when id is not found', () => {
|
||||
mockStoreFiles = [{ id: 'file-1', name: 'a.txt' }] as FileEntity[]
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
result.current.handleUpdateFile({ id: 'nonexistent' } as FileEntity)
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove a file via handleRemoveFile', () => {
|
||||
mockStoreFiles = [{ id: 'file-1', name: 'a.txt' }] as FileEntity[]
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
result.current.handleRemoveFile('file-1')
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear all files via handleClearFiles', () => {
|
||||
mockStoreFiles = [{ id: 'a' }] as FileEntity[]
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
result.current.handleClearFiles()
|
||||
expect(mockSetFiles).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
describe('handleReUploadFile', () => {
|
||||
it('should re-upload a file and call fileUpload', () => {
|
||||
const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
mockStoreFiles = [{
|
||||
id: 'file-1',
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
progress: -1,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
originalFile,
|
||||
}] as FileEntity[]
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
result.current.handleReUploadFile('file-1')
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
expect(mockFileUpload).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not re-upload when file id is not found', () => {
|
||||
mockStoreFiles = []
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
result.current.handleReUploadFile('nonexistent')
|
||||
expect(mockFileUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle progress callback during re-upload', () => {
|
||||
const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
mockStoreFiles = [{
|
||||
id: 'file-1',
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
progress: -1,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
originalFile,
|
||||
}] as FileEntity[]
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleReUploadFile('file-1')
|
||||
|
||||
const uploadCall = mockFileUpload.mock.calls[0][0]
|
||||
uploadCall.onProgressCallback(50)
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle success callback during re-upload', () => {
|
||||
const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
mockStoreFiles = [{
|
||||
id: 'file-1',
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
progress: -1,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
originalFile,
|
||||
}] as FileEntity[]
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleReUploadFile('file-1')
|
||||
|
||||
const uploadCall = mockFileUpload.mock.calls[0][0]
|
||||
uploadCall.onSuccessCallback({ id: 'uploaded-1' })
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle error callback during re-upload', () => {
|
||||
const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
mockStoreFiles = [{
|
||||
id: 'file-1',
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
progress: -1,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
originalFile,
|
||||
}] as FileEntity[]
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleReUploadFile('file-1')
|
||||
|
||||
const uploadCall = mockFileUpload.mock.calls[0][0]
|
||||
uploadCall.onErrorCallback(new Error('fail'))
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleLoadFileFromLink', () => {
|
||||
it('should run startProgressTimer to increment file progress', () => {
|
||||
vi.useFakeTimers()
|
||||
mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {})) // never resolves
|
||||
|
||||
// Set up a file in the store that has progress 0
|
||||
mockStoreFiles = [{
|
||||
id: 'mock-uuid',
|
||||
name: 'https://example.com/file.txt',
|
||||
type: '',
|
||||
size: 0,
|
||||
progress: 0,
|
||||
transferMethod: 'remote_url',
|
||||
supportFileType: '',
|
||||
}] as FileEntity[]
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
|
||||
// Advance timer to trigger the interval
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should add file and call uploadRemoteFileInfo', () => {
|
||||
mockUploadRemoteFileInfo.mockResolvedValue({
|
||||
id: 'remote-1',
|
||||
mime_type: 'text/plain',
|
||||
size: 100,
|
||||
name: 'remote.txt',
|
||||
url: 'https://example.com/remote.txt',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/file.txt', false)
|
||||
})
|
||||
|
||||
it('should remove file when extension is not allowed', async () => {
|
||||
mockIsAllowedFileExtension.mockReturnValue(false)
|
||||
mockUploadRemoteFileInfo.mockResolvedValue({
|
||||
id: 'remote-1',
|
||||
mime_type: 'text/plain',
|
||||
size: 100,
|
||||
name: 'remote.txt',
|
||||
url: 'https://example.com/remote.txt',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
await act(async () => {
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should use empty arrays when allowed_file_types and allowed_file_extensions are undefined', async () => {
|
||||
mockIsAllowedFileExtension.mockReturnValue(false)
|
||||
mockUploadRemoteFileInfo.mockResolvedValue({
|
||||
id: 'remote-1',
|
||||
mime_type: 'text/plain',
|
||||
size: 100,
|
||||
name: 'remote.txt',
|
||||
url: 'https://example.com/remote.txt',
|
||||
})
|
||||
|
||||
const configWithUndefined = {
|
||||
...defaultFileConfig,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
} as unknown as FileUpload
|
||||
|
||||
const { result } = renderHook(() => useFile(configWithUndefined))
|
||||
await act(async () => {
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
expect(mockIsAllowedFileExtension).toHaveBeenCalledWith('remote.txt', 'text/plain', [], [])
|
||||
})
|
||||
|
||||
it('should remove file when remote upload fails', async () => {
|
||||
mockUploadRemoteFileInfo.mockRejectedValue(new Error('network error'))
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
await act(async () => {
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
await vi.waitFor(() => expect(mockNotify).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should remove file when size limit is exceeded on remote upload', async () => {
|
||||
mockGetSupportFileType.mockReturnValue('image')
|
||||
mockUploadRemoteFileInfo.mockResolvedValue({
|
||||
id: 'remote-1',
|
||||
mime_type: 'image/png',
|
||||
size: 20 * 1024 * 1024,
|
||||
name: 'large.png',
|
||||
url: 'https://example.com/large.png',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
await act(async () => {
|
||||
result.current.handleLoadFileFromLink('https://example.com/large.png')
|
||||
await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
// File should be removed because image exceeds 10MB limit
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should update file on successful remote upload within limits', async () => {
|
||||
mockUploadRemoteFileInfo.mockResolvedValue({
|
||||
id: 'remote-1',
|
||||
mime_type: 'text/plain',
|
||||
size: 100,
|
||||
name: 'remote.txt',
|
||||
url: 'https://example.com/remote.txt',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
await act(async () => {
|
||||
result.current.handleLoadFileFromLink('https://example.com/remote.txt')
|
||||
await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
// setFiles should be called: once for add, once for update
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop progress timer when file reaches 80 percent', () => {
|
||||
vi.useFakeTimers()
|
||||
mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
// Set up a file already at 80% progress
|
||||
mockStoreFiles = [{
|
||||
id: 'mock-uuid',
|
||||
name: 'https://example.com/file.txt',
|
||||
type: '',
|
||||
size: 0,
|
||||
progress: 80,
|
||||
transferMethod: 'remote_url',
|
||||
supportFileType: '',
|
||||
}] as FileEntity[]
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
|
||||
// At progress 80, the timer should stop (clearTimeout path)
|
||||
vi.advanceTimersByTime(200)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should stop progress timer when progress is negative', () => {
|
||||
vi.useFakeTimers()
|
||||
mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
// Set up a file with negative progress (error state)
|
||||
mockStoreFiles = [{
|
||||
id: 'mock-uuid',
|
||||
name: 'https://example.com/file.txt',
|
||||
type: '',
|
||||
size: 0,
|
||||
progress: -1,
|
||||
transferMethod: 'remote_url',
|
||||
supportFileType: '',
|
||||
}] as FileEntity[]
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
|
||||
vi.advanceTimersByTime(200)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleLocalFileUpload', () => {
|
||||
let capturedListeners: Record<string, (() => void)[]>
|
||||
let mockReaderResult: string | null
|
||||
|
||||
beforeEach(() => {
|
||||
capturedListeners = {}
|
||||
mockReaderResult = 'data:text/plain;base64,Y29udGVudA=='
|
||||
|
||||
class MockFileReader {
|
||||
result: string | null = null
|
||||
addEventListener(event: string, handler: () => void) {
|
||||
if (!capturedListeners[event])
|
||||
capturedListeners[event] = []
|
||||
capturedListeners[event].push(handler)
|
||||
}
|
||||
|
||||
readAsDataURL() {
|
||||
this.result = mockReaderResult
|
||||
capturedListeners.load?.forEach(handler => handler())
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('FileReader', MockFileReader)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should upload a local file', () => {
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject file with unsupported extension', () => {
|
||||
mockIsAllowedFileExtension.mockReturnValue(false)
|
||||
const file = new File(['content'], 'test.xyz', { type: 'application/xyz' })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
expect(mockSetFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use empty arrays when allowed_file_types and allowed_file_extensions are undefined', () => {
|
||||
mockIsAllowedFileExtension.mockReturnValue(false)
|
||||
const file = new File(['content'], 'test.xyz', { type: 'application/xyz' })
|
||||
|
||||
const configWithUndefined = {
|
||||
...defaultFileConfig,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
} as unknown as FileUpload
|
||||
|
||||
const { result } = renderHook(() => useFile(configWithUndefined))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockIsAllowedFileExtension).toHaveBeenCalledWith('test.xyz', 'application/xyz', [], [])
|
||||
})
|
||||
|
||||
it('should reject file when upload is disabled and noNeedToCheckEnable is false', () => {
|
||||
const disabledConfig = { ...defaultFileConfig, enabled: false } as FileUpload
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
const { result } = renderHook(() => useFile(disabledConfig, false))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should reject image file exceeding size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('image')
|
||||
const largeFile = new File([new ArrayBuffer(20 * 1024 * 1024)], 'large.png', { type: 'image/png' })
|
||||
Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(largeFile)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should reject audio file exceeding size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('audio')
|
||||
const largeFile = new File([], 'large.mp3', { type: 'audio/mpeg' })
|
||||
Object.defineProperty(largeFile, 'size', { value: 60 * 1024 * 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(largeFile)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should reject video file exceeding size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('video')
|
||||
const largeFile = new File([], 'large.mp4', { type: 'video/mp4' })
|
||||
Object.defineProperty(largeFile, 'size', { value: 200 * 1024 * 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(largeFile)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should reject document file exceeding size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('document')
|
||||
const largeFile = new File([], 'large.pdf', { type: 'application/pdf' })
|
||||
Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(largeFile)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should reject custom file exceeding document size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('custom')
|
||||
const largeFile = new File([], 'large.xyz', { type: 'application/octet-stream' })
|
||||
Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(largeFile)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should allow custom file within document size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('custom')
|
||||
const file = new File(['content'], 'file.xyz', { type: 'application/octet-stream' })
|
||||
Object.defineProperty(file, 'size', { value: 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow document file within size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('document')
|
||||
const file = new File(['content'], 'small.pdf', { type: 'application/pdf' })
|
||||
Object.defineProperty(file, 'size', { value: 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow file with unknown type (default case)', () => {
|
||||
mockGetSupportFileType.mockReturnValue('unknown')
|
||||
const file = new File(['content'], 'test.bin', { type: 'application/octet-stream' })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
// Should not be rejected - unknown type passes checkSizeLimit
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow image file within size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('image')
|
||||
const file = new File(['content'], 'small.png', { type: 'image/png' })
|
||||
Object.defineProperty(file, 'size', { value: 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow audio file within size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('audio')
|
||||
const file = new File(['content'], 'small.mp3', { type: 'audio/mpeg' })
|
||||
Object.defineProperty(file, 'size', { value: 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow video file within size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('video')
|
||||
const file = new File(['content'], 'small.mp4', { type: 'video/mp4' })
|
||||
Object.defineProperty(file, 'size', { value: 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set base64Url for image files during upload', () => {
|
||||
mockGetSupportFileType.mockReturnValue('image')
|
||||
const file = new File(['content'], 'photo.png', { type: 'image/png' })
|
||||
Object.defineProperty(file, 'size', { value: 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
// The file should have been added with base64Url set (for image type)
|
||||
const addedFiles = mockSetFiles.mock.calls[0][0]
|
||||
expect(addedFiles[0].base64Url).toBe('data:text/plain;base64,Y29udGVudA==')
|
||||
})
|
||||
|
||||
it('should set empty base64Url for non-image files during upload', () => {
|
||||
mockGetSupportFileType.mockReturnValue('document')
|
||||
const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
const addedFiles = mockSetFiles.mock.calls[0][0]
|
||||
expect(addedFiles[0].base64Url).toBe('')
|
||||
})
|
||||
|
||||
it('should call fileUpload with callbacks after FileReader loads', () => {
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockFileUpload).toHaveBeenCalled()
|
||||
const uploadCall = mockFileUpload.mock.calls[0][0]
|
||||
|
||||
// Test progress callback
|
||||
uploadCall.onProgressCallback(50)
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
|
||||
// Test success callback
|
||||
uploadCall.onSuccessCallback({ id: 'uploaded-1' })
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle fileUpload error callback', () => {
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
const uploadCall = mockFileUpload.mock.calls[0][0]
|
||||
uploadCall.onErrorCallback(new Error('upload failed'))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should handle FileReader error event', () => {
|
||||
capturedListeners = {}
|
||||
const errorListeners: (() => void)[] = []
|
||||
|
||||
class ErrorFileReader {
|
||||
result: string | null = null
|
||||
addEventListener(event: string, handler: () => void) {
|
||||
if (event === 'error')
|
||||
errorListeners.push(handler)
|
||||
if (!capturedListeners[event])
|
||||
capturedListeners[event] = []
|
||||
capturedListeners[event].push(handler)
|
||||
}
|
||||
|
||||
readAsDataURL() {
|
||||
// Simulate error instead of load
|
||||
errorListeners.forEach(handler => handler())
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('FileReader', ErrorFileReader)
|
||||
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleClipboardPasteFile', () => {
|
||||
it('should handle file paste from clipboard', () => {
|
||||
const file = new File(['content'], 'pasted.png', { type: 'image/png' })
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
const event = {
|
||||
clipboardData: {
|
||||
files: [file],
|
||||
getData: () => '',
|
||||
},
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.ClipboardEvent<HTMLTextAreaElement>
|
||||
|
||||
result.current.handleClipboardPasteFile(event)
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not handle paste when text is present', () => {
|
||||
const file = new File(['content'], 'pasted.png', { type: 'image/png' })
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
const event = {
|
||||
clipboardData: {
|
||||
files: [file],
|
||||
getData: () => 'some text',
|
||||
},
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.ClipboardEvent<HTMLTextAreaElement>
|
||||
|
||||
result.current.handleClipboardPasteFile(event)
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag and drop handlers', () => {
|
||||
it('should set isDragActive on drag enter', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement>
|
||||
act(() => {
|
||||
result.current.handleDragFileEnter(event)
|
||||
})
|
||||
|
||||
expect(result.current.isDragActive).toBe(true)
|
||||
})
|
||||
|
||||
it('should call preventDefault on drag over', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement>
|
||||
result.current.handleDragFileOver(event)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should unset isDragActive on drag leave', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
const enterEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement>
|
||||
act(() => {
|
||||
result.current.handleDragFileEnter(enterEvent)
|
||||
})
|
||||
expect(result.current.isDragActive).toBe(true)
|
||||
|
||||
const leaveEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement>
|
||||
act(() => {
|
||||
result.current.handleDragFileLeave(leaveEvent)
|
||||
})
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle file drop', () => {
|
||||
const file = new File(['content'], 'dropped.txt', { type: 'text/plain' })
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer: { files: [file] },
|
||||
} as unknown as React.DragEvent<HTMLElement>
|
||||
|
||||
act(() => {
|
||||
result.current.handleDropFile(event)
|
||||
})
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
})
|
||||
|
||||
it('should not upload when no file is dropped', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer: { files: [] },
|
||||
} as unknown as React.DragEvent<HTMLElement>
|
||||
|
||||
act(() => {
|
||||
result.current.handleDropFile(event)
|
||||
})
|
||||
|
||||
// No file upload should be triggered
|
||||
expect(mockSetFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('noop handlers', () => {
|
||||
it('should have handleLoadFileFromLinkSuccess as noop', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
expect(() => result.current.handleLoadFileFromLinkSuccess()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should have handleLoadFileFromLinkError as noop', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
expect(() => result.current.handleLoadFileFromLinkError()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter'
|
||||
import 'react-pdf-highlighter/dist/style.css'
|
||||
|
||||
export {
|
||||
PdfHighlighter,
|
||||
PdfLoader,
|
||||
}
|
||||
142
web/app/components/base/file-uploader/pdf-preview.spec.tsx
Normal file
142
web/app/components/base/file-uploader/pdf-preview.spec.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import PdfPreview from './pdf-preview'
|
||||
|
||||
vi.mock('./pdf-highlighter-adapter', () => ({
|
||||
PdfLoader: ({ children, beforeLoad }: { children: (doc: unknown) => ReactNode, beforeLoad: ReactNode }) => (
|
||||
<div data-testid="pdf-loader">
|
||||
{beforeLoad}
|
||||
{children({ numPages: 1 })}
|
||||
</div>
|
||||
),
|
||||
PdfHighlighter: ({ enableAreaSelection, highlightTransform, scrollRef, onScrollChange, onSelectionFinished }: {
|
||||
enableAreaSelection?: (event: MouseEvent) => boolean
|
||||
highlightTransform?: () => ReactNode
|
||||
scrollRef?: (ref: unknown) => void
|
||||
onScrollChange?: () => void
|
||||
onSelectionFinished?: () => unknown
|
||||
}) => {
|
||||
enableAreaSelection?.(new MouseEvent('click'))
|
||||
highlightTransform?.()
|
||||
scrollRef?.(null)
|
||||
onScrollChange?.()
|
||||
onSelectionFinished?.()
|
||||
return <div data-testid="pdf-highlighter" />
|
||||
},
|
||||
}))
|
||||
|
||||
describe('PdfPreview', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
const getScaleContainer = () => {
|
||||
const container = document.querySelector('div[style*="transform"]') as HTMLDivElement | null
|
||||
expect(container).toBeInTheDocument()
|
||||
return container!
|
||||
}
|
||||
|
||||
const getControl = (rightClass: 'right-24' | 'right-16' | 'right-6') => {
|
||||
const control = document.querySelector(`div.absolute.${rightClass}.top-6`) as HTMLDivElement | null
|
||||
expect(control).toBeInTheDocument()
|
||||
return control!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.innerWidth = 1024
|
||||
fireEvent(window, new Event('resize'))
|
||||
})
|
||||
|
||||
it('should render the pdf preview portal with overlay and loading indicator', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
expect(document.querySelector('[tabindex="-1"]')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('pdf-loader')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('pdf-highlighter')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render zoom in, zoom out, and close icon SVGs', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
const svgs = document.querySelectorAll('svg')
|
||||
expect(svgs.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('should zoom in when zoom in control is clicked', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.click(getControl('right-16'))
|
||||
|
||||
expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)')
|
||||
})
|
||||
|
||||
it('should zoom out when zoom out control is clicked', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.click(getControl('right-24'))
|
||||
|
||||
expect(getScaleContainer().getAttribute('style')).toMatch(/scale\(0\.8333/)
|
||||
})
|
||||
|
||||
it('should keep non-1 scale when zooming out from a larger scale', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.click(getControl('right-16'))
|
||||
fireEvent.click(getControl('right-16'))
|
||||
fireEvent.click(getControl('right-24'))
|
||||
|
||||
expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)')
|
||||
})
|
||||
|
||||
it('should reset scale back to 1 when zooming in then out', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.click(getControl('right-16'))
|
||||
fireEvent.click(getControl('right-24'))
|
||||
|
||||
expect(getScaleContainer().getAttribute('style')).toContain('scale(1)')
|
||||
})
|
||||
|
||||
it('should zoom in when ArrowUp key is pressed', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'ArrowUp', code: 'ArrowUp' })
|
||||
|
||||
expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)')
|
||||
})
|
||||
|
||||
it('should zoom out when ArrowDown key is pressed', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'ArrowDown', code: 'ArrowDown' })
|
||||
|
||||
expect(getScaleContainer().getAttribute('style')).toMatch(/scale\(0\.8333/)
|
||||
})
|
||||
|
||||
it('should call onCancel when close control is clicked', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.click(getControl('right-6'))
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when Escape key is pressed', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render the overlay and stop click propagation', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
const overlay = document.querySelector('[tabindex="-1"]')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
const event = new MouseEvent('click', { bubbles: true })
|
||||
const stopPropagation = vi.spyOn(event, 'stopPropagation')
|
||||
overlay!.dispatchEvent(event)
|
||||
expect(stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -6,11 +6,10 @@ import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import 'react-pdf-highlighter/dist/style.css'
|
||||
import { PdfHighlighter, PdfLoader } from './pdf-highlighter-adapter'
|
||||
|
||||
type PdfPreviewProps = {
|
||||
url: string
|
||||
|
||||
168
web/app/components/base/file-uploader/store.spec.tsx
Normal file
168
web/app/components/base/file-uploader/store.spec.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { FileEntity } from './types'
|
||||
import { render, renderHook, screen } from '@testing-library/react'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { createFileStore, FileContext, FileContextProvider, useFileStore, useStore } from './store'
|
||||
|
||||
const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: 'file-1',
|
||||
name: 'test.txt',
|
||||
size: 1024,
|
||||
type: 'text/plain',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'document',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('createFileStore', () => {
|
||||
it('should create a store with empty files by default', () => {
|
||||
const store = createFileStore()
|
||||
expect(store.getState().files).toEqual([])
|
||||
})
|
||||
|
||||
it('should create a store with empty array when value is falsy', () => {
|
||||
const store = createFileStore(undefined)
|
||||
expect(store.getState().files).toEqual([])
|
||||
})
|
||||
|
||||
it('should create a store with initial files', () => {
|
||||
const files = [createMockFile()]
|
||||
const store = createFileStore(files)
|
||||
expect(store.getState().files).toEqual(files)
|
||||
})
|
||||
|
||||
it('should spread initial value to create a new array', () => {
|
||||
const files = [createMockFile()]
|
||||
const store = createFileStore(files)
|
||||
expect(store.getState().files).not.toBe(files)
|
||||
expect(store.getState().files).toEqual(files)
|
||||
})
|
||||
|
||||
it('should update files via setFiles', () => {
|
||||
const store = createFileStore()
|
||||
const newFiles = [createMockFile()]
|
||||
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()]
|
||||
store.getState().setFiles(newFiles)
|
||||
expect(onChange).toHaveBeenCalledWith(newFiles)
|
||||
})
|
||||
|
||||
it('should not throw when onChange is not provided', () => {
|
||||
const store = createFileStore()
|
||||
expect(() => store.getState().setFiles([])).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useStore', () => {
|
||||
it('should return selected state from the store', () => {
|
||||
const files = [createMockFile()]
|
||||
const store = createFileStore(files)
|
||||
|
||||
const { result } = renderHook(() => useStore(s => s.files), {
|
||||
wrapper: ({ children }) => (
|
||||
<FileContext.Provider value={store}>{children}</FileContext.Provider>
|
||||
),
|
||||
})
|
||||
|
||||
expect(result.current).toEqual(files)
|
||||
})
|
||||
|
||||
it('should throw when used without FileContext.Provider', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useStore(s => s.files))
|
||||
}).toThrow('Missing FileContext.Provider in the tree')
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFileStore', () => {
|
||||
it('should return the store from context', () => {
|
||||
const store = createFileStore()
|
||||
|
||||
const { result } = renderHook(() => useFileStore(), {
|
||||
wrapper: ({ children }) => (
|
||||
<FileContext.Provider value={store}>{children}</FileContext.Provider>
|
||||
),
|
||||
})
|
||||
|
||||
expect(result.current).toBe(store)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileContextProvider', () => {
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<div data-testid="child">Hello</div>
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should provide a store to children', () => {
|
||||
const TestChild = () => {
|
||||
const files = useStore(s => s.files)
|
||||
return <div data-testid="files">{files.length}</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<TestChild />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('files')).toHaveTextContent('0')
|
||||
})
|
||||
|
||||
it('should initialize store with value prop', () => {
|
||||
const files = [createMockFile()]
|
||||
const TestChild = () => {
|
||||
const storeFiles = useStore(s => s.files)
|
||||
return <div data-testid="files">{storeFiles.length}</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<FileContextProvider value={files}>
|
||||
<TestChild />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('files')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('should reuse store on re-render instead of creating a new one', () => {
|
||||
const TestChild = () => {
|
||||
const storeFiles = useStore(s => s.files)
|
||||
return <div data-testid="files">{storeFiles.length}</div>
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<FileContextProvider>
|
||||
<TestChild />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('files')).toHaveTextContent('0')
|
||||
|
||||
// Re-render with new value prop - store should be reused (storeRef.current exists)
|
||||
rerender(
|
||||
<FileContextProvider value={[createMockFile()]}>
|
||||
<TestChild />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
// Store was created once on first render, so the value prop change won't create a new store
|
||||
// The files count should still be 0 since storeRef.current is already set
|
||||
expect(screen.getByTestId('files')).toHaveTextContent('0')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import mime from 'mime'
|
||||
import type { FileEntity } from './types'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { upload } from '@/service/base'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getFileExtension,
|
||||
getFileNameFromUrl,
|
||||
getFilesInLogs,
|
||||
getFileUploadErrorMessage,
|
||||
getProcessedFiles,
|
||||
getProcessedFilesFromResponse,
|
||||
getSupportFileExtensionList,
|
||||
@@ -18,23 +19,40 @@ import {
|
||||
isAllowedFileExtension,
|
||||
} from './utils'
|
||||
|
||||
vi.mock('mime', () => ({
|
||||
default: {
|
||||
getAllExtensions: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
upload: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('file-uploader utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('getFileUploadErrorMessage', () => {
|
||||
const createMockT = () => vi.fn().mockImplementation((key: string) => key) as unknown as import('i18next').TFunction
|
||||
|
||||
it('should return forbidden message when error code is forbidden', () => {
|
||||
const error = { response: { code: 'forbidden', message: 'Access denied' } }
|
||||
expect(getFileUploadErrorMessage(error, 'default', createMockT())).toBe('Access denied')
|
||||
})
|
||||
|
||||
it('should return file_extension_blocked translation when error code matches', () => {
|
||||
const error = { response: { code: 'file_extension_blocked' } }
|
||||
expect(getFileUploadErrorMessage(error, 'default', createMockT())).toBe('fileUploader.fileExtensionBlocked')
|
||||
})
|
||||
|
||||
it('should return default message for other errors', () => {
|
||||
const error = { response: { code: 'unknown_error' } }
|
||||
expect(getFileUploadErrorMessage(error, 'Upload failed', createMockT())).toBe('Upload failed')
|
||||
})
|
||||
|
||||
it('should return default message when error has no response', () => {
|
||||
expect(getFileUploadErrorMessage(null, 'Upload failed', createMockT())).toBe('Upload failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fileUpload', () => {
|
||||
it('should handle successful file upload', () => {
|
||||
it('should handle successful file upload', async () => {
|
||||
const mockFile = new File(['test'], 'test.txt')
|
||||
const mockCallbacks = {
|
||||
onProgressCallback: vi.fn(),
|
||||
@@ -50,32 +68,102 @@ describe('file-uploader utils', () => {
|
||||
})
|
||||
|
||||
expect(upload).toHaveBeenCalled()
|
||||
|
||||
// Wait for the promise to resolve and call onSuccessCallback
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCallbacks.onSuccessCallback).toHaveBeenCalledWith({ id: '123' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onErrorCallback when upload fails', async () => {
|
||||
const mockFile = new File(['test'], 'test.txt')
|
||||
const mockCallbacks = {
|
||||
onProgressCallback: vi.fn(),
|
||||
onSuccessCallback: vi.fn(),
|
||||
onErrorCallback: vi.fn(),
|
||||
}
|
||||
|
||||
const uploadError = new Error('Upload failed')
|
||||
vi.mocked(upload).mockRejectedValue(uploadError)
|
||||
|
||||
fileUpload({
|
||||
file: mockFile,
|
||||
...mockCallbacks,
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockCallbacks.onErrorCallback).toHaveBeenCalledWith(uploadError)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onProgressCallback when progress event is computable', () => {
|
||||
const mockFile = new File(['test'], 'test.txt')
|
||||
const mockCallbacks = {
|
||||
onProgressCallback: vi.fn(),
|
||||
onSuccessCallback: vi.fn(),
|
||||
onErrorCallback: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mocked(upload).mockImplementation(({ onprogress }) => {
|
||||
// Simulate a progress event
|
||||
if (onprogress)
|
||||
onprogress.call({} as XMLHttpRequest, { lengthComputable: true, loaded: 50, total: 100 } as ProgressEvent)
|
||||
|
||||
return Promise.resolve({ id: '123' })
|
||||
})
|
||||
|
||||
fileUpload({
|
||||
file: mockFile,
|
||||
...mockCallbacks,
|
||||
})
|
||||
|
||||
expect(mockCallbacks.onProgressCallback).toHaveBeenCalledWith(50)
|
||||
})
|
||||
|
||||
it('should not call onProgressCallback when progress event is not computable', () => {
|
||||
const mockFile = new File(['test'], 'test.txt')
|
||||
const mockCallbacks = {
|
||||
onProgressCallback: vi.fn(),
|
||||
onSuccessCallback: vi.fn(),
|
||||
onErrorCallback: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mocked(upload).mockImplementation(({ onprogress }) => {
|
||||
if (onprogress)
|
||||
onprogress.call({} as XMLHttpRequest, { lengthComputable: false, loaded: 0, total: 0 } as ProgressEvent)
|
||||
|
||||
return Promise.resolve({ id: '123' })
|
||||
})
|
||||
|
||||
fileUpload({
|
||||
file: mockFile,
|
||||
...mockCallbacks,
|
||||
})
|
||||
|
||||
expect(mockCallbacks.onProgressCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
it('should get extension from mimetype', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
|
||||
expect(getFileExtension('file', 'application/pdf')).toBe('pdf')
|
||||
})
|
||||
|
||||
it('should get extension from mimetype and file name 1', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
|
||||
it('should get extension from mimetype and file name', () => {
|
||||
expect(getFileExtension('file.pdf', 'application/pdf')).toBe('pdf')
|
||||
})
|
||||
|
||||
it('should get extension from mimetype with multiple ext candidates with filename hint', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['der', 'crt', 'pem']))
|
||||
expect(getFileExtension('file.pem', 'application/x-x509-ca-cert')).toBe('pem')
|
||||
})
|
||||
|
||||
it('should get extension from mimetype with multiple ext candidates without filename hint', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['der', 'crt', 'pem']))
|
||||
expect(getFileExtension('file', 'application/x-x509-ca-cert')).toBe('der')
|
||||
const ext = getFileExtension('file', 'application/x-x509-ca-cert')
|
||||
// mime returns Set(['der', 'crt', 'pem']), first value is used when no filename hint
|
||||
expect(['der', 'crt', 'pem']).toContain(ext)
|
||||
})
|
||||
|
||||
it('should get extension from filename if mimetype fails', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(null)
|
||||
it('should get extension from filename when mimetype is empty', () => {
|
||||
expect(getFileExtension('file.txt', '')).toBe('txt')
|
||||
expect(getFileExtension('file.txt.docx', '')).toBe('docx')
|
||||
expect(getFileExtension('file', '')).toBe('')
|
||||
@@ -84,164 +172,123 @@ describe('file-uploader utils', () => {
|
||||
it('should return empty string for remote files', () => {
|
||||
expect(getFileExtension('file.txt', '', true)).toBe('')
|
||||
})
|
||||
|
||||
it('should fall back to filename extension for unknown mimetype', () => {
|
||||
expect(getFileExtension('file.txt', 'application/unknown')).toBe('txt')
|
||||
})
|
||||
|
||||
it('should return empty string for unknown mimetype without filename extension', () => {
|
||||
expect(getFileExtension('file', 'application/unknown')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileAppearanceType', () => {
|
||||
it('should identify gif files', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['gif']))
|
||||
expect(getFileAppearanceType('image.gif', 'image/gif'))
|
||||
.toBe(FileAppearanceTypeEnum.gif)
|
||||
})
|
||||
|
||||
it('should identify image files', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['jpg']))
|
||||
expect(getFileAppearanceType('image.jpg', 'image/jpeg'))
|
||||
.toBe(FileAppearanceTypeEnum.image)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['jpeg']))
|
||||
expect(getFileAppearanceType('image.jpeg', 'image/jpeg'))
|
||||
.toBe(FileAppearanceTypeEnum.image)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['png']))
|
||||
expect(getFileAppearanceType('image.png', 'image/png'))
|
||||
.toBe(FileAppearanceTypeEnum.image)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['webp']))
|
||||
expect(getFileAppearanceType('image.webp', 'image/webp'))
|
||||
.toBe(FileAppearanceTypeEnum.image)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['svg']))
|
||||
expect(getFileAppearanceType('image.svg', 'image/svgxml'))
|
||||
expect(getFileAppearanceType('image.svg', 'image/svg+xml'))
|
||||
.toBe(FileAppearanceTypeEnum.image)
|
||||
})
|
||||
|
||||
it('should identify video files', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mp4']))
|
||||
expect(getFileAppearanceType('video.mp4', 'video/mp4'))
|
||||
.toBe(FileAppearanceTypeEnum.video)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mov']))
|
||||
expect(getFileAppearanceType('video.mov', 'video/quicktime'))
|
||||
.toBe(FileAppearanceTypeEnum.video)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mpeg']))
|
||||
expect(getFileAppearanceType('video.mpeg', 'video/mpeg'))
|
||||
.toBe(FileAppearanceTypeEnum.video)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['webm']))
|
||||
expect(getFileAppearanceType('video.web', 'video/webm'))
|
||||
expect(getFileAppearanceType('video.webm', 'video/webm'))
|
||||
.toBe(FileAppearanceTypeEnum.video)
|
||||
})
|
||||
|
||||
it('should identify audio files', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mp3']))
|
||||
expect(getFileAppearanceType('audio.mp3', 'audio/mpeg'))
|
||||
.toBe(FileAppearanceTypeEnum.audio)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['m4a']))
|
||||
expect(getFileAppearanceType('audio.m4a', 'audio/mp4'))
|
||||
.toBe(FileAppearanceTypeEnum.audio)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['wav']))
|
||||
expect(getFileAppearanceType('audio.wav', 'audio/vnd.wav'))
|
||||
expect(getFileAppearanceType('audio.wav', 'audio/wav'))
|
||||
.toBe(FileAppearanceTypeEnum.audio)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['amr']))
|
||||
expect(getFileAppearanceType('audio.amr', 'audio/AMR'))
|
||||
.toBe(FileAppearanceTypeEnum.audio)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mpga']))
|
||||
expect(getFileAppearanceType('audio.mpga', 'audio/mpeg'))
|
||||
.toBe(FileAppearanceTypeEnum.audio)
|
||||
})
|
||||
|
||||
it('should identify code files', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['html']))
|
||||
expect(getFileAppearanceType('index.html', 'text/html'))
|
||||
.toBe(FileAppearanceTypeEnum.code)
|
||||
})
|
||||
|
||||
it('should identify PDF files', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
|
||||
expect(getFileAppearanceType('doc.pdf', 'application/pdf'))
|
||||
.toBe(FileAppearanceTypeEnum.pdf)
|
||||
})
|
||||
|
||||
it('should identify markdown files', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['md']))
|
||||
expect(getFileAppearanceType('file.md', 'text/markdown'))
|
||||
.toBe(FileAppearanceTypeEnum.markdown)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['markdown']))
|
||||
expect(getFileAppearanceType('file.markdown', 'text/markdown'))
|
||||
.toBe(FileAppearanceTypeEnum.markdown)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mdx']))
|
||||
expect(getFileAppearanceType('file.mdx', 'text/mdx'))
|
||||
.toBe(FileAppearanceTypeEnum.markdown)
|
||||
})
|
||||
|
||||
it('should identify excel files', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xlsx']))
|
||||
expect(getFileAppearanceType('doc.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'))
|
||||
.toBe(FileAppearanceTypeEnum.excel)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xls']))
|
||||
expect(getFileAppearanceType('doc.xls', 'application/vnd.ms-excel'))
|
||||
.toBe(FileAppearanceTypeEnum.excel)
|
||||
})
|
||||
|
||||
it('should identify word files', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['doc']))
|
||||
expect(getFileAppearanceType('doc.doc', 'application/msword'))
|
||||
.toBe(FileAppearanceTypeEnum.word)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['docx']))
|
||||
expect(getFileAppearanceType('doc.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'))
|
||||
.toBe(FileAppearanceTypeEnum.word)
|
||||
})
|
||||
|
||||
it('should identify word files', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['ppt']))
|
||||
it('should identify ppt files', () => {
|
||||
expect(getFileAppearanceType('doc.ppt', 'application/vnd.ms-powerpoint'))
|
||||
.toBe(FileAppearanceTypeEnum.ppt)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pptx']))
|
||||
expect(getFileAppearanceType('doc.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'))
|
||||
.toBe(FileAppearanceTypeEnum.ppt)
|
||||
})
|
||||
|
||||
it('should identify document files', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['txt']))
|
||||
expect(getFileAppearanceType('file.txt', 'text/plain'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['csv']))
|
||||
expect(getFileAppearanceType('file.csv', 'text/csv'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['msg']))
|
||||
expect(getFileAppearanceType('file.msg', 'application/vnd.ms-outlook'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['eml']))
|
||||
expect(getFileAppearanceType('file.eml', 'message/rfc822'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xml']))
|
||||
expect(getFileAppearanceType('file.xml', 'application/rssxml'))
|
||||
expect(getFileAppearanceType('file.xml', 'application/xml'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['epub']))
|
||||
expect(getFileAppearanceType('file.epub', 'application/epubzip'))
|
||||
expect(getFileAppearanceType('file.epub', 'application/epub+zip'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
})
|
||||
|
||||
it('should handle null mime extension', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(null)
|
||||
expect(getFileAppearanceType('file.txt', 'text/plain'))
|
||||
it('should fall back to filename extension for unknown mimetype', () => {
|
||||
expect(getFileAppearanceType('file.txt', 'application/unknown'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
})
|
||||
|
||||
it('should return custom type for unrecognized extensions', () => {
|
||||
expect(getFileAppearanceType('file.xyz', 'application/xyz'))
|
||||
.toBe(FileAppearanceTypeEnum.custom)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSupportFileType', () => {
|
||||
@@ -278,25 +325,70 @@ describe('file-uploader utils', () => {
|
||||
upload_file_id: '123',
|
||||
})
|
||||
})
|
||||
|
||||
it('should fallback to empty string when url is missing', () => {
|
||||
const files = [{
|
||||
id: '123',
|
||||
name: 'test.txt',
|
||||
size: 1024,
|
||||
type: 'text/plain',
|
||||
progress: 100,
|
||||
supportFileType: 'document',
|
||||
transferMethod: TransferMethod.local_file,
|
||||
url: undefined,
|
||||
uploadedId: '123',
|
||||
}] as unknown as FileEntity[]
|
||||
|
||||
const result = getProcessedFiles(files)
|
||||
expect(result[0].url).toBe('')
|
||||
})
|
||||
|
||||
it('should fallback to empty string when uploadedId is missing', () => {
|
||||
const files = [{
|
||||
id: '123',
|
||||
name: 'test.txt',
|
||||
size: 1024,
|
||||
type: 'text/plain',
|
||||
progress: 100,
|
||||
supportFileType: 'document',
|
||||
transferMethod: TransferMethod.local_file,
|
||||
url: 'http://example.com',
|
||||
uploadedId: undefined,
|
||||
}] as unknown as FileEntity[]
|
||||
|
||||
const result = getProcessedFiles(files)
|
||||
expect(result[0].upload_file_id).toBe('')
|
||||
})
|
||||
|
||||
it('should filter out files with progress -1', () => {
|
||||
const files = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'good.txt',
|
||||
progress: 100,
|
||||
supportFileType: 'document',
|
||||
transferMethod: TransferMethod.local_file,
|
||||
url: 'http://example.com',
|
||||
uploadedId: '1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'bad.txt',
|
||||
progress: -1,
|
||||
supportFileType: 'document',
|
||||
transferMethod: TransferMethod.local_file,
|
||||
url: 'http://example.com',
|
||||
uploadedId: '2',
|
||||
},
|
||||
] as unknown as FileEntity[]
|
||||
|
||||
const result = getProcessedFiles(files)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].upload_file_id).toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProcessedFilesFromResponse', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(mime.getAllExtensions).mockImplementation((mimeType: string) => {
|
||||
const mimeMap: Record<string, Set<string>> = {
|
||||
'image/jpeg': new Set(['jpg', 'jpeg']),
|
||||
'image/png': new Set(['png']),
|
||||
'image/gif': new Set(['gif']),
|
||||
'video/mp4': new Set(['mp4']),
|
||||
'audio/mp3': new Set(['mp3']),
|
||||
'application/pdf': new Set(['pdf']),
|
||||
'text/plain': new Set(['txt']),
|
||||
'application/json': new Set(['json']),
|
||||
}
|
||||
return mimeMap[mimeType] || new Set()
|
||||
})
|
||||
})
|
||||
|
||||
it('should process files correctly without type correction', () => {
|
||||
const files = [{
|
||||
related_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9',
|
||||
@@ -367,7 +459,7 @@ describe('file-uploader utils', () => {
|
||||
extension: '.mp3',
|
||||
filename: 'audio.mp3',
|
||||
size: 1024,
|
||||
mime_type: 'audio/mp3',
|
||||
mime_type: 'audio/mpeg',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/audio.mp3',
|
||||
@@ -415,7 +507,7 @@ describe('file-uploader utils', () => {
|
||||
expect(result[0].supportFileType).toBe('document')
|
||||
})
|
||||
|
||||
it('should NOT correct when filename and MIME type both point to wrong type', () => {
|
||||
it('should NOT correct when filename and MIME type both point to same type', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.jpg',
|
||||
@@ -540,6 +632,11 @@ describe('file-uploader utils', () => {
|
||||
expect(getFileNameFromUrl('http://example.com/path/file.txt'))
|
||||
.toBe('file.txt')
|
||||
})
|
||||
|
||||
it('should return empty string for URL ending with slash', () => {
|
||||
expect(getFileNameFromUrl('http://example.com/path/'))
|
||||
.toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSupportFileExtensionList', () => {
|
||||
@@ -599,7 +696,6 @@ describe('file-uploader utils', () => {
|
||||
|
||||
describe('isAllowedFileExtension', () => {
|
||||
it('should validate allowed file extensions', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
|
||||
expect(isAllowedFileExtension(
|
||||
'test.pdf',
|
||||
'application/pdf',
|
||||
|
||||
69
web/app/components/base/file-uploader/video-preview.spec.tsx
Normal file
69
web/app/components/base/file-uploader/video-preview.spec.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import VideoPreview from './video-preview'
|
||||
|
||||
describe('VideoPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render video element with correct title', () => {
|
||||
render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
|
||||
|
||||
const video = document.querySelector('video')
|
||||
expect(video).toBeInTheDocument()
|
||||
expect(video).toHaveAttribute('title', 'Test Video')
|
||||
})
|
||||
|
||||
it('should render source element with correct src and type', () => {
|
||||
render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
|
||||
|
||||
const source = document.querySelector('source')
|
||||
expect(source).toHaveAttribute('src', 'https://example.com/video.mp4')
|
||||
expect(source).toHaveAttribute('type', 'video/mp4')
|
||||
})
|
||||
|
||||
it('should render close button with icon', () => {
|
||||
const { getByTestId } = render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
|
||||
|
||||
const closeIcon = getByTestId('video-preview-close-btn')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCancel when close button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
const { getByTestId } = render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />)
|
||||
|
||||
const closeIcon = getByTestId('video-preview-close-btn')
|
||||
fireEvent.click(closeIcon.parentElement!)
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop propagation when backdrop is clicked', () => {
|
||||
const { baseElement } = render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
|
||||
|
||||
const backdrop = baseElement.querySelector('[tabindex="-1"]')
|
||||
const event = new MouseEvent('click', { bubbles: true })
|
||||
const stopPropagation = vi.spyOn(event, 'stopPropagation')
|
||||
backdrop!.dispatchEvent(event)
|
||||
|
||||
expect(stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when Escape key is pressed', () => {
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render in a portal attached to document.body', () => {
|
||||
render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
|
||||
|
||||
const video = document.querySelector('video')
|
||||
expect(video?.closest('[tabindex="-1"]')?.parentElement).toBe(document.body)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FC } from 'react'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
@@ -35,7 +34,7 @@ const VideoPreview: FC<VideoPreviewProps> = ({
|
||||
className="absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-gray-500" />
|
||||
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="video-preview-close-btn" />
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
181
web/app/components/base/float-right-container/index.spec.tsx
Normal file
181
web/app/components/base/float-right-container/index.spec.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import FloatRightContainer from './index'
|
||||
|
||||
describe('FloatRightContainer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering behavior across mobile and desktop branches.
|
||||
describe('Rendering', () => {
|
||||
it('should render content in drawer when isMobile is true and isOpen is true', async () => {
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={true}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
title="Mobile panel"
|
||||
>
|
||||
<div>Mobile content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mobile panel')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mobile content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render content when isMobile is true and isOpen is false', () => {
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={true}
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
unmount={true}
|
||||
>
|
||||
<div>Closed mobile content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Closed mobile content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render content inline when isMobile is false and isOpen is true', () => {
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={false}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
title="Desktop drawer title should not render"
|
||||
>
|
||||
<div>Desktop inline content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Desktop inline content')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Desktop drawer title should not render')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when isMobile is false and isOpen is false', () => {
|
||||
const { container } = render(
|
||||
<FloatRightContainer
|
||||
isMobile={false}
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
>
|
||||
<div>Hidden desktop content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(screen.queryByText('Hidden desktop content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Validate that drawer-specific props are passed through in mobile mode.
|
||||
describe('Props forwarding', () => {
|
||||
it('should call onClose when close icon is clicked in mobile drawer mode', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={true}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
showClose={true}
|
||||
>
|
||||
<div>Closable mobile content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
const closeIcon = screen.getByTestId('close-icon')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(closeIcon)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when close is done using escape key', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={true}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
showClose={true}
|
||||
>
|
||||
<div>Closable content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
const closeIcon = screen.getByTestId('close-icon')
|
||||
closeIcon.focus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when close is done using space key', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={true}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
showClose={true}
|
||||
>
|
||||
<div>Closable content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
const closeIcon = screen.getByTestId('close-icon')
|
||||
closeIcon.focus()
|
||||
await userEvent.keyboard(' ')
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should apply drawer className props in mobile drawer mode', async () => {
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={true}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
dialogClassName="custom-dialog-class"
|
||||
panelClassName="custom-panel-class"
|
||||
>
|
||||
<div>Class forwarding content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(dialog).toHaveClass('custom-dialog-class')
|
||||
|
||||
const panel = document.querySelector('.custom-panel-class')
|
||||
expect(panel).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge-case behavior with optional children.
|
||||
describe('Edge cases', () => {
|
||||
it('should render without crashing when children is undefined in mobile mode', async () => {
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={true}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
title="Empty mobile panel"
|
||||
>
|
||||
{undefined}
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Empty mobile panel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -47,6 +47,7 @@ const ImageGallery: FC<Props> = ({
|
||||
style={imgStyle}
|
||||
src={src}
|
||||
alt=""
|
||||
data-testid="gallery-image" // Added for testing
|
||||
onClick={() => setImagePreviewUrl(src)}
|
||||
onError={e => e.currentTarget.remove()}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
|
||||
import { RiAddBoxLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { t } from 'i18next'
|
||||
import * as React from 'react'
|
||||
@@ -256,7 +256,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
|
||||
className="absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-gray-500" />
|
||||
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="image-preview-close-button" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>,
|
||||
|
||||
73
web/app/components/base/markdown-blocks/audio-block.spec.tsx
Normal file
73
web/app/components/base/markdown-blocks/audio-block.spec.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { NamedExoticComponent } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
|
||||
// AudioBlock.integration.spec.tsx
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import AudioBlock from './audio-block'
|
||||
|
||||
// Mock the nested AudioPlayer used by AudioGallery (do not mock AudioGallery itself)
|
||||
const audioPlayerMock = vi.fn()
|
||||
vi.mock('@/app/components/base/audio-gallery/AudioPlayer', () => ({
|
||||
default: (props: { srcs: string[] }) => {
|
||||
audioPlayerMock(props)
|
||||
return <div data-testid="audio-player" data-srcs={JSON.stringify(props.srcs)} />
|
||||
},
|
||||
})) // adjust path if AudioBlock sits elsewhere
|
||||
|
||||
describe('AudioBlock (integration - real AudioGallery)', () => {
|
||||
beforeEach(() => {
|
||||
audioPlayerMock.mockClear()
|
||||
})
|
||||
|
||||
it('renders AudioGallery with multiple srcs extracted from node.children', () => {
|
||||
const node = {
|
||||
children: [
|
||||
{ properties: { src: 'one.mp3' } },
|
||||
{ properties: { src: 'two.mp3' } },
|
||||
{ type: 'text', value: 'plain' },
|
||||
],
|
||||
properties: {},
|
||||
}
|
||||
|
||||
const { container } = render(<AudioBlock node={node} />)
|
||||
|
||||
const gallery = screen.getByTestId('audio-player')
|
||||
expect(gallery).toBeInTheDocument()
|
||||
|
||||
expect(audioPlayerMock).toHaveBeenCalledTimes(1)
|
||||
expect(audioPlayerMock).toHaveBeenCalledWith({ srcs: ['one.mp3', 'two.mp3'] })
|
||||
|
||||
expect(container.firstChild).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders AudioGallery with single src from node.properties when no children with properties', () => {
|
||||
const node = {
|
||||
children: [{ type: 'text', value: 'no-src' }],
|
||||
properties: { src: 'single.mp3' },
|
||||
}
|
||||
|
||||
render(<AudioBlock node={node} />)
|
||||
|
||||
expect(audioPlayerMock).toHaveBeenCalledTimes(1)
|
||||
expect(audioPlayerMock).toHaveBeenCalledWith({ srcs: ['single.mp3'] })
|
||||
expect(screen.getByTestId('audio-player')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns null when there are no audio sources', () => {
|
||||
const node = {
|
||||
children: [{ type: 'text', value: 'nothing here' }],
|
||||
properties: {},
|
||||
}
|
||||
|
||||
const { container } = render(<AudioBlock node={node} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(audioPlayerMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has displayName set to AudioBlock', () => {
|
||||
const component = AudioBlock as NamedExoticComponent<{ node: unknown }>
|
||||
expect(component.displayName).toBe('AudioBlock')
|
||||
})
|
||||
})
|
||||
121
web/app/components/base/markdown-blocks/button.spec.tsx
Normal file
121
web/app/components/base/markdown-blocks/button.spec.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { NamedExoticComponent } from 'react'
|
||||
import type { ChatContextValue } from '@/app/components/base/chat/chat/context'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
// markdown-button.spec.tsx
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChatContextProvider } from '@/app/components/base/chat/chat/context'
|
||||
|
||||
import MarkdownButton from './button'
|
||||
|
||||
// Only mock the URL utility so behavior is deterministic
|
||||
const isValidUrlSpy = vi.fn()
|
||||
vi.mock('./utils', () => ({
|
||||
isValidUrl: (u: string) => isValidUrlSpy(u),
|
||||
})) // test subject
|
||||
|
||||
type TestNode = {
|
||||
properties?: {
|
||||
dataVariant?: string
|
||||
dataMessage?: string
|
||||
dataLink?: string
|
||||
dataSize?: string
|
||||
}
|
||||
children?: Array<{ value?: string }>
|
||||
}
|
||||
|
||||
describe('MarkdownButton (integration)', () => {
|
||||
const onSendSpy = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function renderWithCtx(node: TestNode) {
|
||||
// Provide minimal ChatContext; cast to ChatContextValue to satisfy the provider signature
|
||||
const ctx = {
|
||||
onSend: (msg: unknown) => onSendSpy(msg),
|
||||
// other props are optional at runtime; assert type to satisfy TS
|
||||
} as unknown as ChatContextValue
|
||||
|
||||
return render(
|
||||
<ChatContextProvider {...ctx}>
|
||||
<MarkdownButton node={node as unknown as Record<string, unknown>} />
|
||||
</ChatContextProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
it('renders button text from node children', () => {
|
||||
const node: TestNode = { children: [{ value: 'Click me' }], properties: {} }
|
||||
renderWithCtx(node)
|
||||
expect(screen.getByRole('button')).toHaveTextContent('Click me')
|
||||
})
|
||||
|
||||
it('opens new tab when link is valid and does not call onSend', async () => {
|
||||
isValidUrlSpy.mockReturnValue(true)
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const user = userEvent.setup()
|
||||
|
||||
const node: TestNode = {
|
||||
properties: { dataLink: 'https://example.com' },
|
||||
children: [{ value: 'Go' }],
|
||||
}
|
||||
|
||||
renderWithCtx(node)
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(isValidUrlSpy).toHaveBeenCalledWith('https://example.com')
|
||||
expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank')
|
||||
expect(onSendSpy).not.toHaveBeenCalled()
|
||||
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls onSend when link is invalid but message exists', async () => {
|
||||
isValidUrlSpy.mockReturnValue(false)
|
||||
const user = userEvent.setup()
|
||||
|
||||
const node: TestNode = {
|
||||
properties: { dataLink: 'not-a-url', dataMessage: 'hello!' },
|
||||
children: [{ value: 'Send' }],
|
||||
}
|
||||
|
||||
renderWithCtx(node)
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(isValidUrlSpy).toHaveBeenCalledWith('not-a-url')
|
||||
expect(onSendSpy).toHaveBeenCalledTimes(1)
|
||||
expect(onSendSpy).toHaveBeenCalledWith('hello!')
|
||||
})
|
||||
|
||||
it('does nothing when no link and no message', async () => {
|
||||
isValidUrlSpy.mockReturnValue(false)
|
||||
const user = userEvent.setup()
|
||||
|
||||
const node: TestNode = { properties: {}, children: [{ value: 'Empty' }] }
|
||||
renderWithCtx(node)
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(isValidUrlSpy).not.toHaveBeenCalled()
|
||||
expect(onSendSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onSend when message present and no link', async () => {
|
||||
const user = userEvent.setup()
|
||||
const node: TestNode = {
|
||||
properties: { dataMessage: 'msg-only' },
|
||||
children: [{ value: 'Msg' }],
|
||||
}
|
||||
|
||||
renderWithCtx(node)
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(onSendSpy).toHaveBeenCalledWith('msg-only')
|
||||
})
|
||||
|
||||
it('has displayName set to MarkdownButton', () => {
|
||||
const comp = MarkdownButton as NamedExoticComponent<{ node: unknown }>
|
||||
expect(comp.displayName).toBe('MarkdownButton')
|
||||
})
|
||||
})
|
||||
96
web/app/components/base/markdown-blocks/paragraph.spec.tsx
Normal file
96
web/app/components/base/markdown-blocks/paragraph.spec.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Paragraph from './paragraph'
|
||||
|
||||
vi.mock('@/app/components/base/image-gallery', () => ({
|
||||
default: ({ srcs }: { srcs: string[] }) => (
|
||||
<div data-testid="image-gallery">{srcs.join(',')}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
type MockNode = {
|
||||
children?: Array<{
|
||||
tagName?: string
|
||||
properties?: {
|
||||
src?: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
type ParagraphProps = {
|
||||
node: MockNode
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const renderParagraph = (props: ParagraphProps) => {
|
||||
return render(<Paragraph {...props} />)
|
||||
}
|
||||
|
||||
describe('Paragraph', () => {
|
||||
it('should render normal paragraph when no image child exists', () => {
|
||||
renderParagraph({
|
||||
node: { children: [] },
|
||||
children: 'Hello world',
|
||||
})
|
||||
|
||||
expect(screen.getByText('Hello world').tagName).toBe('P')
|
||||
})
|
||||
|
||||
it('should render image gallery when first child is img', () => {
|
||||
renderParagraph({
|
||||
node: {
|
||||
children: [
|
||||
{
|
||||
tagName: 'img',
|
||||
properties: { src: 'test.png' },
|
||||
},
|
||||
],
|
||||
},
|
||||
children: ['Image only'],
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('image-gallery')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('image-gallery')).toHaveTextContent('test.png')
|
||||
})
|
||||
|
||||
it('should render additional content after image when children length > 1', () => {
|
||||
renderParagraph({
|
||||
node: {
|
||||
children: [
|
||||
{
|
||||
tagName: 'img',
|
||||
properties: { src: 'test.png' },
|
||||
},
|
||||
],
|
||||
},
|
||||
children: ['Image', <span key="1">Caption</span>],
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('image-gallery')).toBeInTheDocument()
|
||||
expect(screen.getByText('Caption')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render paragraph when first child exists but is not img', () => {
|
||||
renderParagraph({
|
||||
node: {
|
||||
children: [
|
||||
{
|
||||
tagName: 'div',
|
||||
},
|
||||
],
|
||||
},
|
||||
children: 'Not image',
|
||||
})
|
||||
|
||||
expect(screen.getByText('Not image').tagName).toBe('P')
|
||||
})
|
||||
|
||||
it('should render paragraph when children_node is undefined', () => {
|
||||
renderParagraph({
|
||||
node: {},
|
||||
children: 'Fallback',
|
||||
})
|
||||
|
||||
expect(screen.getByText('Fallback').tagName).toBe('P')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
/* eslint-disable next/no-img-element */
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { usePluginReadmeAsset } from '@/service/use-plugins'
|
||||
import { PluginParagraph } from './plugin-paragraph'
|
||||
import { getMarkdownImageURL } from './utils'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
usePluginReadmeAsset: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./utils', () => ({
|
||||
getMarkdownImageURL: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/image-uploader/image-preview', () => ({
|
||||
default: ({ url, onCancel }: { url: string, onCancel: () => void }) => (
|
||||
<div data-testid="image-preview-modal">
|
||||
<span>{url}</span>
|
||||
<button onClick={onCancel} type="button">Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Interfaces to avoid 'any' and satisfy strict linting
|
||||
*/
|
||||
type MockNode = {
|
||||
children?: Array<{
|
||||
tagName?: string
|
||||
properties?: { src?: string }
|
||||
}>
|
||||
}
|
||||
|
||||
type HookReturn = {
|
||||
data?: Blob
|
||||
isLoading?: boolean
|
||||
error?: Error | null
|
||||
}
|
||||
|
||||
describe('PluginParagraph', () => {
|
||||
const mockPluginInfo = {
|
||||
pluginUniqueIdentifier: 'test-plugin-id',
|
||||
pluginId: 'plugin-123',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Ensure URL globals exist in the test environment using globalThis
|
||||
if (!globalThis.URL.createObjectURL) {
|
||||
globalThis.URL.createObjectURL = vi.fn()
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
}
|
||||
|
||||
// Default mock return to prevent destructuring errors
|
||||
vi.mocked(usePluginReadmeAsset).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as HookReturn as ReturnType<typeof usePluginReadmeAsset>)
|
||||
})
|
||||
|
||||
it('should render a standard paragraph when not an image', () => {
|
||||
const node: MockNode = { children: [{ tagName: 'span' }] }
|
||||
render(
|
||||
<PluginParagraph node={node}>
|
||||
Hello World
|
||||
</PluginParagraph>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('standard-paragraph')).toHaveTextContent('Hello World')
|
||||
})
|
||||
|
||||
it('should render an ImageGallery when the first child is an image', () => {
|
||||
const node: MockNode = {
|
||||
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
|
||||
}
|
||||
vi.mocked(getMarkdownImageURL).mockReturnValue('https://cdn.com/test-img.png')
|
||||
|
||||
const { container } = render(
|
||||
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
|
||||
<img src="test-img.png" alt="" />
|
||||
</PluginParagraph>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('image-paragraph-wrapper')).toBeInTheDocument()
|
||||
// Query by selector since alt="" removes the 'img' role from the accessibility tree
|
||||
const img = container.querySelector('img')
|
||||
expect(img).toHaveAttribute('src', 'https://cdn.com/test-img.png')
|
||||
})
|
||||
|
||||
it('should use a blob URL when asset data is successfully fetched', () => {
|
||||
const node: MockNode = {
|
||||
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
|
||||
}
|
||||
const mockBlob = new Blob([''], { type: 'image/png' })
|
||||
vi.mocked(usePluginReadmeAsset).mockReturnValue({
|
||||
data: mockBlob,
|
||||
} as HookReturn as ReturnType<typeof usePluginReadmeAsset>)
|
||||
|
||||
vi.spyOn(globalThis.URL, 'createObjectURL').mockReturnValue('blob:actual-blob-url')
|
||||
|
||||
const { container } = render(
|
||||
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
|
||||
<img src="test-img.png" alt="" />
|
||||
</PluginParagraph>,
|
||||
)
|
||||
|
||||
const img = container.querySelector('img')
|
||||
expect(img).toHaveAttribute('src', 'blob:actual-blob-url')
|
||||
})
|
||||
|
||||
it('should render remaining children below the image gallery', () => {
|
||||
const node: MockNode = {
|
||||
children: [
|
||||
{ tagName: 'img', properties: { src: 'test-img.png' } },
|
||||
{ tagName: 'text' },
|
||||
],
|
||||
}
|
||||
|
||||
render(
|
||||
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
|
||||
<img src="test-img.png" alt="" />
|
||||
<span>Caption Text</span>
|
||||
</PluginParagraph>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('remaining-children')).toHaveTextContent('Caption Text')
|
||||
})
|
||||
|
||||
it('should revoke the blob URL on unmount to prevent memory leaks', () => {
|
||||
const node: MockNode = {
|
||||
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
|
||||
}
|
||||
const mockBlob = new Blob([''], { type: 'image/png' })
|
||||
vi.mocked(usePluginReadmeAsset).mockReturnValue({
|
||||
data: mockBlob,
|
||||
} as HookReturn as ReturnType<typeof usePluginReadmeAsset>)
|
||||
|
||||
const revokeSpy = vi.spyOn(globalThis.URL, 'revokeObjectURL')
|
||||
vi.spyOn(globalThis.URL, 'createObjectURL').mockReturnValue('blob:cleanup-test')
|
||||
|
||||
const { unmount } = render(
|
||||
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
|
||||
<img src="test-img.png" alt="" />
|
||||
</PluginParagraph>,
|
||||
)
|
||||
|
||||
unmount()
|
||||
expect(revokeSpy).toHaveBeenCalledWith('blob:cleanup-test')
|
||||
})
|
||||
|
||||
it('should open the image preview modal when an image in the gallery is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const node: MockNode = {
|
||||
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
|
||||
}
|
||||
vi.mocked(getMarkdownImageURL).mockReturnValue('https://cdn.com/gallery.png')
|
||||
|
||||
const { container } = render(
|
||||
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
|
||||
<img src="test-img.png" alt="" />
|
||||
</PluginParagraph>,
|
||||
)
|
||||
|
||||
const img = container.querySelector('img')
|
||||
if (img)
|
||||
await user.click(img)
|
||||
|
||||
// ImageGallery is not mocked, so it should trigger the preview
|
||||
expect(screen.getByTestId('image-preview-modal')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://cdn.com/gallery.png')).toBeInTheDocument()
|
||||
|
||||
const closeBtn = screen.getByText('Close')
|
||||
await user.click(closeBtn)
|
||||
expect(screen.queryByTestId('image-preview-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -58,13 +58,13 @@ export const PluginParagraph: React.FC<PluginParagraphProps> = ({ pluginInfo, no
|
||||
const remainingChildren = Array.isArray(children) && children.length > 1 ? children.slice(1) : undefined
|
||||
|
||||
return (
|
||||
<div className="markdown-img-wrapper">
|
||||
<div className="markdown-img-wrapper" data-testid="image-paragraph-wrapper">
|
||||
<ImageGallery srcs={[imageUrl]} />
|
||||
{remainingChildren && (
|
||||
<div className="mt-2">{remainingChildren}</div>
|
||||
<div className="mt-2" data-testid="remaining-children">{remainingChildren}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <p>{children}</p>
|
||||
return <p data-testid="standard-paragraph">{children}</p>
|
||||
}
|
||||
|
||||
61
web/app/components/base/markdown-blocks/pre-code.spec.tsx
Normal file
61
web/app/components/base/markdown-blocks/pre-code.spec.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import PreCode from './pre-code'
|
||||
|
||||
describe('PreCode Component', () => {
|
||||
it('renders children correctly inside the pre tag', () => {
|
||||
const { container } = render(
|
||||
<PreCode>
|
||||
<code data-testid="test-code">console.log("hello world")</code>
|
||||
</PreCode>,
|
||||
)
|
||||
|
||||
const preElement = container.querySelector('pre')
|
||||
const codeElement = screen.getByTestId('test-code')
|
||||
|
||||
expect(preElement).toBeInTheDocument()
|
||||
expect(codeElement).toBeInTheDocument()
|
||||
// Verify code is a descendant of pre
|
||||
expect(preElement).toContainElement(codeElement)
|
||||
expect(codeElement.textContent).toBe('console.log("hello world")')
|
||||
})
|
||||
|
||||
it('contains the copy button span for CSS targeting', () => {
|
||||
const { container } = render(
|
||||
<PreCode>
|
||||
<code>test content</code>
|
||||
</PreCode>,
|
||||
)
|
||||
|
||||
const copySpan = container.querySelector('.copy-code-button')
|
||||
expect(copySpan).toBeInTheDocument()
|
||||
expect(copySpan?.tagName).toBe('SPAN')
|
||||
})
|
||||
|
||||
it('renders as a <pre> element', () => {
|
||||
const { container } = render(<PreCode>Content</PreCode>)
|
||||
expect(container.querySelector('pre')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles multiple children correctly', () => {
|
||||
render(
|
||||
<PreCode>
|
||||
<span>Line 1</span>
|
||||
<span>Line 2</span>
|
||||
</PreCode>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Line 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Line 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('correctly instantiates the pre element node', () => {
|
||||
const { container } = render(<PreCode>Ref check</PreCode>)
|
||||
const pre = container.querySelector('pre')
|
||||
|
||||
// Verifies the node is an actual HTMLPreElement,
|
||||
// confirming the ref-linked element rendered correctly.
|
||||
expect(pre).toBeInstanceOf(HTMLPreElement)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user