mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 09:55:09 +00:00
Compare commits
14 Commits
main
...
1f864fe8e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f864fe8e7 | ||
|
|
53048feb9f | ||
|
|
c019916494 | ||
|
|
1f1456f3b9 | ||
|
|
756e4b8e37 | ||
|
|
98ee6a7620 | ||
|
|
6456efbe41 | ||
|
|
3f2b2c199f | ||
|
|
b27b34674b | ||
|
|
9487daf71c | ||
|
|
e80bd15d5c | ||
|
|
a8ddc1408e | ||
|
|
5bb4110f85 | ||
|
|
b0bae39696 |
@@ -204,6 +204,16 @@ When assigned to test a directory/path, test **ALL content** within that path:
|
||||
|
||||
> See [Test Structure Template](#test-structure-template) for correct import/mock patterns.
|
||||
|
||||
### `nuqs` Query State Testing (Required for URL State Hooks)
|
||||
|
||||
When a component or hook uses `useQueryState` / `useQueryStates`:
|
||||
|
||||
- ✅ Use `NuqsTestingAdapter` (prefer shared helpers in `web/test/nuqs-testing.tsx`)
|
||||
- ✅ Assert URL synchronization via `onUrlUpdate` (`searchParams`, `options.history`)
|
||||
- ✅ For custom parsers (`createParser`), keep `parse` and `serialize` bijective and add round-trip edge cases (`%2F`, `%25`, spaces, legacy encoded values)
|
||||
- ✅ Verify default-clearing behavior (default values should be removed from URL when applicable)
|
||||
- ⚠️ Only mock `nuqs` directly when URL behavior is explicitly out of scope for the test
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. AAA Pattern (Arrange-Act-Assert)
|
||||
|
||||
@@ -80,6 +80,9 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
|
||||
- [ ] Router mocks match actual Next.js API
|
||||
- [ ] Mocks reflect actual component conditional behavior
|
||||
- [ ] Only mock: API services, complex context providers, third-party libs
|
||||
- [ ] For `nuqs` URL-state tests, wrap with `NuqsTestingAdapter` (prefer `web/test/nuqs-testing.tsx`)
|
||||
- [ ] For `nuqs` URL-state tests, assert `onUrlUpdate` payload (`searchParams`, `options.history`)
|
||||
- [ ] If custom `nuqs` parser exists, add round-trip tests for encoded edge cases (`%2F`, `%25`, spaces, legacy encoded values)
|
||||
|
||||
### Queries
|
||||
|
||||
|
||||
@@ -125,6 +125,31 @@ describe('Component', () => {
|
||||
})
|
||||
```
|
||||
|
||||
### 2.1 `nuqs` Query State (Preferred: Testing Adapter)
|
||||
|
||||
For tests that validate URL query behavior, use `NuqsTestingAdapter` instead of mocking `nuqs` directly.
|
||||
|
||||
```typescript
|
||||
import { renderHookWithNuqs } from '@/test/nuqs-testing'
|
||||
|
||||
it('should sync query to URL with push history', async () => {
|
||||
const { result, onUrlUpdate } = renderHookWithNuqs(() => useMyQueryState(), {
|
||||
searchParams: '?page=1',
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ page: 2 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.options.history).toBe('push')
|
||||
expect(update.searchParams.get('page')).toBe('2')
|
||||
})
|
||||
```
|
||||
|
||||
Use direct `vi.mock('nuqs')` only when URL synchronization is intentionally out of scope.
|
||||
|
||||
### 3. Portal Components (with Shared State)
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -133,7 +133,7 @@ class AppQueueManager(ABC):
|
||||
self._publish(event, pub_from)
|
||||
|
||||
@abstractmethod
|
||||
def _publish(self, event: AppQueueEvent, pub_from: PublishFrom) -> None:
|
||||
def _publish(self, event: AppQueueEvent, pub_from: PublishFrom):
|
||||
"""
|
||||
Publish event to queue
|
||||
:param event:
|
||||
|
||||
@@ -39,7 +39,7 @@ class Moderation(Extensible, ABC):
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def validate_config(cls, tenant_id: str, config: dict) -> None:
|
||||
def validate_config(cls, tenant_id: str, config: dict):
|
||||
"""
|
||||
Validate the incoming form config data.
|
||||
|
||||
|
||||
@@ -129,11 +129,11 @@ class LangfuseSpan(BaseModel):
|
||||
default=None,
|
||||
description="The id of the user that triggered the execution. Used to provide user-level analytics.",
|
||||
)
|
||||
start_time: datetime | None = Field(
|
||||
start_time: datetime | str | None = Field(
|
||||
default_factory=datetime.now,
|
||||
description="The time at which the span started, defaults to the current time.",
|
||||
)
|
||||
end_time: datetime | None = Field(
|
||||
end_time: datetime | str | 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: LevelEnum | None = Field(
|
||||
level: str | 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 | None = Field(
|
||||
start_time: datetime | str | None = Field(
|
||||
default_factory=datetime.now,
|
||||
description="The time at which the generation started, defaults to the current time.",
|
||||
)
|
||||
completion_start_time: datetime | None = Field(
|
||||
completion_start_time: datetime | str | 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 | None = Field(
|
||||
end_time: datetime | str | None = Field(
|
||||
default=None,
|
||||
description="The time at which the generation ended. Automatically set by generation.end().",
|
||||
)
|
||||
|
||||
@@ -18,7 +18,8 @@ except ImportError:
|
||||
from importlib_metadata import version # type: ignore[import-not-found]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.metrics import Histogram, Meter
|
||||
from opentelemetry.metrics import Meter
|
||||
from opentelemetry.metrics._internal.instrument import Histogram
|
||||
from opentelemetry.sdk.metrics.export import MetricReader
|
||||
|
||||
from opentelemetry import trace as trace_api
|
||||
|
||||
@@ -15,7 +15,7 @@ class BaseVector(ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs) -> list[str] | None:
|
||||
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -2,7 +2,7 @@ import re
|
||||
from json import dumps as json_dumps
|
||||
from json import loads as json_loads
|
||||
from json.decoder import JSONDecodeError
|
||||
from typing import Any, TypedDict
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from flask import request
|
||||
@@ -14,12 +14,6 @@ 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(
|
||||
@@ -41,17 +35,17 @@ class ApiBasedToolSchemaParser:
|
||||
server_url = matched_servers[0] if matched_servers else server_url
|
||||
|
||||
# list all interfaces
|
||||
interfaces: list[_OpenAPIInterface] = []
|
||||
interfaces = []
|
||||
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(
|
||||
_OpenAPIInterface(
|
||||
path=path,
|
||||
method=method,
|
||||
operation=path_item[method],
|
||||
)
|
||||
{
|
||||
"path": path,
|
||||
"method": method,
|
||||
"operation": path_item[method],
|
||||
}
|
||||
)
|
||||
|
||||
# get all parameters
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
"""
|
||||
Integration tests for Dataset and Document model properties using testcontainers.
|
||||
|
||||
These tests validate database-backed model properties (total_documents, word_count, etc.)
|
||||
without mocking SQLAlchemy queries, ensuring real query behavior against PostgreSQL.
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.dataset import Dataset, Document, DocumentSegment
|
||||
|
||||
|
||||
class TestDatasetDocumentProperties:
|
||||
"""Integration tests for Dataset and Document model properties."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _auto_rollback(self, db_session_with_containers: Session) -> Generator[None, None, None]:
|
||||
"""Automatically rollback session changes after each test."""
|
||||
yield
|
||||
db_session_with_containers.rollback()
|
||||
|
||||
def test_dataset_with_documents_relationship(self, db_session_with_containers: Session) -> None:
|
||||
"""Test dataset can track its documents."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i in range(3):
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=i + 1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name=f"doc_{i}.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.total_documents == 3
|
||||
|
||||
def test_dataset_available_documents_count(self, db_session_with_containers: Session) -> None:
|
||||
"""Test dataset can count available documents."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc_available = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="available.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
indexing_status="completed",
|
||||
enabled=True,
|
||||
archived=False,
|
||||
)
|
||||
doc_pending = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=2,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="pending.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
indexing_status="waiting",
|
||||
enabled=True,
|
||||
archived=False,
|
||||
)
|
||||
doc_disabled = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=3,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="disabled.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
indexing_status="completed",
|
||||
enabled=False,
|
||||
archived=False,
|
||||
)
|
||||
db_session_with_containers.add_all([doc_available, doc_pending, doc_disabled])
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.total_available_documents == 1
|
||||
|
||||
def test_dataset_word_count_aggregation(self, db_session_with_containers: Session) -> None:
|
||||
"""Test dataset can aggregate word count from documents."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i, wc in enumerate([2000, 3000]):
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=i + 1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name=f"doc_{i}.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
word_count=wc,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.word_count == 5000
|
||||
|
||||
def test_dataset_available_segment_count(self, db_session_with_containers: Session) -> None:
|
||||
"""Test Dataset.available_segment_count counts completed and enabled segments."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="doc.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i in range(2):
|
||||
seg = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=i + 1,
|
||||
content=f"segment {i}",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
status="completed",
|
||||
enabled=True,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg)
|
||||
|
||||
seg_waiting = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=3,
|
||||
content="waiting segment",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
status="waiting",
|
||||
enabled=True,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg_waiting)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.available_segment_count == 2
|
||||
|
||||
def test_document_segment_count_property(self, db_session_with_containers: Session) -> None:
|
||||
"""Test document can count its segments."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="doc.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i in range(3):
|
||||
seg = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=i + 1,
|
||||
content=f"segment {i}",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert doc.segment_count == 3
|
||||
|
||||
def test_document_hit_count_aggregation(self, db_session_with_containers: Session) -> None:
|
||||
"""Test document can aggregate hit count from segments."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="doc.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i, hits in enumerate([10, 15]):
|
||||
seg = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=i + 1,
|
||||
content=f"segment {i}",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
hit_count=hits,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert doc.hit_count == 25
|
||||
@@ -12,7 +12,7 @@ This test suite covers:
|
||||
import json
|
||||
import pickle
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
from models.dataset import (
|
||||
@@ -954,6 +954,156 @@ 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."""
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
*/
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import List from '@/app/components/apps/list'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
@@ -161,10 +161,9 @@ const createPage = (apps: App[], hasMore = false, page = 1): AppListResponse =>
|
||||
})
|
||||
|
||||
const renderList = (searchParams?: Record<string, string>) => {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
return renderWithNuqs(
|
||||
<List controlRefreshList={0} />,
|
||||
{ searchParams },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -209,11 +208,7 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
it('should transition from loading to content when data loads', () => {
|
||||
mockIsLoading = true
|
||||
const { rerender } = render(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
const { rerender } = renderWithNuqs(<List controlRefreshList={0} />)
|
||||
|
||||
const skeletonCards = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletonCards.length).toBeGreaterThan(0)
|
||||
@@ -224,11 +219,7 @@ describe('App List Browsing Flow', () => {
|
||||
createMockApp({ id: 'app-1', name: 'Loaded App' }),
|
||||
])]
|
||||
|
||||
rerender(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
rerender(<List controlRefreshList={0} />)
|
||||
|
||||
expect(screen.getByText('Loaded App')).toBeInTheDocument()
|
||||
})
|
||||
@@ -424,17 +415,9 @@ describe('App List Browsing Flow', () => {
|
||||
it('should call refetch when controlRefreshList increments', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
|
||||
const { rerender } = render(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
const { rerender } = renderWithNuqs(<List controlRefreshList={0} />)
|
||||
|
||||
rerender(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={1} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
rerender(<List controlRefreshList={1} />)
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
*/
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import List from '@/app/components/apps/list'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
@@ -214,11 +214,7 @@ const createPage = (apps: App[]): AppListResponse => ({
|
||||
})
|
||||
|
||||
const renderList = () => {
|
||||
return render(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
return renderWithNuqs(<List controlRefreshList={0} />)
|
||||
}
|
||||
|
||||
describe('Create App Flow', () => {
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
*/
|
||||
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { renderHookWithNuqs } from '@/test/nuqs-testing'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
@@ -28,12 +29,16 @@ const { useDocumentSort } = await import(
|
||||
const { useDocumentSelection } = await import(
|
||||
'@/app/components/datasets/documents/components/document-list/hooks/use-document-selection',
|
||||
)
|
||||
const { default: useDocumentListQueryState } = await import(
|
||||
const { useDocumentListQueryState } = await import(
|
||||
'@/app/components/datasets/documents/hooks/use-document-list-query-state',
|
||||
)
|
||||
|
||||
type LocalDoc = SimpleDocumentDetail & { percent?: number }
|
||||
|
||||
const renderQueryStateHook = (searchParams = '') => {
|
||||
return renderHookWithNuqs(() => useDocumentListQueryState(), { searchParams })
|
||||
}
|
||||
|
||||
const createDoc = (overrides?: Partial<LocalDoc>): LocalDoc => ({
|
||||
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: 'test-doc.txt',
|
||||
@@ -85,7 +90,7 @@ describe('Document Management Flow', () => {
|
||||
|
||||
describe('URL-based Query State', () => {
|
||||
it('should parse default query from empty URL params', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
const { result } = renderQueryStateHook()
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
@@ -96,107 +101,85 @@ describe('Document Management Flow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should update query and push to router', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
it('should update keyword query with replace history', async () => {
|
||||
const { result, onUrlUpdate } = renderQueryStateHook()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: 'test', page: 2 })
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
// The push call should contain the updated query params
|
||||
const pushUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushUrl).toContain('keyword=test')
|
||||
expect(pushUrl).toContain('page=2')
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.options.history).toBe('replace')
|
||||
expect(update.searchParams.get('keyword')).toBe('test')
|
||||
expect(update.searchParams.get('page')).toBe('2')
|
||||
})
|
||||
|
||||
it('should reset query to defaults', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
it('should reset query to defaults', async () => {
|
||||
const { result, onUrlUpdate } = renderQueryStateHook()
|
||||
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
// Default query omits default values from URL
|
||||
const pushUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushUrl).toBe('/datasets/ds-1/documents')
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.options.history).toBe('replace')
|
||||
expect(update.searchParams.toString()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Document Sort Integration', () => {
|
||||
it('should return documents unsorted when no sort field set', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1', name: 'Banana.txt', word_count: 300 }),
|
||||
createDoc({ id: 'doc-2', name: 'Apple.txt', word_count: 100 }),
|
||||
createDoc({ id: 'doc-3', name: 'Cherry.txt', word_count: 200 }),
|
||||
]
|
||||
|
||||
it('should derive sort field and order from remote sort value', () => {
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '-created_at',
|
||||
onRemoteSortChange: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.sortField).toBeNull()
|
||||
expect(result.current.sortedDocuments).toHaveLength(3)
|
||||
expect(result.current.sortField).toBe('created_at')
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
})
|
||||
|
||||
it('should sort by name descending', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1', name: 'Banana.txt' }),
|
||||
createDoc({ id: 'doc-2', name: 'Apple.txt' }),
|
||||
createDoc({ id: 'doc-3', name: 'Cherry.txt' }),
|
||||
]
|
||||
|
||||
it('should call remote sort change with descending sort for a new field', () => {
|
||||
const onRemoteSortChange = vi.fn()
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '-created_at',
|
||||
onRemoteSortChange,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
result.current.handleSort('hit_count')
|
||||
})
|
||||
|
||||
expect(result.current.sortField).toBe('name')
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
const names = result.current.sortedDocuments.map(d => d.name)
|
||||
expect(names).toEqual(['Cherry.txt', 'Banana.txt', 'Apple.txt'])
|
||||
expect(onRemoteSortChange).toHaveBeenCalledWith('-hit_count')
|
||||
})
|
||||
|
||||
it('should toggle sort order on same field click', () => {
|
||||
const docs = [createDoc({ id: 'doc-1', name: 'A.txt' }), createDoc({ id: 'doc-2', name: 'B.txt' })]
|
||||
|
||||
it('should toggle descending to ascending when clicking active field', () => {
|
||||
const onRemoteSortChange = vi.fn()
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '-created_at',
|
||||
remoteSortValue: '-hit_count',
|
||||
onRemoteSortChange,
|
||||
}))
|
||||
|
||||
act(() => result.current.handleSort('name'))
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
act(() => {
|
||||
result.current.handleSort('hit_count')
|
||||
})
|
||||
|
||||
act(() => result.current.handleSort('name'))
|
||||
expect(result.current.sortOrder).toBe('asc')
|
||||
expect(onRemoteSortChange).toHaveBeenCalledWith('hit_count')
|
||||
})
|
||||
|
||||
it('should filter by status before sorting', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1', name: 'A.txt', display_status: 'available' }),
|
||||
createDoc({ id: 'doc-2', name: 'B.txt', display_status: 'error' }),
|
||||
createDoc({ id: 'doc-3', name: 'C.txt', display_status: 'available' }),
|
||||
]
|
||||
|
||||
it('should ignore null sort field updates', () => {
|
||||
const onRemoteSortChange = vi.fn()
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: 'available',
|
||||
remoteSortValue: '-created_at',
|
||||
onRemoteSortChange,
|
||||
}))
|
||||
|
||||
// Only 'available' documents should remain
|
||||
expect(result.current.sortedDocuments).toHaveLength(2)
|
||||
expect(result.current.sortedDocuments.every(d => d.display_status === 'available')).toBe(true)
|
||||
act(() => {
|
||||
result.current.handleSort(null)
|
||||
})
|
||||
|
||||
expect(onRemoteSortChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -309,14 +292,13 @@ describe('Document Management Flow', () => {
|
||||
describe('Cross-Module: Query State → Sort → Selection Pipeline', () => {
|
||||
it('should maintain consistent default state across all hooks', () => {
|
||||
const docs = [createDoc({ id: 'doc-1' })]
|
||||
const { result: queryResult } = renderHook(() => useDocumentListQueryState())
|
||||
const { result: queryResult } = renderQueryStateHook()
|
||||
const { result: sortResult } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: queryResult.current.query.status,
|
||||
remoteSortValue: queryResult.current.query.sort,
|
||||
onRemoteSortChange: vi.fn(),
|
||||
}))
|
||||
const { result: selResult } = renderHook(() => useDocumentSelection({
|
||||
documents: sortResult.current.sortedDocuments,
|
||||
documents: docs,
|
||||
selectedIds: [],
|
||||
onSelectedIdChange: vi.fn(),
|
||||
}))
|
||||
@@ -325,8 +307,9 @@ describe('Document Management Flow', () => {
|
||||
expect(queryResult.current.query.sort).toBe('-created_at')
|
||||
expect(queryResult.current.query.status).toBe('all')
|
||||
|
||||
// Sort inherits 'all' status → no filtering applied
|
||||
expect(sortResult.current.sortedDocuments).toHaveLength(1)
|
||||
// Sort state is derived from URL default sort.
|
||||
expect(sortResult.current.sortField).toBe('created_at')
|
||||
expect(sortResult.current.sortOrder).toBe('desc')
|
||||
|
||||
// Selection starts empty
|
||||
expect(selResult.current.isAllSelected).toBe(false)
|
||||
|
||||
@@ -28,9 +28,13 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('nuqs', () => ({
|
||||
useQueryState: () => ['builtin', vi.fn()],
|
||||
}))
|
||||
vi.mock('nuqs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('nuqs')>()
|
||||
return {
|
||||
...actual,
|
||||
useQueryState: () => ['builtin', vi.fn()],
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({ enable_marketplace: false }),
|
||||
@@ -212,6 +216,12 @@ vi.mock('@/app/components/tools/marketplace', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/marketplace/hooks', () => ({
|
||||
useMarketplace: () => ({
|
||||
handleScroll: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/mcp', () => ({
|
||||
default: () => <div data-testid="mcp-list">MCP List</div>,
|
||||
}))
|
||||
|
||||
@@ -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, within } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
@@ -237,8 +237,7 @@ describe('ConfigVar', () => {
|
||||
expect(actionButtons).toHaveLength(2)
|
||||
fireEvent.click(actionButtons[0])
|
||||
|
||||
const editDialog = await screen.findByRole('dialog')
|
||||
const saveButton = within(editDialog).getByRole('button', { name: 'common.operation.save' })
|
||||
const saveButton = await screen.findByRole('button', { name: 'common.operation.save' })
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -323,8 +323,14 @@ describe('CustomizeModal', () => {
|
||||
expect(screen.getByText('appOverview.overview.appInfo.customize.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const closeButton = screen.getByTestId('modal-close-button')
|
||||
fireEvent.click(closeButton)
|
||||
// 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!)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
import List from '../list'
|
||||
@@ -186,15 +185,13 @@ beforeAll(() => {
|
||||
} as unknown as typeof IntersectionObserver
|
||||
})
|
||||
|
||||
// Render helper wrapping with NuqsTestingAdapter
|
||||
// Render helper wrapping with shared nuqs testing helper.
|
||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||
const renderList = (searchParams = '') => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
return renderWithNuqs(
|
||||
<List />,
|
||||
{ searchParams, onUrlUpdate },
|
||||
)
|
||||
return render(<List />, { wrapper })
|
||||
}
|
||||
|
||||
describe('List', () => {
|
||||
@@ -391,18 +388,10 @@ describe('List', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = render(
|
||||
<NuqsTestingAdapter>
|
||||
<List />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
const { rerender } = renderWithNuqs(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<NuqsTestingAdapter>
|
||||
<List />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
rerender(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { renderHookWithNuqs } from '@/test/nuqs-testing'
|
||||
import useAppsQueryState from '../use-apps-query-state'
|
||||
|
||||
const renderWithAdapter = (searchParams = '') => {
|
||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
)
|
||||
const { result } = renderHook(() => useAppsQueryState(), { wrapper })
|
||||
return { result, onUrlUpdate }
|
||||
return renderHookWithNuqs(() => useAppsQueryState(), { searchParams })
|
||||
}
|
||||
|
||||
describe('useAppsQueryState', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { FC } from 'react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
@@ -16,7 +16,7 @@ import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AppModeEnum, AppModes } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
@@ -33,6 +33,18 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' })
|
||||
|
||||
type Props = {
|
||||
controlRefreshList?: number
|
||||
}
|
||||
@@ -45,7 +57,7 @@ const List: FC<Props> = ({
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
'category',
|
||||
parseAsString.withDefault('all').withOptions({ history: 'push' }),
|
||||
parseAsAppListCategory,
|
||||
)
|
||||
|
||||
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
|
||||
@@ -80,7 +92,7 @@ const List: FC<Props> = ({
|
||||
name: searchKeywords,
|
||||
tag_ids: tagIDs,
|
||||
is_created_by_me: isCreatedByMe,
|
||||
...(activeTab !== 'all' ? { mode: activeTab as AppModeEnum } : {}),
|
||||
...(activeTab !== 'all' ? { mode: activeTab } : {}),
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -186,7 +198,10 @@ const List: FC<Props> = ({
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pb-5 pt-7">
|
||||
<TabSliderNew
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
onChange={(nextValue) => {
|
||||
if (isAppListCategory(nextValue))
|
||||
setActiveTab(nextValue)
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Alert from './alert'
|
||||
|
||||
describe('Alert', () => {
|
||||
const defaultProps = {
|
||||
message: 'This is an alert message',
|
||||
onHide: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Alert {...defaultProps} />)
|
||||
expect(screen.getByText(defaultProps.message)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the info icon', () => {
|
||||
render(<Alert {...defaultProps} />)
|
||||
const icon = screen.getByTestId('info-icon')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the close icon', () => {
|
||||
render(<Alert {...defaultProps} />)
|
||||
const closeIcon = screen.getByTestId('close-icon')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<Alert {...defaultProps} className="my-custom-class" />)
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv).toHaveClass('my-custom-class')
|
||||
})
|
||||
|
||||
it('should retain base classes when custom className is applied', () => {
|
||||
const { container } = render(<Alert {...defaultProps} className="my-custom-class" />)
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv).toHaveClass('pointer-events-none', 'w-full')
|
||||
})
|
||||
|
||||
it('should default type to info', () => {
|
||||
render(<Alert {...defaultProps} />)
|
||||
const gradientDiv = screen.getByTestId('alert-gradient')
|
||||
expect(gradientDiv).toHaveClass('from-components-badge-status-light-normal-halo')
|
||||
})
|
||||
|
||||
it('should render with explicit type info', () => {
|
||||
render(<Alert {...defaultProps} type="info" />)
|
||||
const gradientDiv = screen.getByTestId('alert-gradient')
|
||||
expect(gradientDiv).toHaveClass('from-components-badge-status-light-normal-halo')
|
||||
})
|
||||
|
||||
it('should display the provided message text', () => {
|
||||
const msg = 'A different alert message'
|
||||
render(<Alert {...defaultProps} message={msg} />)
|
||||
expect(screen.getByText(msg)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onHide when close button is clicked', () => {
|
||||
const onHide = vi.fn()
|
||||
render(<Alert {...defaultProps} onHide={onHide} />)
|
||||
const closeButton = screen.getByTestId('close-icon')
|
||||
fireEvent.click(closeButton)
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onHide when other parts of the alert are clicked', () => {
|
||||
const onHide = vi.fn()
|
||||
render(<Alert {...defaultProps} onHide={onHide} />)
|
||||
fireEvent.click(screen.getByText(defaultProps.message))
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with an empty message string', () => {
|
||||
render(<Alert {...defaultProps} message="" />)
|
||||
const messageDiv = screen.getByTestId('msg-container')
|
||||
expect(messageDiv).toBeInTheDocument()
|
||||
expect(messageDiv).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should render with a very long message', () => {
|
||||
const longMessage = 'A'.repeat(1000)
|
||||
render(<Alert {...defaultProps} message={longMessage} />)
|
||||
expect(screen.getByText(longMessage)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiInformation2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import {
|
||||
memo,
|
||||
@@ -31,13 +35,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 }))} data-testid="alert-gradient">
|
||||
<div className={cn('pointer-events-none absolute inset-0 bg-gradient-to-r opacity-[0.4]', bgVariants({ type }))}>
|
||||
</div>
|
||||
<div className="flex h-6 w-6 items-center justify-center">
|
||||
<span className="i-ri-information-2-fill text-text-accent" data-testid="info-icon" />
|
||||
<RiInformation2Fill className="text-text-accent" />
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<div className="text-text-secondary system-xs-regular" data-testid="msg-container">
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,7 +49,7 @@ const Alert: React.FC<Props> = ({
|
||||
className="pointer-events-auto flex h-6 w-6 cursor-pointer items-center justify-center"
|
||||
onClick={onHide}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" data-testid="close-icon" />
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,201 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,226 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import BlockInput, { getInputKeys } from './index'
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
checkKeys: vi.fn((_keys: string[]) => ({
|
||||
isValid: true,
|
||||
errorMessageKey: '',
|
||||
errorKey: '',
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('BlockInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(Toast, 'notify')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<BlockInput value="" />)
|
||||
const wrapper = screen.getByTestId('block-input')
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with initial value', () => {
|
||||
const { container } = render(<BlockInput value="Hello World" />)
|
||||
expect(container.textContent).toContain('Hello World')
|
||||
})
|
||||
|
||||
it('should render variable highlights', () => {
|
||||
render(<BlockInput value="Hello {{name}}" />)
|
||||
const nameElement = screen.getByText('name')
|
||||
expect(nameElement).toBeInTheDocument()
|
||||
expect(nameElement.parentElement).toHaveClass('text-primary-600')
|
||||
})
|
||||
|
||||
it('should render multiple variable highlights', () => {
|
||||
render(<BlockInput value="{{foo}} and {{bar}}" />)
|
||||
expect(screen.getByText('foo')).toBeInTheDocument()
|
||||
expect(screen.getByText('bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display character count in footer when not readonly', () => {
|
||||
render(<BlockInput value="Hello" />)
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide footer in readonly mode', () => {
|
||||
render(<BlockInput value="Hello" readonly />)
|
||||
expect(screen.queryByText('5')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<BlockInput value="test" className="custom-class" />)
|
||||
const innerContent = screen.getByTestId('block-input-content')
|
||||
expect(innerContent).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should apply readonly prop with max height', () => {
|
||||
render(<BlockInput value="test" readonly />)
|
||||
const contentDiv = screen.getByTestId('block-input').firstChild as Element
|
||||
expect(contentDiv).toHaveClass('max-h-[180px]')
|
||||
})
|
||||
|
||||
it('should have default empty value', () => {
|
||||
render(<BlockInput value="" />)
|
||||
const contentDiv = screen.getByTestId('block-input')
|
||||
expect(contentDiv).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should enter edit mode when clicked', async () => {
|
||||
render(<BlockInput value="Hello" />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should update value when typing in edit mode', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { checkKeys } = await import('@/utils/var')
|
||||
; (checkKeys as ReturnType<typeof vi.fn>).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' })
|
||||
|
||||
render(<BlockInput value="Hello" onConfirm={onConfirm} />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: 'Hello World' } })
|
||||
|
||||
expect(textarea).toHaveValue('Hello World')
|
||||
})
|
||||
|
||||
it('should call onConfirm on value change with valid keys', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { checkKeys } = await import('@/utils/var')
|
||||
; (checkKeys as ReturnType<typeof vi.fn>).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' })
|
||||
|
||||
render(<BlockInput value="initial" onConfirm={onConfirm} />)
|
||||
|
||||
const contentArea = screen.getByText('initial')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '{{name}}' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalledWith('{{name}}', ['name'])
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast on value change with invalid keys', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { checkKeys } = await import('@/utils/var');
|
||||
(checkKeys as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
isValid: false,
|
||||
errorMessageKey: 'invalidKey',
|
||||
errorKey: 'test_key',
|
||||
})
|
||||
|
||||
render(<BlockInput value="initial" onConfirm={onConfirm} />)
|
||||
|
||||
const contentArea = screen.getByText('initial')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '{{invalid}}' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalled()
|
||||
})
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not enter edit mode when readonly is true', () => {
|
||||
render(<BlockInput value="Hello" readonly />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty string value', () => {
|
||||
const { container } = render(<BlockInput value="" />)
|
||||
expect(container.textContent).toBe('0')
|
||||
const span = screen.getByTestId('block-input').querySelector('span')
|
||||
expect(span).toBeInTheDocument()
|
||||
expect(span).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should handle value without variables', () => {
|
||||
render(<BlockInput value="plain text" />)
|
||||
expect(screen.getByText('plain text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle newlines in value', () => {
|
||||
render(<BlockInput value="line1\nline2" />)
|
||||
expect(screen.getByText(/line1/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple same variables', () => {
|
||||
render(<BlockInput value="{{name}} and {{name}}" />)
|
||||
const highlights = screen.getAllByText('name')
|
||||
expect(highlights).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle value with only variables', () => {
|
||||
render(<BlockInput value="{{foo}}{{bar}}{{baz}}" />)
|
||||
expect(screen.getByText('foo')).toBeInTheDocument()
|
||||
expect(screen.getByText('bar')).toBeInTheDocument()
|
||||
expect(screen.getByText('baz')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle text adjacent to variables', () => {
|
||||
render(<BlockInput value="prefix {{var}} suffix" />)
|
||||
expect(screen.getByText(/prefix/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/suffix/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputKeys', () => {
|
||||
it('should extract keys from {{}} syntax', () => {
|
||||
const keys = getInputKeys('Hello {{name}}')
|
||||
expect(keys).toEqual(['name'])
|
||||
})
|
||||
|
||||
it('should extract multiple keys', () => {
|
||||
const keys = getInputKeys('{{foo}} and {{bar}}')
|
||||
expect(keys).toEqual(['foo', 'bar'])
|
||||
})
|
||||
|
||||
it('should remove duplicate keys', () => {
|
||||
const keys = getInputKeys('{{name}} and {{name}}')
|
||||
expect(keys).toEqual(['name'])
|
||||
})
|
||||
|
||||
it('should return empty array for no variables', () => {
|
||||
const keys = getInputKeys('plain text')
|
||||
expect(keys).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array for empty string', () => {
|
||||
const keys = getInputKeys('')
|
||||
expect(keys).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle keys with underscores and numbers', () => {
|
||||
const keys = getInputKeys('{{user_1}} and {{user_2}}')
|
||||
expect(keys).toEqual(['user_1', 'user_2'])
|
||||
})
|
||||
})
|
||||
@@ -63,7 +63,7 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
}, [isEditing])
|
||||
|
||||
const style = cn({
|
||||
'block h-full w-full break-all border-0 px-4 py-2 text-sm text-gray-900 outline-0': true,
|
||||
'block px-4 py-2 w-full h-full text-sm text-gray-900 outline-0 border-0 break-all': 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)} data-testid="block-input-content">
|
||||
<div className={cn(style, className)}>
|
||||
{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')} data-testid="block-input">
|
||||
<div className={cn('block-input w-full overflow-y-auto rounded-xl border-none bg-white')}>
|
||||
{textAreaContent}
|
||||
{/* footer */}
|
||||
{!readonly && (
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AddButton from './add-button'
|
||||
|
||||
describe('AddButton', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<AddButton onClick={vi.fn()} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render an add icon', () => {
|
||||
render(<AddButton onClick={vi.fn()} />)
|
||||
const iconSpan = screen.getByTestId('add-button').querySelector('span')
|
||||
expect(iconSpan).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<AddButton onClick={vi.fn()} className="my-custom" />)
|
||||
expect(container.firstChild).toHaveClass('my-custom')
|
||||
})
|
||||
|
||||
it('should retain base classes when custom className is applied', () => {
|
||||
const { container } = render(<AddButton onClick={vi.fn()} className="my-custom" />)
|
||||
expect(container.firstChild).toHaveClass('cursor-pointer')
|
||||
expect(container.firstChild).toHaveClass('rounded-md')
|
||||
expect(container.firstChild).toHaveClass('select-none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(<AddButton onClick={onClick} />)
|
||||
fireEvent.click(container.firstChild!)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClick multiple times on repeated clicks', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(<AddButton onClick={onClick} />)
|
||||
fireEvent.click(container.firstChild!)
|
||||
fireEvent.click(container.firstChild!)
|
||||
fireEvent.click(container.firstChild!)
|
||||
expect(onClick).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
@@ -13,8 +14,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} data-testid="add-button">
|
||||
<span className="i-ri-add-line 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}>
|
||||
<RiAddLine className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SyncButton from './sync-button'
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useBoolean: () => [false, { setTrue: vi.fn(), setFalse: vi.fn() }],
|
||||
}))
|
||||
|
||||
describe('SyncButton', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<SyncButton onClick={vi.fn()} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a refresh icon', () => {
|
||||
render(<SyncButton onClick={vi.fn()} />)
|
||||
const iconSpan = screen.getByTestId('sync-button').querySelector('span')
|
||||
expect(iconSpan).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<SyncButton onClick={vi.fn()} className="my-custom" />)
|
||||
const clickableDiv = screen.getByTestId('sync-button')
|
||||
expect(clickableDiv).toHaveClass('my-custom')
|
||||
})
|
||||
|
||||
it('should retain base classes when custom className is applied', () => {
|
||||
render(<SyncButton onClick={vi.fn()} className="my-custom" />)
|
||||
const clickableDiv = screen.getByTestId('sync-button')
|
||||
expect(clickableDiv).toHaveClass('rounded-md')
|
||||
expect(clickableDiv).toHaveClass('select-none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<SyncButton onClick={onClick} />)
|
||||
const clickableDiv = screen.getByTestId('sync-button')
|
||||
fireEvent.click(clickableDiv)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClick multiple times on repeated clicks', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<SyncButton onClick={onClick} />)
|
||||
const clickableDiv = screen.getByTestId('sync-button')
|
||||
fireEvent.click(clickableDiv)
|
||||
fireEvent.click(clickableDiv)
|
||||
fireEvent.click(clickableDiv)
|
||||
expect(onClick).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
'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'
|
||||
@@ -17,8 +18,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} data-testid="sync-button">
|
||||
<span className="i-ri-refresh-line 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}>
|
||||
<RiRefreshLine className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</TooltipPlus>
|
||||
)
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import IndeterminateIcon from './indeterminate-icon'
|
||||
|
||||
describe('IndeterminateIcon', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<IndeterminateIcon />)
|
||||
expect(screen.getByTestId('indeterminate-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render an svg element', () => {
|
||||
const { container } = render(<IndeterminateIcon />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -32,10 +32,10 @@ describe('YearAndMonthPicker Options', () => {
|
||||
it('should render year options', () => {
|
||||
const props = createOptionsProps()
|
||||
|
||||
const { container } = render(<Options {...props} />)
|
||||
render(<Options {...props} />)
|
||||
|
||||
const yearList = container.querySelectorAll('ul')[1]
|
||||
expect(yearList?.children).toHaveLength(200)
|
||||
const allItems = screen.getAllByRole('listitem')
|
||||
expect(allItems).toHaveLength(212)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'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'
|
||||
@@ -80,18 +81,7 @@ export default function Drawer({
|
||||
)}
|
||||
{showClose && (
|
||||
<DialogTitle className="mb-4 flex cursor-pointer items-center" as="div">
|
||||
<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"
|
||||
/>
|
||||
<XMarkIcon className="h-4 w-4 text-text-tertiary" onClick={onClose} />
|
||||
</DialogTitle>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,69 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,149 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,415 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,420 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
import { PageType } from './type'
|
||||
|
||||
describe('PageType', () => {
|
||||
it('should have log and annotation values', () => {
|
||||
expect(PageType.log).toBe('log')
|
||||
expect(PageType.annotation).toBe('annotation')
|
||||
})
|
||||
})
|
||||
@@ -1,241 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,187 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,510 +0,0 @@
|
||||
import type { OpeningStatement } from '@/app/components/base/features/types'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import OpeningSettingModal from './modal'
|
||||
|
||||
const getPromptEditor = () => {
|
||||
const editor = document.querySelector('[data-lexical-editor="true"]')
|
||||
expect(editor).toBeInTheDocument()
|
||||
return editor as HTMLElement
|
||||
}
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
checkKeys: (_keys: string[]) => ({ isValid: true }),
|
||||
getNewVar: (key: string, type: string) => ({ key, name: key, type, required: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/config-prompt/confirm-add-var', () => ({
|
||||
default: ({ varNameArr, onConfirm, onCancel }: {
|
||||
varNameArr: string[]
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
<div data-testid="confirm-add-var">
|
||||
<span>{varNameArr.join(',')}</span>
|
||||
<button data-testid="confirm-add" onClick={onConfirm}>Confirm</button>
|
||||
<button data-testid="cancel-add" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('react-sortablejs', () => ({
|
||||
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
const defaultData: OpeningStatement = {
|
||||
enabled: true,
|
||||
opening_statement: 'Hello, how can I help?',
|
||||
suggested_questions: ['Question 1', 'Question 2'],
|
||||
}
|
||||
|
||||
const createMockInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
|
||||
variable: 'name',
|
||||
label: 'Name',
|
||||
type: InputVarType.textInput,
|
||||
required: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('OpeningSettingModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the modal title', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/feature\.conversationOpener\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the opening statement in the editor', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(getPromptEditor()).toHaveTextContent('Hello, how can I help?')
|
||||
})
|
||||
|
||||
it('should render suggested questions', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel and save buttons', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel is clicked', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when close icon is clicked', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
await userEvent.click(closeButton)
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when close icon receives Enter key', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
closeButton.focus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onCancel when close icon receives Space key', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
closeButton.focus()
|
||||
fireEvent.keyDown(closeButton, { key: ' ' })
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onSave with updated data when save is clicked', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
opening_statement: 'Hello, how can I help?',
|
||||
suggested_questions: ['Question 1', 'Question 2'],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should disable save when opening statement is empty', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: '' }}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/).closest('button')
|
||||
expect(saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should add a new suggested question', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Before adding: 2 existing questions
|
||||
expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByText(/variableConfig\.addOption/))
|
||||
|
||||
// After adding: the 2 existing questions still present plus 1 new empty one
|
||||
expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument()
|
||||
// The new empty question renders as an input with empty value
|
||||
const allInputs = screen.getAllByDisplayValue('')
|
||||
expect(allInputs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should delete a suggested question via save verification', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, suggested_questions: ['Question 1'] }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Question should be present initially
|
||||
expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
|
||||
|
||||
const deleteIconWrapper = screen.getByTestId('delete-question-Question 1').parentElement
|
||||
expect(deleteIconWrapper).toBeTruthy()
|
||||
await userEvent.click(deleteIconWrapper!)
|
||||
|
||||
// After deletion, Question 1 should be gone
|
||||
expect(screen.queryByDisplayValue('Question 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update a suggested question value', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByDisplayValue('Question 1')
|
||||
await userEvent.clear(input)
|
||||
await userEvent.type(input, 'Updated Question')
|
||||
|
||||
expect(input).toHaveValue('Updated Question')
|
||||
})
|
||||
|
||||
it('should show confirm dialog when variables are not in prompt', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(screen.getByTestId('confirm-add-var')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save without variable check when confirm cancel is clicked', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
await userEvent.click(screen.getByTestId('cancel-add'))
|
||||
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show question count', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Count is displayed as "2/10" across child elements
|
||||
expect(screen.getByText(/openingStatement\.openingQuestion/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onAutoAddPromptVariable when confirm add is clicked', async () => {
|
||||
const onAutoAddPromptVariable = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onAutoAddPromptVariable={onAutoAddPromptVariable}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
// Confirm add var dialog should appear
|
||||
await userEvent.click(screen.getByTestId('confirm-add'))
|
||||
|
||||
expect(onAutoAddPromptVariable).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not show add button when max questions reached', async () => {
|
||||
const questionsAtMax: OpeningStatement = {
|
||||
enabled: true,
|
||||
opening_statement: 'Hello',
|
||||
suggested_questions: Array.from({ length: 10 }, (_, i) => `Q${i + 1}`),
|
||||
}
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={questionsAtMax}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/variableConfig\.addOption/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply and remove focused styling on question input focus/blur', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByDisplayValue('Question 1') as HTMLInputElement
|
||||
const questionRow = input.parentElement
|
||||
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(questionRow).not.toHaveClass('border-components-input-border-active')
|
||||
|
||||
await userEvent.click(input)
|
||||
expect(questionRow).toHaveClass('border-components-input-border-active')
|
||||
|
||||
// Tab press to blur
|
||||
await userEvent.tab()
|
||||
expect(questionRow).not.toHaveClass('border-components-input-border-active')
|
||||
})
|
||||
|
||||
it('should apply and remove deleting styling on delete icon hover', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const questionInput = screen.getByDisplayValue('Question 1') as HTMLInputElement
|
||||
const questionRow = questionInput.parentElement
|
||||
const deleteIconWrapper = screen.getByTestId('delete-question-Question 1').parentElement
|
||||
|
||||
expect(questionRow).not.toHaveClass('border-components-input-border-destructive')
|
||||
expect(deleteIconWrapper).toBeTruthy()
|
||||
|
||||
await userEvent.hover(deleteIconWrapper!)
|
||||
expect(questionRow).toHaveClass('border-components-input-border-destructive')
|
||||
|
||||
await userEvent.unhover(deleteIconWrapper!)
|
||||
expect(questionRow).not.toHaveClass('border-components-input-border-destructive')
|
||||
})
|
||||
|
||||
it('should handle save with empty suggested questions', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, suggested_questions: [] }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
suggested_questions: [],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should not save when opening statement is only whitespace', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: ' ' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip variable check when variables match prompt variables', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
promptVariables={[{ key: 'name', name: 'Name', type: 'string', required: true }]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
// Variable is in promptVariables, so no confirm dialog
|
||||
expect(screen.queryByTestId('confirm-add-var')).not.toBeInTheDocument()
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip variable check when variables match workflow variables', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
workflowVariables={[createMockInputVar()]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
// Variable matches workflow variables, so no confirm dialog
|
||||
expect(screen.queryByTestId('confirm-add-var')).not.toBeInTheDocument()
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show confirm dialog when variables not in workflow variables', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{unknown}}' }}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
workflowVariables={[createMockInputVar()]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(screen.getByTestId('confirm-add-var')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use updated opening statement after prop changes', async () => {
|
||||
const onSave = vi.fn()
|
||||
const view = await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
view.rerender(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'New greeting!' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
await Promise.resolve()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
opening_statement: 'New greeting!',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render empty opening statement with placeholder in editor', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: '' }}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const editor = getPromptEditor()
|
||||
expect(editor.textContent?.trim()).toBe('')
|
||||
expect(screen.getByText('appDebug.openingStatement.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
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'
|
||||
@@ -138,7 +139,7 @@ const OpeningSettingModal = ({
|
||||
)}
|
||||
key={index}
|
||||
>
|
||||
<span className="handle i-ri-draggable h-4 w-4 cursor-grab text-text-quaternary" />
|
||||
<RiDraggable className="handle h-4 w-4 cursor-grab text-text-quaternary" />
|
||||
<input
|
||||
type="input"
|
||||
value={question || ''}
|
||||
@@ -165,7 +166,7 @@ const OpeningSettingModal = ({
|
||||
onMouseEnter={() => setDeletingID(index)}
|
||||
onMouseLeave={() => setDeletingID(null)}
|
||||
>
|
||||
<span className="i-ri-delete-bin-line h-3.5 w-3.5" data-testid={`delete-question-${question}`} />
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -174,10 +175,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"
|
||||
>
|
||||
<span className="i-ri-add-line h-4 w-4" />
|
||||
<div className="text-[13px] system-sm-medium">{t('variableConfig.addOption', { ns: 'appDebug' })}</div>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
<div className="system-sm-medium text-[13px]">{t('variableConfig.addOption', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -191,26 +192,12 @@ 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="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 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>
|
||||
<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">
|
||||
<span className="i-ri-asterisk h-5 w-5 text-text-primary-on-surface" />
|
||||
<RiAsterisk className="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
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import DialogWrapper from './dialog-wrapper'
|
||||
|
||||
describe('DialogWrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render children when show is true', () => {
|
||||
render(
|
||||
<DialogWrapper show>
|
||||
<div data-testid="content">Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render children when show is false', () => {
|
||||
render(
|
||||
<DialogWrapper show={false}>
|
||||
<div data-testid="content">Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply workflow styles by default', () => {
|
||||
render(
|
||||
<DialogWrapper show>
|
||||
<div data-testid="content">Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
const wrapper = screen.getByTestId('content').parentElement
|
||||
expect(wrapper).toHaveClass('rounded-l-2xl')
|
||||
expect(wrapper).not.toHaveClass('rounded-2xl')
|
||||
})
|
||||
|
||||
it('should apply non-workflow styles when inWorkflow is false', () => {
|
||||
render(
|
||||
<DialogWrapper show inWorkflow={false}>
|
||||
<div data-testid="content">Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
const content = screen.getByTestId('content')
|
||||
const panel = content.parentElement
|
||||
const layoutContainer = screen.getByTestId('dialog-layout-container')
|
||||
|
||||
expect(layoutContainer).toHaveClass('pr-2')
|
||||
expect(layoutContainer).toHaveClass('pt-[64px]')
|
||||
expect(layoutContainer).not.toHaveClass('pt-[112px]')
|
||||
|
||||
expect(panel).toHaveClass('rounded-2xl')
|
||||
expect(panel).toHaveClass('border-[0.5px]')
|
||||
expect(panel).not.toHaveClass('rounded-l-2xl')
|
||||
})
|
||||
|
||||
it('should accept custom className', () => {
|
||||
render(
|
||||
<DialogWrapper show className="custom-class">
|
||||
<div data-testid="content">Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
const wrapper = screen.getByTestId('content').parentElement
|
||||
expect(wrapper).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Close behavior', () => {
|
||||
it('should call onClose when escape is pressed', async () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DialogWrapper show onClose={onClose}>
|
||||
<div>Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not throw when escape is pressed without onClose', () => {
|
||||
render(
|
||||
<DialogWrapper show>
|
||||
<div>Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
expect(() => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -33,12 +33,12 @@ const DialogWrapper = ({
|
||||
</TransitionChild>
|
||||
|
||||
<div className="fixed inset-0">
|
||||
<div className={cn('flex min-h-full flex-col items-end justify-center pb-2', inWorkflow ? 'pt-[112px]' : 'pr-2 pt-[64px]')} data-testid="dialog-layout-container">
|
||||
<div className={cn('flex min-h-full flex-col items-end justify-center pb-2', inWorkflow ? 'pt-[112px]' : 'pr-2 pt-[64px]')}>
|
||||
<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,
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,191 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,204 +0,0 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import SettingContent from './setting-content'
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', () => ({
|
||||
default: ({ payload, onChange }: { payload: Record<string, unknown>, onChange: (p: Record<string, unknown>) => void }) => (
|
||||
<div data-testid="file-upload-setting">
|
||||
<span data-testid="payload">{JSON.stringify(payload)}</span>
|
||||
<button
|
||||
data-testid="change-setting"
|
||||
onClick={() => onChange({
|
||||
...payload,
|
||||
allowed_file_types: ['document'],
|
||||
})}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
data-testid="clear-file-types"
|
||||
onClick={() => onChange({
|
||||
...payload,
|
||||
allowed_file_types: [],
|
||||
})}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/types', () => ({
|
||||
SupportUploadFileTypes: {
|
||||
image: 'image',
|
||||
},
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.jpg'],
|
||||
number_limits: 5,
|
||||
},
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { imageUpload?: boolean, onClose?: () => void, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<SettingContent
|
||||
imageUpload={props.imageUpload}
|
||||
onClose={props.onClose ?? vi.fn()}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SettingContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render file upload modal title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.fileUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render image upload modal title when imageUpload is true', () => {
|
||||
renderWithProvider({ imageUpload: true })
|
||||
|
||||
expect(screen.getByText(/feature\.imageUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render FileUploadSetting component with payload from file feature', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByTestId('file-upload-setting')).toBeInTheDocument()
|
||||
const payload = screen.getByTestId('payload')
|
||||
expect(payload.textContent).toContain('"allowed_file_upload_methods":["local_file"]')
|
||||
expect(payload.textContent).toContain('"allowed_file_types":["image"]')
|
||||
expect(payload.textContent).toContain('"allowed_file_extensions":[".jpg"]')
|
||||
expect(payload.textContent).toContain('"max_length":5')
|
||||
})
|
||||
|
||||
it('should use fallback payload values when file feature is undefined', () => {
|
||||
renderWithProvider({}, { file: undefined })
|
||||
|
||||
const payload = screen.getByTestId('payload')
|
||||
expect(payload.textContent).toContain('"allowed_file_upload_methods":["local_file","remote_url"]')
|
||||
expect(payload.textContent).toContain('"allowed_file_types":["image"]')
|
||||
expect(payload.textContent).toContain('"max_length":3')
|
||||
})
|
||||
|
||||
it('should render cancel and save buttons', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClose when close icon is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeIconButton = screen.getByTestId('close-setting-modal')
|
||||
expect(closeIconButton).toBeInTheDocument()
|
||||
if (!closeIconButton)
|
||||
throw new Error('Close icon button should exist')
|
||||
|
||||
fireEvent.click(closeIconButton)
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose when close icon receives Enter key', async () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeIconButton = screen.getByTestId('close-setting-modal')
|
||||
closeIconButton.focus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when close icon receives Space key', async () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeIconButton = screen.getByTestId('close-setting-modal')
|
||||
closeIconButton.focus()
|
||||
fireEvent.keyDown(closeIconButton, { key: ' ' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when cancel button is clicked to close', () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
// Use the cancel button to test the close behavior
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onChange when save is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not throw when save is clicked without onChange', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should disable save button when allowed file types are empty', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-file-types'))
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /operation\.save/ })
|
||||
expect(saveButton).toBeDisabled()
|
||||
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update temp payload when FileUploadSetting onChange is called', () => {
|
||||
renderWithProvider()
|
||||
|
||||
// Click the change button in mock FileUploadSetting to trigger setTempPayload
|
||||
fireEvent.click(screen.getByTestId('change-setting'))
|
||||
|
||||
// The payload should be updated with the new allowed_file_types
|
||||
const payload = screen.getByTestId('payload')
|
||||
expect(payload.textContent).toContain('document')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
@@ -57,22 +58,8 @@ const SettingContent = ({
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<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 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>
|
||||
<FileUploadSetting
|
||||
isMultiple
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,194 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,215 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,133 +0,0 @@
|
||||
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' })
|
||||
})
|
||||
})
|
||||
@@ -1,427 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,787 +0,0 @@
|
||||
import type { ModerationConfig } from '@/models/debug'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ModerationSettingModal from './moderation-setting-modal'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockCodeBasedExtensions: { data: { data: Record<string, unknown>[] } } = { data: { data: [] } }
|
||||
let mockModelProvidersData: {
|
||||
data: { data: Record<string, unknown>[] }
|
||||
isPending: boolean
|
||||
refetch: ReturnType<typeof vi.fn>
|
||||
} = {
|
||||
data: {
|
||||
data: [{
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: 'paid',
|
||||
quota_configurations: [{ quota_type: 'paid', is_valid: true }],
|
||||
},
|
||||
custom_configuration: { status: 'active' },
|
||||
}],
|
||||
},
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useCodeBasedExtensions: () => mockCodeBasedExtensions,
|
||||
useModelProviders: () => mockModelProvidersData,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
CustomConfigurationStatusEnum: { active: 'active' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/constants', () => ({
|
||||
ACCOUNT_SETTING_TAB: { PROVIDER: 'provider' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({
|
||||
default: ({ onChange }: { value: string, onChange: (v: string) => void }) => (
|
||||
<div data-testid="api-selector">
|
||||
<button data-testid="select-api" onClick={() => onChange('api-ext-1')}>Select API</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const defaultData: ModerationConfig = {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
keywords: 'bad\nword',
|
||||
inputs_config: { enabled: true, preset_response: 'Input blocked' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
|
||||
describe('ModerationSettingModal', () => {
|
||||
const onSave = vi.fn()
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCodeBasedExtensions = { data: { data: [] } }
|
||||
mockModelProvidersData = {
|
||||
data: {
|
||||
data: [{
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: 'paid',
|
||||
quota_configurations: [{ quota_type: 'paid', is_valid: true }],
|
||||
},
|
||||
custom_configuration: { status: 'active' },
|
||||
}],
|
||||
},
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should render the modal title', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render provider options', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.provider\.openai/)).toBeInTheDocument()
|
||||
// Keywords text appears both as provider option and section label
|
||||
expect(screen.getAllByText(/feature\.moderation\.modal\.provider\.keywords/).length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText(/apiBasedExtension\.selector\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show keywords textarea when keywords type is selected', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) as HTMLTextAreaElement
|
||||
expect(textarea).toBeInTheDocument()
|
||||
expect(textarea).toHaveValue('bad\nword')
|
||||
})
|
||||
|
||||
it('should render cancel and save buttons', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel is clicked', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={onCancel}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error when saving without inputs or outputs enabled', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: 'test',
|
||||
inputs_config: { enabled: false, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should show error when keywords type has no keywords', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: '',
|
||||
inputs_config: { enabled: true, preset_response: 'blocked' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onSave with formatted data when valid', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: 'bad\nword',
|
||||
inputs_config: { enabled: true, preset_response: 'blocked' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'keywords',
|
||||
enabled: true,
|
||||
config: expect.objectContaining({
|
||||
keywords: 'bad\nword',
|
||||
inputs_config: expect.objectContaining({ enabled: true }),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should show api selector when api type is selected', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'api', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('api-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch provider type when clicked', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click on openai_moderation provider
|
||||
fireEvent.click(screen.getByText(/feature\.moderation\.modal\.provider\.openai/))
|
||||
|
||||
// The keywords textarea should no longer be visible since type changed
|
||||
expect(screen.queryByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update keywords on textarea change', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) as HTMLTextAreaElement
|
||||
fireEvent.change(textarea, { target: { value: 'new\nkeywords' } })
|
||||
|
||||
expect(textarea).toHaveValue('new\nkeywords')
|
||||
})
|
||||
|
||||
it('should render moderation content sections', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.content\.input/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.content\.output/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error when inputs enabled but no preset_response for keywords type', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: 'test',
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should show error when api type has no api_based_extension_id', async () => {
|
||||
const data: ModerationConfig = {
|
||||
enabled: true,
|
||||
type: 'api',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should save with api_based_extension_id in formatted data for api type', async () => {
|
||||
const data: ModerationConfig = {
|
||||
enabled: true,
|
||||
type: 'api',
|
||||
config: {
|
||||
api_based_extension_id: 'ext-1',
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
// api type doesn't require preset_response, so save should succeed
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'api',
|
||||
config: expect.objectContaining({
|
||||
api_based_extension_id: 'ext-1',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should show error when outputs enabled but no preset_response for keywords type', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: 'test',
|
||||
inputs_config: { enabled: false, preset_response: '' },
|
||||
outputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should toggle input moderation content', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const switches = screen.getAllByRole('switch')
|
||||
expect(screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(1)
|
||||
|
||||
fireEvent.click(switches[0])
|
||||
|
||||
expect(screen.queryAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should toggle output moderation content', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const switches = screen.getAllByRole('switch')
|
||||
expect(screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(1)
|
||||
|
||||
fireEvent.click(switches[1])
|
||||
|
||||
expect(screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should select api extension via api selector', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'api', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-api'))
|
||||
|
||||
// Trigger save and confirm the chosen extension id is passed through
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({ api_based_extension_id: 'api-ext-1' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should save with openai_moderation type when configured', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{
|
||||
enabled: true,
|
||||
type: 'openai_moderation',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: 'blocked' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'openai_moderation',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle keyword truncation to 100 chars per line and 100 lines', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/)
|
||||
// Create a long keyword that exceeds 100 chars
|
||||
const longWord = 'a'.repeat(150)
|
||||
fireEvent.change(textarea, { target: { value: longWord } })
|
||||
|
||||
// Value should be truncated to 100 chars
|
||||
expect((textarea as HTMLTextAreaElement).value.length).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
it('should save with formatted outputs_config when both enabled', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: 'test',
|
||||
inputs_config: { enabled: true, preset_response: 'input blocked' },
|
||||
outputs_config: { enabled: true, preset_response: 'output blocked' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
inputs_config: expect.objectContaining({ enabled: true }),
|
||||
outputs_config: expect.objectContaining({ enabled: true }),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should switch from keywords to api type', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click api provider
|
||||
fireEvent.click(screen.getByText(/apiBasedExtension\.selector\.title/))
|
||||
|
||||
// API selector should now be visible, keywords textarea should be hidden
|
||||
expect(screen.getByTestId('api-selector')).toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty lines in keywords', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) as HTMLTextAreaElement
|
||||
fireEvent.change(textarea, { target: { value: 'word1\n\nword2\n\n' } })
|
||||
|
||||
expect(textarea.value).toBe('word1\n\nword2\n')
|
||||
})
|
||||
|
||||
it('should show OpenAI not configured warning when OpenAI provider is not set up', async () => {
|
||||
mockModelProvidersData = {
|
||||
data: {
|
||||
data: [{
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: 'free',
|
||||
quota_configurations: [],
|
||||
},
|
||||
custom_configuration: { status: 'no-configure' },
|
||||
}],
|
||||
},
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'openai_moderation', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.openaiNotConfig\.before/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open settings modal when provider link is clicked in OpenAI warning', async () => {
|
||||
mockModelProvidersData = {
|
||||
data: {
|
||||
data: [{
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: 'free',
|
||||
quota_configurations: [],
|
||||
},
|
||||
custom_configuration: { status: 'no-configure' },
|
||||
}],
|
||||
},
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'openai_moderation', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/settings\.provider/))
|
||||
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not save when OpenAI type is selected but not configured', async () => {
|
||||
mockModelProvidersData = {
|
||||
data: {
|
||||
data: [{
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: 'free',
|
||||
quota_configurations: [],
|
||||
},
|
||||
custom_configuration: { status: 'no-configure' },
|
||||
}],
|
||||
},
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'openai_moderation', config: { inputs_config: { enabled: true, preset_response: 'blocked' }, outputs_config: { enabled: false, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render code-based extension providers', async () => {
|
||||
mockCodeBasedExtensions = {
|
||||
data: {
|
||||
data: [{
|
||||
name: 'custom-ext',
|
||||
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
|
||||
form_schema: [
|
||||
{ variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: 'Enter URL', options: [], max_length: 200 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Custom Extension')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show form generation when code-based extension is selected', async () => {
|
||||
mockCodeBasedExtensions = {
|
||||
data: {
|
||||
data: [{
|
||||
name: 'custom-ext',
|
||||
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
|
||||
form_schema: [
|
||||
{ variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: 'Enter URL', options: [], max_length: 200 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'custom-ext', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('API URL')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Enter URL')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should initialize config from form schema when switching to code-based extension', async () => {
|
||||
mockCodeBasedExtensions = {
|
||||
data: {
|
||||
data: [{
|
||||
name: 'custom-ext',
|
||||
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
|
||||
form_schema: [
|
||||
{ variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: 'https://default.com', placeholder: '', options: [], max_length: 200 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click on the custom extension provider
|
||||
fireEvent.click(screen.getByText('Custom Extension'))
|
||||
|
||||
// The form input should use the default value from form schema
|
||||
expect(screen.getByDisplayValue('https://default.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error when required form schema field is empty on save', async () => {
|
||||
mockCodeBasedExtensions = {
|
||||
data: {
|
||||
data: [{
|
||||
name: 'custom-ext',
|
||||
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
|
||||
form_schema: [
|
||||
{ variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: '', options: [], max_length: 200 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'custom-ext', config: { inputs_config: { enabled: true, preset_response: 'blocked' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should save with code-based extension config when valid', async () => {
|
||||
mockCodeBasedExtensions = {
|
||||
data: {
|
||||
data: [{
|
||||
name: 'custom-ext',
|
||||
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
|
||||
form_schema: [
|
||||
{ variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: '', options: [], max_length: 200 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'custom-ext', config: { api_url: 'https://example.com', inputs_config: { enabled: true, preset_response: 'blocked' }, outputs_config: { enabled: false, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'custom-ext',
|
||||
config: expect.objectContaining({
|
||||
api_url: 'https://example.com',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should show doc link for api type', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'api', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/apiBasedExtension\.link/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,14 @@
|
||||
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'
|
||||
@@ -235,21 +238,8 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
className="!mt-14 !w-[600px] !max-w-none !p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<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 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>
|
||||
<div className="py-2">
|
||||
<div className="text-sm font-medium leading-9 text-text-primary">
|
||||
@@ -261,9 +251,9 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
<div
|
||||
key={provider.key}
|
||||
className={cn(
|
||||
'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',
|
||||
'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',
|
||||
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 && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs system-sm-medium',
|
||||
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 === 'openai_moderation' && provider.key === 'openai_moderation' && !isOpenAIProviderConfigured && 'text-text-disabled',
|
||||
)}
|
||||
onClick={() => handleDataTypeChange(provider.key)}
|
||||
@@ -282,7 +272,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">
|
||||
<span className="i-custom-vender-line-general-info-circle mr-1 h-4 w-4 text-[#F79009]" />
|
||||
<InfoCircle className="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
|
||||
@@ -334,7 +324,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center text-xs text-text-tertiary 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" />
|
||||
<BookOpen01 className="mr-1 h-3 w-3 text-text-tertiary group-hover:text-primary-600" />
|
||||
{t('apiBasedExtension.link', { ns: 'common' })}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,115 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,349 +0,0 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { TtsAutoPlay } from '@/types/app'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import ParamConfigContent from './param-config-content'
|
||||
|
||||
let mockLanguages = [
|
||||
{ value: 'en-US', name: 'English', example: 'Hello world' },
|
||||
{ value: 'zh-Hans', name: '中文', example: '你好' },
|
||||
]
|
||||
|
||||
let mockPathname = '/app/test-app-id/configuration'
|
||||
|
||||
let mockVoiceItems: { value: string, name: string }[] | undefined = [
|
||||
{ value: 'alloy', name: 'Alloy' },
|
||||
{ value: 'echo', name: 'Echo' },
|
||||
]
|
||||
|
||||
const mockUseAppVoices = vi.fn((_appId: string, _language?: string) => ({
|
||||
data: mockVoiceItems,
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => mockPathname,
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
get languages() {
|
||||
return mockLanguages
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useAppVoices: (appId: string, language?: string) => mockUseAppVoices(appId, language),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: true, language: 'en-US', voice: 'alloy', autoPlay: TtsAutoPlay.disabled },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { onClose?: () => void, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<ParamConfigContent
|
||||
onClose={props.onClose ?? vi.fn()}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ParamConfigContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPathname = '/app/test-app-id/configuration'
|
||||
mockLanguages = [
|
||||
{ value: 'en-US', name: 'English', example: 'Hello world' },
|
||||
{ value: 'zh-Hans', name: '中文', example: '你好' },
|
||||
]
|
||||
mockVoiceItems = [
|
||||
{ value: 'alloy', name: 'Alloy' },
|
||||
{ value: 'echo', name: 'Echo' },
|
||||
]
|
||||
})
|
||||
|
||||
// Rendering states and static UI sections.
|
||||
describe('Rendering', () => {
|
||||
it('should render voice settings title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render language label', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.language/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render voice label', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.voice/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render autoPlay toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.autoPlay/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tooltip icon for language', () => {
|
||||
renderWithProvider()
|
||||
|
||||
const languageLabel = screen.getByText(/voice\.voiceSettings\.language/)
|
||||
expect(languageLabel).toBeInTheDocument()
|
||||
const tooltip = languageLabel.parentElement as HTMLElement
|
||||
expect(tooltip.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display language listbox button', () => {
|
||||
renderWithProvider()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should display current voice in listbox button', () => {
|
||||
renderWithProvider()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
|
||||
expect(voiceButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render audition button when language has example', () => {
|
||||
renderWithProvider()
|
||||
|
||||
const auditionButton = screen.queryByTestId('audition-button')
|
||||
expect(auditionButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render audition button when language has no example', () => {
|
||||
mockLanguages = [
|
||||
{ value: 'en-US', name: 'English', example: '' },
|
||||
{ value: 'zh-Hans', name: '中文', example: '' },
|
||||
]
|
||||
|
||||
renderWithProvider()
|
||||
|
||||
const auditionButton = screen.queryByTestId('audition-button')
|
||||
expect(auditionButton).toBeNull()
|
||||
})
|
||||
|
||||
it('should render with no language set and use first as default', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: '', voice: '', autoPlay: TtsAutoPlay.disabled },
|
||||
})
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render with no voice set and use first as default', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US', voice: 'nonexistent', autoPlay: TtsAutoPlay.disabled },
|
||||
})
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
|
||||
expect(voiceButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User-triggered behavior and callbacks.
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClose when close button is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i })
|
||||
await userEvent.click(closeButton)
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose when close button receives Enter key', async () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i })
|
||||
await userEvent.click(closeButton)
|
||||
onClose.mockClear()
|
||||
closeButton.focus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onClose when close button receives unrelated key', async () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i })
|
||||
closeButton.focus()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toggle autoPlay switch and call onChange', async () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
await userEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set autoPlay to disabled when toggled off from enabled state', async () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider(
|
||||
{ onChange },
|
||||
{ text2speech: { enabled: true, language: 'en-US', voice: 'alloy', autoPlay: TtsAutoPlay.enabled } },
|
||||
)
|
||||
|
||||
const autoPlaySwitch = screen.getByRole('switch')
|
||||
expect(autoPlaySwitch).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
await userEvent.click(autoPlaySwitch)
|
||||
|
||||
expect(autoPlaySwitch).toHaveAttribute('aria-checked', 'false')
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call feature update without onChange callback', async () => {
|
||||
renderWithProvider()
|
||||
|
||||
await userEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open language listbox and show options', async () => {
|
||||
renderWithProvider()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.'))
|
||||
expect(languageButton).toBeDefined()
|
||||
await userEvent.click(languageButton!)
|
||||
|
||||
const options = await screen.findAllByRole('option')
|
||||
expect(options.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('should handle language change', async () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.'))
|
||||
expect(languageButton).toBeDefined()
|
||||
await userEvent.click(languageButton!)
|
||||
const options = await screen.findAllByRole('option')
|
||||
expect(options.length).toBeGreaterThan(1)
|
||||
await userEvent.click(options[1])
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle voice change', async () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
|
||||
expect(voiceButton).toBeDefined()
|
||||
await userEvent.click(voiceButton!)
|
||||
const options = await screen.findAllByRole('option')
|
||||
expect(options.length).toBeGreaterThan(1)
|
||||
await userEvent.click(options[1])
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show selected language option in listbox', async () => {
|
||||
renderWithProvider()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.'))
|
||||
expect(languageButton).toBeDefined()
|
||||
await userEvent.click(languageButton!)
|
||||
const options = await screen.findAllByRole('option')
|
||||
expect(options.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const selectedOption = options.find(opt => opt.textContent?.includes('voice.language.enUS'))
|
||||
expect(selectedOption).toBeDefined()
|
||||
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('should show selected voice option in listbox', async () => {
|
||||
renderWithProvider()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
|
||||
expect(voiceButton).toBeDefined()
|
||||
await userEvent.click(voiceButton!)
|
||||
const options = await screen.findAllByRole('option')
|
||||
expect(options.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const selectedOption = options.find(opt => opt.textContent?.includes('Alloy'))
|
||||
expect(selectedOption).toBeDefined()
|
||||
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
// Fallback and boundary scenarios.
|
||||
describe('Edge Cases', () => {
|
||||
it('should show placeholder and disable voice selection when no languages are available', () => {
|
||||
mockLanguages = []
|
||||
mockVoiceItems = undefined
|
||||
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US', voice: 'alloy', autoPlay: TtsAutoPlay.disabled },
|
||||
})
|
||||
|
||||
const placeholderTexts = screen.getAllByText(/placeholder\.select/)
|
||||
expect(placeholderTexts.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
const disabledButtons = screen
|
||||
.getAllByRole('button')
|
||||
.filter(button => button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true')
|
||||
|
||||
expect(disabledButtons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should call useAppVoices with empty appId when pathname has no app segment', () => {
|
||||
mockPathname = '/configuration'
|
||||
|
||||
renderWithProvider()
|
||||
|
||||
expect(mockUseAppVoices).toHaveBeenCalledWith('', 'en-US')
|
||||
})
|
||||
|
||||
it('should render language text when selected language value is empty string', () => {
|
||||
mockLanguages = [{ value: '' as string, name: 'Unknown Language', example: '' }]
|
||||
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: '', voice: '', autoPlay: TtsAutoPlay.disabled },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/voice\.language\./)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,8 @@
|
||||
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'
|
||||
@@ -65,25 +67,11 @@ const VoiceParamConfig = ({
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<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 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>
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 flex items-center py-1 text-text-secondary system-sm-semibold">
|
||||
<div className="system-sm-semibold mb-1 flex items-center py-1 text-text-secondary">
|
||||
{t('voice.voiceSettings.language', { ns: 'appDebug' })}
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
@@ -115,7 +103,10 @@ const VoiceParamConfig = ({
|
||||
: localLanguagePlaceholder}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<span className="i-heroicons-chevron-down-20-solid h-4 w-4 text-text-tertiary" aria-hidden="true" />
|
||||
<ChevronDownIcon
|
||||
className="h-4 w-4 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
@@ -146,7 +137,7 @@ const VoiceParamConfig = ({
|
||||
<span
|
||||
className={cn('absolute inset-y-0 right-0 flex items-center pr-4 text-text-secondary')}
|
||||
>
|
||||
<span className="i-heroicons-check-20-solid h-4 w-4" aria-hidden="true" />
|
||||
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
@@ -159,7 +150,7 @@ const VoiceParamConfig = ({
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 py-1 text-text-secondary system-sm-semibold">
|
||||
<div className="system-sm-semibold mb-1 py-1 text-text-secondary">
|
||||
{t('voice.voiceSettings.voice', { ns: 'appDebug' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -182,7 +173,10 @@ const VoiceParamConfig = ({
|
||||
{voiceItem?.name ?? localVoicePlaceholder}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<span className="i-heroicons-chevron-down-20-solid h-4 w-4 text-text-tertiary" aria-hidden="true" />
|
||||
<ChevronDownIcon
|
||||
className="h-4 w-4 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
@@ -209,7 +203,7 @@ const VoiceParamConfig = ({
|
||||
<span
|
||||
className={cn('absolute inset-y-0 right-0 flex items-center pr-4 text-text-secondary')}
|
||||
>
|
||||
<span className="i-heroicons-check-20-solid h-4 w-4" aria-hidden="true" />
|
||||
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
@@ -221,7 +215,7 @@ const VoiceParamConfig = ({
|
||||
</div>
|
||||
</Listbox>
|
||||
{languageItem?.example && (
|
||||
<div className="h-8 shrink-0 rounded-lg bg-components-button-tertiary-bg p-1" data-testid="audition-button">
|
||||
<div className="h-8 shrink-0 rounded-lg bg-components-button-tertiary-bg p-1">
|
||||
<AudioBtn
|
||||
value={languageItem?.example}
|
||||
isAudition
|
||||
@@ -233,7 +227,7 @@ const VoiceParamConfig = ({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 py-1 text-text-secondary system-sm-semibold">
|
||||
<div className="system-sm-semibold mb-1 py-1 text-text-secondary">
|
||||
{t('voice.voiceSettings.autoPlay', { ns: 'appDebug' })}
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,180 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,69 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AudioPreview from './audio-preview'
|
||||
|
||||
describe('AudioPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render audio element with correct source', () => {
|
||||
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
|
||||
|
||||
const audio = document.querySelector('audio')
|
||||
expect(audio).toBeInTheDocument()
|
||||
expect(audio).toHaveAttribute('title', 'Test Audio')
|
||||
})
|
||||
|
||||
it('should render source element with correct src and type', () => {
|
||||
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
|
||||
|
||||
const source = document.querySelector('source')
|
||||
expect(source).toHaveAttribute('src', 'https://example.com/audio.mp3')
|
||||
expect(source).toHaveAttribute('type', 'audio/mpeg')
|
||||
})
|
||||
|
||||
it('should render close button with icon', () => {
|
||||
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
|
||||
|
||||
const closeIcon = screen.getByTestId('close-btn')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCancel when close button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={onCancel} />)
|
||||
|
||||
const closeIcon = screen.getByTestId('close-btn')
|
||||
fireEvent.click(closeIcon.parentElement!)
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop propagation when backdrop is clicked', () => {
|
||||
const { baseElement } = render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
|
||||
|
||||
const backdrop = baseElement.querySelector('[tabindex="-1"]')
|
||||
const event = new MouseEvent('click', { bubbles: true })
|
||||
const stopPropagation = vi.spyOn(event, 'stopPropagation')
|
||||
backdrop!.dispatchEvent(event)
|
||||
|
||||
expect(stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when Escape key is pressed', () => {
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={onCancel} />)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render in a portal attached to document.body', () => {
|
||||
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
|
||||
|
||||
const audio = document.querySelector('audio')
|
||||
expect(audio?.closest('[tabindex="-1"]')?.parentElement).toBe(document.body)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
@@ -35,7 +36,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}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="close-btn" />
|
||||
<RiCloseLine className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,173 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,179 +0,0 @@
|
||||
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('')
|
||||
})
|
||||
})
|
||||
@@ -1,142 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,407 +0,0 @@
|
||||
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: '',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -1,207 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,246 +0,0 @@
|
||||
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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -1,337 +0,0 @@
|
||||
import type { FileEntity } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import FileItem from './file-item'
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/format', () => ({
|
||||
formatFileSize: (size: number) => `${size}B`,
|
||||
}))
|
||||
|
||||
vi.mock('../dynamic-pdf-preview', () => ({
|
||||
default: ({ url, onCancel }: { url: string, onCancel: () => void }) => (
|
||||
<div data-testid="pdf-preview" data-url={url}>
|
||||
<button data-testid="pdf-close" onClick={onCancel}>Close PDF</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: 'file-1',
|
||||
name: 'document.pdf',
|
||||
size: 2048,
|
||||
type: 'application/pdf',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'document',
|
||||
uploadedId: 'uploaded-1',
|
||||
url: 'https://example.com/document.pdf',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('FileItem (chat-input)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render file name', () => {
|
||||
render(<FileItem file={createFile()} />)
|
||||
|
||||
expect(screen.getByText(/document\.pdf/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file extension and size', () => {
|
||||
const { container } = render(<FileItem file={createFile()} />)
|
||||
|
||||
// Extension and size are rendered as text nodes in the metadata div
|
||||
expect(container.textContent).toContain('pdf')
|
||||
expect(container.textContent).toContain('2048B')
|
||||
})
|
||||
|
||||
it('should render FileTypeIcon', () => {
|
||||
const { container } = render(<FileItem file={createFile()} />)
|
||||
|
||||
const fileTypeIcon = container.querySelector('svg')
|
||||
expect(fileTypeIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render delete button when showDeleteAction is true', () => {
|
||||
render(<FileItem file={createFile()} showDeleteAction />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should call onRemove when delete button is clicked', () => {
|
||||
const onRemove = vi.fn()
|
||||
render(<FileItem file={createFile()} showDeleteAction onRemove={onRemove} />)
|
||||
const delete_button = screen.getByTestId('delete-button')
|
||||
fireEvent.click(delete_button)
|
||||
expect(onRemove).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should render progress circle when file is uploading', () => {
|
||||
const { container } = render(
|
||||
<FileItem file={createFile({ progress: 50, uploadedId: undefined })} />,
|
||||
)
|
||||
|
||||
const progressSvg = container.querySelector('svg circle')
|
||||
expect(progressSvg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render replay icon when upload failed', () => {
|
||||
render(<FileItem file={createFile({ progress: -1 })} />)
|
||||
|
||||
const replayIcon = screen.getByTestId('replay-icon')
|
||||
expect(replayIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onReUpload when replay icon is clicked', () => {
|
||||
const onReUpload = vi.fn()
|
||||
render(
|
||||
<FileItem file={createFile({ progress: -1 })} onReUpload={onReUpload} />,
|
||||
)
|
||||
|
||||
const replayIcon = screen.getByTestId('replay-icon')
|
||||
fireEvent.click(replayIcon!)
|
||||
|
||||
expect(onReUpload).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should have error styling when upload failed', () => {
|
||||
const { container } = render(<FileItem file={createFile({ progress: -1 })} />)
|
||||
const fileItemContainer = container.firstChild as HTMLElement
|
||||
expect(fileItemContainer).toHaveClass('border-state-destructive-border')
|
||||
expect(fileItemContainer).toHaveClass('bg-state-destructive-hover-alt')
|
||||
})
|
||||
|
||||
it('should show audio preview when audio file name is clicked', async () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({
|
||||
name: 'audio.mp3',
|
||||
type: 'audio/mpeg',
|
||||
url: 'https://example.com/audio.mp3',
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/audio\.mp3/i))
|
||||
|
||||
const audioElement = document.querySelector('audio')
|
||||
expect(audioElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show video preview when video file name is clicked', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({
|
||||
name: 'video.mp4',
|
||||
type: 'video/mp4',
|
||||
url: 'https://example.com/video.mp4',
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/video\.mp4/i))
|
||||
|
||||
const videoElement = document.querySelector('video')
|
||||
expect(videoElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show pdf preview when pdf file name is clicked', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({
|
||||
name: 'doc.pdf',
|
||||
type: 'application/pdf',
|
||||
url: 'https://example.com/doc.pdf',
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/doc\.pdf/i))
|
||||
|
||||
expect(screen.getByTestId('pdf-preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close audio preview', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({
|
||||
name: 'audio.mp3',
|
||||
type: 'audio/mpeg',
|
||||
url: 'https://example.com/audio.mp3',
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/audio\.mp3/i))
|
||||
expect(document.querySelector('audio')).toBeInTheDocument()
|
||||
|
||||
const deleteButton = screen.getByTestId('close-btn')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(document.querySelector('audio')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render download button when showDownloadAction is true and url exists', () => {
|
||||
render(<FileItem file={createFile()} showDownloadAction />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should call downloadUrl when download button is clicked', async () => {
|
||||
const { downloadUrl } = await import('@/utils/download')
|
||||
render(<FileItem file={createFile()} showDownloadAction />)
|
||||
|
||||
const downloadBtn = screen.getByTestId('download-button')
|
||||
fireEvent.click(downloadBtn)
|
||||
|
||||
expect(downloadUrl).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not render download button when showDownloadAction is false', () => {
|
||||
render(<FileItem file={createFile()} showDownloadAction={false} />)
|
||||
|
||||
const buttons = screen.queryAllByRole('button')
|
||||
expect(buttons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should not show preview when canPreview is false', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({
|
||||
name: 'audio.mp3',
|
||||
type: 'audio/mpeg',
|
||||
})}
|
||||
canPreview={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/audio\.mp3/i))
|
||||
|
||||
expect(document.querySelector('audio')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close video preview', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({
|
||||
name: 'video.mp4',
|
||||
type: 'video/mp4',
|
||||
url: 'https://example.com/video.mp4',
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/video\.mp4/i))
|
||||
expect(document.querySelector('video')).toBeInTheDocument()
|
||||
|
||||
const closeBtn = screen.getByTestId('video-preview-close-btn')
|
||||
fireEvent.click(closeBtn)
|
||||
|
||||
expect(document.querySelector('video')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close pdf preview', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({
|
||||
name: 'doc.pdf',
|
||||
type: 'application/pdf',
|
||||
url: 'https://example.com/doc.pdf',
|
||||
})}
|
||||
canPreview
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/doc\.pdf/i))
|
||||
expect(screen.getByTestId('pdf-preview')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('pdf-close'))
|
||||
expect(screen.queryByTestId('pdf-preview')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use createObjectURL when no url or base64Url but has originalFile', () => {
|
||||
const mockUrl = 'blob:http://localhost/test-blob'
|
||||
const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue(mockUrl)
|
||||
|
||||
const file = createFile({
|
||||
name: 'audio.mp3',
|
||||
type: 'audio/mpeg',
|
||||
url: undefined,
|
||||
base64Url: undefined,
|
||||
originalFile: new File(['content'], 'audio.mp3', { type: 'audio/mpeg' }),
|
||||
})
|
||||
render(<FileItem file={file} canPreview />)
|
||||
|
||||
fireEvent.click(screen.getByText(/audio\.mp3/i))
|
||||
|
||||
expect(document.querySelector('audio')).toBeInTheDocument()
|
||||
expect(createObjectURLSpy).toHaveBeenCalled()
|
||||
createObjectURLSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should not use createObjectURL when no originalFile and no urls', () => {
|
||||
const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL')
|
||||
const file = createFile({
|
||||
name: 'audio.mp3',
|
||||
type: 'audio/mpeg',
|
||||
url: undefined,
|
||||
base64Url: undefined,
|
||||
originalFile: undefined,
|
||||
})
|
||||
render(<FileItem file={file} canPreview />)
|
||||
|
||||
fireEvent.click(screen.getByText(/audio\.mp3/i))
|
||||
expect(createObjectURLSpy).not.toHaveBeenCalled()
|
||||
createObjectURLSpy.mockRestore()
|
||||
expect(document.querySelector('audio')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render download button when download_url is falsy', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({ url: undefined, base64Url: undefined })}
|
||||
showDownloadAction
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.queryAllByRole('button')
|
||||
expect(buttons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should render download button when base64Url is available as download_url', () => {
|
||||
render(
|
||||
<FileItem
|
||||
file={createFile({ url: undefined, base64Url: 'data:application/pdf;base64,abc' })}
|
||||
showDownloadAction
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should not render extension separator when ext is empty', () => {
|
||||
render(<FileItem file={createFile({ name: 'noext' })} />)
|
||||
|
||||
expect(screen.getByText(/noext/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render file size when size is 0', () => {
|
||||
render(<FileItem file={createFile({ size: 0 })} />)
|
||||
|
||||
expect(screen.queryByText(/0B/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,15 @@
|
||||
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'
|
||||
@@ -57,21 +62,20 @@ 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"
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-components-button-secondary-text" />
|
||||
<RiCloseLine className="h-4 w-4 text-components-button-secondary-text" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
<div
|
||||
className="mb-1 line-clamp-2 h-8 cursor-pointer break-all text-text-tertiary system-xs-medium"
|
||||
className="system-xs-medium mb-1 line-clamp-2 h-8 cursor-pointer break-all text-text-tertiary"
|
||||
title={name}
|
||||
onClick={() => canPreview && setPreviewUrl(tmp_preview_url || '')}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
<div className="relative flex items-center justify-between">
|
||||
<div className="flex items-center text-text-tertiary system-2xs-medium-uppercase">
|
||||
<div className="system-2xs-medium-uppercase flex items-center text-text-tertiary">
|
||||
<FileTypeIcon
|
||||
size="sm"
|
||||
type={getFileAppearanceType(name, type)}
|
||||
@@ -98,9 +102,8 @@ const FileItem = ({
|
||||
e.stopPropagation()
|
||||
downloadUrl({ url: download_url || '', fileName: name, target: '_blank' })
|
||||
}}
|
||||
data-testid="download-button"
|
||||
>
|
||||
<span className="i-ri-download-line h-3.5 w-3.5 text-text-tertiary" />
|
||||
<RiDownloadLine className="h-3.5 w-3.5 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
@@ -115,7 +118,10 @@ const FileItem = ({
|
||||
}
|
||||
{
|
||||
uploadError && (
|
||||
<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} />
|
||||
<ReplayLine
|
||||
className="h-4 w-4 text-text-tertiary"
|
||||
onClick={() => onReUpload?.(id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,867 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter'
|
||||
import 'react-pdf-highlighter/dist/style.css'
|
||||
|
||||
export {
|
||||
PdfHighlighter,
|
||||
PdfLoader,
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import PdfPreview from './pdf-preview'
|
||||
|
||||
vi.mock('./pdf-highlighter-adapter', () => ({
|
||||
PdfLoader: ({ children, beforeLoad }: { children: (doc: unknown) => ReactNode, beforeLoad: ReactNode }) => (
|
||||
<div data-testid="pdf-loader">
|
||||
{beforeLoad}
|
||||
{children({ numPages: 1 })}
|
||||
</div>
|
||||
),
|
||||
PdfHighlighter: ({ enableAreaSelection, highlightTransform, scrollRef, onScrollChange, onSelectionFinished }: {
|
||||
enableAreaSelection?: (event: MouseEvent) => boolean
|
||||
highlightTransform?: () => ReactNode
|
||||
scrollRef?: (ref: unknown) => void
|
||||
onScrollChange?: () => void
|
||||
onSelectionFinished?: () => unknown
|
||||
}) => {
|
||||
enableAreaSelection?.(new MouseEvent('click'))
|
||||
highlightTransform?.()
|
||||
scrollRef?.(null)
|
||||
onScrollChange?.()
|
||||
onSelectionFinished?.()
|
||||
return <div data-testid="pdf-highlighter" />
|
||||
},
|
||||
}))
|
||||
|
||||
describe('PdfPreview', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
const getScaleContainer = () => {
|
||||
const container = document.querySelector('div[style*="transform"]') as HTMLDivElement | null
|
||||
expect(container).toBeInTheDocument()
|
||||
return container!
|
||||
}
|
||||
|
||||
const getControl = (rightClass: 'right-24' | 'right-16' | 'right-6') => {
|
||||
const control = document.querySelector(`div.absolute.${rightClass}.top-6`) as HTMLDivElement | null
|
||||
expect(control).toBeInTheDocument()
|
||||
return control!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.innerWidth = 1024
|
||||
fireEvent(window, new Event('resize'))
|
||||
})
|
||||
|
||||
it('should render the pdf preview portal with overlay and loading indicator', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
expect(document.querySelector('[tabindex="-1"]')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('pdf-loader')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('pdf-highlighter')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render zoom in, zoom out, and close icon SVGs', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
const svgs = document.querySelectorAll('svg')
|
||||
expect(svgs.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('should zoom in when zoom in control is clicked', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.click(getControl('right-16'))
|
||||
|
||||
expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)')
|
||||
})
|
||||
|
||||
it('should zoom out when zoom out control is clicked', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.click(getControl('right-24'))
|
||||
|
||||
expect(getScaleContainer().getAttribute('style')).toMatch(/scale\(0\.8333/)
|
||||
})
|
||||
|
||||
it('should keep non-1 scale when zooming out from a larger scale', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.click(getControl('right-16'))
|
||||
fireEvent.click(getControl('right-16'))
|
||||
fireEvent.click(getControl('right-24'))
|
||||
|
||||
expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)')
|
||||
})
|
||||
|
||||
it('should reset scale back to 1 when zooming in then out', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.click(getControl('right-16'))
|
||||
fireEvent.click(getControl('right-24'))
|
||||
|
||||
expect(getScaleContainer().getAttribute('style')).toContain('scale(1)')
|
||||
})
|
||||
|
||||
it('should zoom in when ArrowUp key is pressed', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'ArrowUp', code: 'ArrowUp' })
|
||||
|
||||
expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)')
|
||||
})
|
||||
|
||||
it('should zoom out when ArrowDown key is pressed', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'ArrowDown', code: 'ArrowDown' })
|
||||
|
||||
expect(getScaleContainer().getAttribute('style')).toMatch(/scale\(0\.8333/)
|
||||
})
|
||||
|
||||
it('should call onCancel when close control is clicked', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.click(getControl('right-6'))
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when Escape key is pressed', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render the overlay and stop click propagation', () => {
|
||||
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
|
||||
|
||||
const overlay = document.querySelector('[tabindex="-1"]')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
const event = new MouseEvent('click', { bubbles: true })
|
||||
const stopPropagation = vi.spyOn(event, 'stopPropagation')
|
||||
overlay!.dispatchEvent(event)
|
||||
expect(stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -6,10 +6,11 @@ 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 { PdfHighlighter, PdfLoader } from './pdf-highlighter-adapter'
|
||||
import 'react-pdf-highlighter/dist/style.css'
|
||||
|
||||
type PdfPreviewProps = {
|
||||
url: string
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import type { FileEntity } from './types'
|
||||
import { render, renderHook, screen } from '@testing-library/react'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { createFileStore, FileContext, FileContextProvider, useFileStore, useStore } from './store'
|
||||
|
||||
const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: 'file-1',
|
||||
name: 'test.txt',
|
||||
size: 1024,
|
||||
type: 'text/plain',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'document',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('createFileStore', () => {
|
||||
it('should create a store with empty files by default', () => {
|
||||
const store = createFileStore()
|
||||
expect(store.getState().files).toEqual([])
|
||||
})
|
||||
|
||||
it('should create a store with empty array when value is falsy', () => {
|
||||
const store = createFileStore(undefined)
|
||||
expect(store.getState().files).toEqual([])
|
||||
})
|
||||
|
||||
it('should create a store with initial files', () => {
|
||||
const files = [createMockFile()]
|
||||
const store = createFileStore(files)
|
||||
expect(store.getState().files).toEqual(files)
|
||||
})
|
||||
|
||||
it('should spread initial value to create a new array', () => {
|
||||
const files = [createMockFile()]
|
||||
const store = createFileStore(files)
|
||||
expect(store.getState().files).not.toBe(files)
|
||||
expect(store.getState().files).toEqual(files)
|
||||
})
|
||||
|
||||
it('should update files via setFiles', () => {
|
||||
const store = createFileStore()
|
||||
const newFiles = [createMockFile()]
|
||||
store.getState().setFiles(newFiles)
|
||||
expect(store.getState().files).toEqual(newFiles)
|
||||
})
|
||||
|
||||
it('should call onChange when setFiles is called', () => {
|
||||
const onChange = vi.fn()
|
||||
const store = createFileStore([], onChange)
|
||||
const newFiles = [createMockFile()]
|
||||
store.getState().setFiles(newFiles)
|
||||
expect(onChange).toHaveBeenCalledWith(newFiles)
|
||||
})
|
||||
|
||||
it('should not throw when onChange is not provided', () => {
|
||||
const store = createFileStore()
|
||||
expect(() => store.getState().setFiles([])).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useStore', () => {
|
||||
it('should return selected state from the store', () => {
|
||||
const files = [createMockFile()]
|
||||
const store = createFileStore(files)
|
||||
|
||||
const { result } = renderHook(() => useStore(s => s.files), {
|
||||
wrapper: ({ children }) => (
|
||||
<FileContext.Provider value={store}>{children}</FileContext.Provider>
|
||||
),
|
||||
})
|
||||
|
||||
expect(result.current).toEqual(files)
|
||||
})
|
||||
|
||||
it('should throw when used without FileContext.Provider', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useStore(s => s.files))
|
||||
}).toThrow('Missing FileContext.Provider in the tree')
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFileStore', () => {
|
||||
it('should return the store from context', () => {
|
||||
const store = createFileStore()
|
||||
|
||||
const { result } = renderHook(() => useFileStore(), {
|
||||
wrapper: ({ children }) => (
|
||||
<FileContext.Provider value={store}>{children}</FileContext.Provider>
|
||||
),
|
||||
})
|
||||
|
||||
expect(result.current).toBe(store)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileContextProvider', () => {
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<div data-testid="child">Hello</div>
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should provide a store to children', () => {
|
||||
const TestChild = () => {
|
||||
const files = useStore(s => s.files)
|
||||
return <div data-testid="files">{files.length}</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<TestChild />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('files')).toHaveTextContent('0')
|
||||
})
|
||||
|
||||
it('should initialize store with value prop', () => {
|
||||
const files = [createMockFile()]
|
||||
const TestChild = () => {
|
||||
const storeFiles = useStore(s => s.files)
|
||||
return <div data-testid="files">{storeFiles.length}</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<FileContextProvider value={files}>
|
||||
<TestChild />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('files')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('should reuse store on re-render instead of creating a new one', () => {
|
||||
const TestChild = () => {
|
||||
const storeFiles = useStore(s => s.files)
|
||||
return <div data-testid="files">{storeFiles.length}</div>
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<FileContextProvider>
|
||||
<TestChild />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('files')).toHaveTextContent('0')
|
||||
|
||||
// Re-render with new value prop - store should be reused (storeRef.current exists)
|
||||
rerender(
|
||||
<FileContextProvider value={[createMockFile()]}>
|
||||
<TestChild />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
// Store was created once on first render, so the value prop change won't create a new store
|
||||
// The files count should still be 0 since storeRef.current is already set
|
||||
expect(screen.getByTestId('files')).toHaveTextContent('0')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileEntity } from './types'
|
||||
import mime from 'mime'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { upload } from '@/service/base'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
getFileExtension,
|
||||
getFileNameFromUrl,
|
||||
getFilesInLogs,
|
||||
getFileUploadErrorMessage,
|
||||
getProcessedFiles,
|
||||
getProcessedFilesFromResponse,
|
||||
getSupportFileExtensionList,
|
||||
@@ -19,40 +18,23 @@ import {
|
||||
isAllowedFileExtension,
|
||||
} from './utils'
|
||||
|
||||
vi.mock('mime', () => ({
|
||||
default: {
|
||||
getAllExtensions: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
upload: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('file-uploader utils', () => {
|
||||
beforeEach(() => {
|
||||
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')
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('fileUpload', () => {
|
||||
it('should handle successful file upload', async () => {
|
||||
it('should handle successful file upload', () => {
|
||||
const mockFile = new File(['test'], 'test.txt')
|
||||
const mockCallbacks = {
|
||||
onProgressCallback: vi.fn(),
|
||||
@@ -68,102 +50,32 @@ 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', () => {
|
||||
it('should get extension from mimetype and file name 1', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
|
||||
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', () => {
|
||||
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)
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['der', 'crt', 'pem']))
|
||||
expect(getFileExtension('file', 'application/x-x509-ca-cert')).toBe('der')
|
||||
})
|
||||
|
||||
it('should get extension from filename when mimetype is empty', () => {
|
||||
it('should get extension from filename if mimetype fails', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(null)
|
||||
expect(getFileExtension('file.txt', '')).toBe('txt')
|
||||
expect(getFileExtension('file.txt.docx', '')).toBe('docx')
|
||||
expect(getFileExtension('file', '')).toBe('')
|
||||
@@ -172,123 +84,164 @@ 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)
|
||||
expect(getFileAppearanceType('image.svg', 'image/svg+xml'))
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['svg']))
|
||||
expect(getFileAppearanceType('image.svg', 'image/svgxml'))
|
||||
.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)
|
||||
expect(getFileAppearanceType('video.webm', 'video/webm'))
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['webm']))
|
||||
expect(getFileAppearanceType('video.web', '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)
|
||||
expect(getFileAppearanceType('audio.wav', 'audio/wav'))
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['wav']))
|
||||
expect(getFileAppearanceType('audio.wav', 'audio/vnd.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 ppt files', () => {
|
||||
it('should identify word files', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['ppt']))
|
||||
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)
|
||||
expect(getFileAppearanceType('file.xml', 'application/xml'))
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xml']))
|
||||
expect(getFileAppearanceType('file.xml', 'application/rssxml'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
expect(getFileAppearanceType('file.epub', 'application/epub+zip'))
|
||||
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['epub']))
|
||||
expect(getFileAppearanceType('file.epub', 'application/epubzip'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
})
|
||||
|
||||
it('should fall back to filename extension for unknown mimetype', () => {
|
||||
expect(getFileAppearanceType('file.txt', 'application/unknown'))
|
||||
it('should handle null mime extension', () => {
|
||||
vi.mocked(mime.getAllExtensions).mockReturnValue(null)
|
||||
expect(getFileAppearanceType('file.txt', 'text/plain'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
})
|
||||
|
||||
it('should return custom type for unrecognized extensions', () => {
|
||||
expect(getFileAppearanceType('file.xyz', 'application/xyz'))
|
||||
.toBe(FileAppearanceTypeEnum.custom)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSupportFileType', () => {
|
||||
@@ -325,70 +278,25 @@ 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',
|
||||
@@ -459,7 +367,7 @@ describe('file-uploader utils', () => {
|
||||
extension: '.mp3',
|
||||
filename: 'audio.mp3',
|
||||
size: 1024,
|
||||
mime_type: 'audio/mpeg',
|
||||
mime_type: 'audio/mp3',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/audio.mp3',
|
||||
@@ -507,7 +415,7 @@ describe('file-uploader utils', () => {
|
||||
expect(result[0].supportFileType).toBe('document')
|
||||
})
|
||||
|
||||
it('should NOT correct when filename and MIME type both point to same type', () => {
|
||||
it('should NOT correct when filename and MIME type both point to wrong type', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.jpg',
|
||||
@@ -632,11 +540,6 @@ 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', () => {
|
||||
@@ -696,6 +599,7 @@ 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',
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import VideoPreview from './video-preview'
|
||||
|
||||
describe('VideoPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render video element with correct title', () => {
|
||||
render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
|
||||
|
||||
const video = document.querySelector('video')
|
||||
expect(video).toBeInTheDocument()
|
||||
expect(video).toHaveAttribute('title', 'Test Video')
|
||||
})
|
||||
|
||||
it('should render source element with correct src and type', () => {
|
||||
render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
|
||||
|
||||
const source = document.querySelector('source')
|
||||
expect(source).toHaveAttribute('src', 'https://example.com/video.mp4')
|
||||
expect(source).toHaveAttribute('type', 'video/mp4')
|
||||
})
|
||||
|
||||
it('should render close button with icon', () => {
|
||||
const { getByTestId } = render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
|
||||
|
||||
const closeIcon = getByTestId('video-preview-close-btn')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCancel when close button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
const { getByTestId } = render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />)
|
||||
|
||||
const closeIcon = getByTestId('video-preview-close-btn')
|
||||
fireEvent.click(closeIcon.parentElement!)
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop propagation when backdrop is clicked', () => {
|
||||
const { baseElement } = render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
|
||||
|
||||
const backdrop = baseElement.querySelector('[tabindex="-1"]')
|
||||
const event = new MouseEvent('click', { bubbles: true })
|
||||
const stopPropagation = vi.spyOn(event, 'stopPropagation')
|
||||
backdrop!.dispatchEvent(event)
|
||||
|
||||
expect(stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when Escape key is pressed', () => {
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render in a portal attached to document.body', () => {
|
||||
render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
|
||||
|
||||
const video = document.querySelector('video')
|
||||
expect(video?.closest('[tabindex="-1"]')?.parentElement).toBe(document.body)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
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'
|
||||
@@ -34,7 +35,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}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="video-preview-close-btn" />
|
||||
<RiCloseLine className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import FloatRightContainer from './index'
|
||||
|
||||
describe('FloatRightContainer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering behavior across mobile and desktop branches.
|
||||
describe('Rendering', () => {
|
||||
it('should render content in drawer when isMobile is true and isOpen is true', async () => {
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={true}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
title="Mobile panel"
|
||||
>
|
||||
<div>Mobile content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mobile panel')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mobile content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render content when isMobile is true and isOpen is false', () => {
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={true}
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
unmount={true}
|
||||
>
|
||||
<div>Closed mobile content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Closed mobile content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render content inline when isMobile is false and isOpen is true', () => {
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={false}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
title="Desktop drawer title should not render"
|
||||
>
|
||||
<div>Desktop inline content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Desktop inline content')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Desktop drawer title should not render')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when isMobile is false and isOpen is false', () => {
|
||||
const { container } = render(
|
||||
<FloatRightContainer
|
||||
isMobile={false}
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
>
|
||||
<div>Hidden desktop content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(screen.queryByText('Hidden desktop content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Validate that drawer-specific props are passed through in mobile mode.
|
||||
describe('Props forwarding', () => {
|
||||
it('should call onClose when close icon is clicked in mobile drawer mode', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={true}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
showClose={true}
|
||||
>
|
||||
<div>Closable mobile content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
const closeIcon = screen.getByTestId('close-icon')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(closeIcon)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when close is done using escape key', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={true}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
showClose={true}
|
||||
>
|
||||
<div>Closable content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
const closeIcon = screen.getByTestId('close-icon')
|
||||
closeIcon.focus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when close is done using space key', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={true}
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
showClose={true}
|
||||
>
|
||||
<div>Closable content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
const closeIcon = screen.getByTestId('close-icon')
|
||||
closeIcon.focus()
|
||||
await userEvent.keyboard(' ')
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should apply drawer className props in mobile drawer mode', async () => {
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={true}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
dialogClassName="custom-dialog-class"
|
||||
panelClassName="custom-panel-class"
|
||||
>
|
||||
<div>Class forwarding content</div>
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(dialog).toHaveClass('custom-dialog-class')
|
||||
|
||||
const panel = document.querySelector('.custom-panel-class')
|
||||
expect(panel).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge-case behavior with optional children.
|
||||
describe('Edge cases', () => {
|
||||
it('should render without crashing when children is undefined in mobile mode', async () => {
|
||||
render(
|
||||
<FloatRightContainer
|
||||
isMobile={true}
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
title="Empty mobile panel"
|
||||
>
|
||||
{undefined}
|
||||
</FloatRightContainer>,
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('Empty mobile panel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -47,7 +47,6 @@ const ImageGallery: FC<Props> = ({
|
||||
style={imgStyle}
|
||||
src={src}
|
||||
alt=""
|
||||
data-testid="gallery-image" // Added for testing
|
||||
onClick={() => setImagePreviewUrl(src)}
|
||||
onError={e => e.currentTarget.remove()}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import { RiAddBoxLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
|
||||
import { RiAddBoxLine, RiCloseLine, 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}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="image-preview-close-button" />
|
||||
<RiCloseLine className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user