Compare commits

...

31 Commits

Author SHA1 Message Date
Tyson Cung
84533cbfe0 fix: resolve pyright bad-index errors in parser.py (#32507)
Some checks are pending
autofix.ci / autofix (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions
Main CI Pipeline / Check Changed Files (push) Waiting to run
Main CI Pipeline / API Tests (push) Blocked by required conditions
Main CI Pipeline / Web Tests (push) Blocked by required conditions
Main CI Pipeline / Style Check (push) Waiting to run
Main CI Pipeline / VDB Tests (push) Blocked by required conditions
Main CI Pipeline / DB Migration Test (push) Blocked by required conditions
2026-02-24 17:29:17 +09:00
Saumya Talwani
0eaae4f573 test: added tests for some base components (#32370) 2026-02-24 16:22:43 +08:00
Saumya Talwani
9819f7d69c test: add tests for file-upload components (#32373)
Co-authored-by: sahil <sahil@infocusp.com>
2026-02-24 16:16:06 +08:00
不做了睡大觉
a040b9428d fix: correct type annotations in Langfuse trace entities to match SDK (#32498)
Co-authored-by: User <user@example.com>
2026-02-24 16:31:12 +09:00
Saumya Talwani
740d94c6ed test: add tests for some base components (#32356) 2026-02-24 14:35:23 +08:00
Poojan
657eeb65b8 test: add unit tests for base-components-part-2 (#32409) 2026-02-24 14:34:48 +08:00
Saumya Talwani
f923901d3f test: add tests for base > features (#32397)
Co-authored-by: sahil <sahil@infocusp.com>
2026-02-24 13:01:45 +08:00
akashseth-ifp
a0ddaed6d3 test(web): Fix failing web test in 'Web Tests' GitHub Action (#32481) 2026-02-24 13:01:30 +08:00
akashseth-ifp
2162cd1a69 test(web): increase test coverage for components inside header folder (#32392) 2026-02-24 12:44:10 +08:00
mahammadasim
0070891114 test: add unit tests for prompt editor's component picker block plugin. (#32412) 2026-02-24 12:42:57 +08:00
Poojan
6e531fe44f test: add unit tests for base-components part-3 (#32408) 2026-02-24 12:21:02 +08:00
J0su3Code
80f49367eb fix: add return type annotation to abstract _publish method (#32493)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
2026-02-24 03:12:43 +09:00
Tyson Cung
7c60ad01d3 fix: add return type annotation to Moderation.validate_config abstract method (#32491)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
2026-02-24 02:11:43 +09:00
Stella Miyako
57890eed25 refactor: fix opentelemetry histogram type assignment error (#32490)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-24 01:32:16 +09:00
木之本澪
737575d637 test: migrate Dataset/Document property tests to testcontainers (#32487)
Co-authored-by: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-24 01:23:48 +09:00
木之本澪
f76ee7cfa4 fix: add return type annotation to BaseVector.create (#32475)
Co-authored-by: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com>
2026-02-23 22:28:40 +09:00
akashseth-ifp
a0244d1390 test(web): add tests for model-provider-page files in header account-… (#32360) 2026-02-23 20:07:19 +08:00
akashseth-ifp
42af9d5438 test(web): add members-page account-setting specs and improve coverage (#32311) 2026-02-23 20:06:35 +08:00
Tyson Cung
4c48e3b997 refactor: inherit ABC in AppQueueManager for proper abstract method usage (#32461)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
2026-02-23 15:46:30 +09:00
dependabot[bot]
46f0cebbb0 chore(deps): update redis[hiredis] requirement from ~=6.1.0 to ~=7.2.0 in /api (#32464)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 15:41:12 +09:00
dependabot[bot]
2d54192f35 chore(deps): update python-docx requirement from ~=1.1.0 to ~=1.2.0 in /api (#32463)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-23 15:38:20 +09:00
dependabot[bot]
80a5398dea chore(deps): update pydantic requirement from ~=2.11.4 to ~=2.12.5 in /api (#32462)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-23 15:37:44 +09:00
Saumya Talwani
ab64c4adf9 test: add test cases for some base components (#32314) 2026-02-23 13:17:46 +08:00
mahammadasim
ce8354a42a test: Add unit tests for Data Source Integrations (Notion, Website) and Modals (#32313)
Co-authored-by: akashseth-ifp <akash.seth@infocusp.com>
2026-02-23 13:00:02 +08:00
akashseth-ifp
d0bb642fc5 test(web): Added test for model-auth files in header folder (#32358) 2026-02-23 12:57:00 +08:00
mahammadasim
e4ddf07194 test: header account about, account setting and account dropdown (#32283) 2026-02-23 12:15:57 +08:00
akashseth-ifp
aad980f267 test: tighten user-visible specs and raise coverage for key-validator… (#32281) 2026-02-23 12:15:34 +08:00
wangxiaolei
8141e3af99 fix: fix node after change can not select start node (#32441)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
2026-02-21 14:04:21 +08:00
Asuka Minato
b108de6607 refactor: refine some type in trial (#32426)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-21 14:02:41 +08:00
dependabot[bot]
7b3b3dbe52 chore(deps): bump flask from 3.1.2 to 3.1.3 in /api (#32432)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 20:00:39 +09:00
dependabot[bot]
5d7aeaa7e5 chore(deps): bump werkzeug from 3.1.5 to 3.1.6 in /api (#32431)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 20:00:17 +09:00
263 changed files with 33675 additions and 701 deletions

View File

@@ -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")

View File

@@ -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.")

View File

@@ -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:

View File

@@ -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.

View File

@@ -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().",
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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."""

121
api/uv.lock generated
View File

@@ -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" },
@@ -2019,7 +2019,7 @@ wheels = [
[[package]]
name = "flask"
version = "3.1.2"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
@@ -2029,9 +2029,9 @@ dependencies = [
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" }
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
@@ -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]
@@ -7207,14 +7214,14 @@ wheels = [
[[package]]
name = "werkzeug"
version = "3.1.5"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" }
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" },
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
]
[[package]]

View File

@@ -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(() => {

View File

@@ -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)
})
})

View 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()
})
})
})

View File

@@ -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>

View 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()
})
})
})

View 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')
})
})

View 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')
})
})
})

View 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()
})
})
})

View 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'])
})
})

View File

@@ -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 && (

View 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)
})
})
})

View File

@@ -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>
)
}

View 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)
})
})
})

View File

@@ -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>
)

View 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()
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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)
})
})

View File

@@ -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>

View 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)
})
})
})

View 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')
})
})

View 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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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

View File

@@ -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()
})
})
})

View File

@@ -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,

View File

@@ -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()
})
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})
})

View File

@@ -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')
})
})

View File

@@ -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

View File

@@ -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()
})
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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' })
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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>

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})
})

View File

@@ -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

View File

@@ -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)
})
})

View 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)
})
})
})

View 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)
})
})

View File

@@ -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,

View 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)
})
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View 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('')
})
})

View 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()
})
})

View File

@@ -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')
})
})

View File

@@ -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: '',
}))
})
})

View File

@@ -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')
})
})

View File

@@ -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',
}))
})
})

View File

@@ -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()
})
})

View File

@@ -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>

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View 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()
})
})
})

View File

@@ -0,0 +1,7 @@
import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter'
import 'react-pdf-highlighter/dist/style.css'
export {
PdfHighlighter,
PdfLoader,
}

View 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()
})
})

View File

@@ -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

View 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')
})
})

View File

@@ -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',

View 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)
})
})

View File

@@ -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,

View 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()
})
})
})

View File

@@ -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()}
/>

View File

@@ -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>,

View 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')
})
})

View 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')
})
})

View 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')
})
})

View File

@@ -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()
})
})

View File

@@ -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>
}

View 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