mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 18:05:11 +00:00
Merge branch 'main' into fix/query-client-infra-improvements
This commit is contained in:
@@ -133,7 +133,7 @@ class AppQueueManager(ABC):
|
||||
self._publish(event, pub_from)
|
||||
|
||||
@abstractmethod
|
||||
def _publish(self, event: AppQueueEvent, pub_from: PublishFrom):
|
||||
def _publish(self, event: AppQueueEvent, pub_from: PublishFrom) -> None:
|
||||
"""
|
||||
Publish event to queue
|
||||
:param event:
|
||||
|
||||
@@ -39,7 +39,7 @@ class Moderation(Extensible, ABC):
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def validate_config(cls, tenant_id: str, config: dict):
|
||||
def validate_config(cls, tenant_id: str, config: dict) -> None:
|
||||
"""
|
||||
Validate the incoming form config data.
|
||||
|
||||
|
||||
@@ -129,11 +129,11 @@ class LangfuseSpan(BaseModel):
|
||||
default=None,
|
||||
description="The id of the user that triggered the execution. Used to provide user-level analytics.",
|
||||
)
|
||||
start_time: datetime | str | None = Field(
|
||||
start_time: datetime | None = Field(
|
||||
default_factory=datetime.now,
|
||||
description="The time at which the span started, defaults to the current time.",
|
||||
)
|
||||
end_time: datetime | str | None = Field(
|
||||
end_time: datetime | None = Field(
|
||||
default=None,
|
||||
description="The time at which the span ended. Automatically set by span.end().",
|
||||
)
|
||||
@@ -146,7 +146,7 @@ class LangfuseSpan(BaseModel):
|
||||
description="Additional metadata of the span. Can be any JSON object. Metadata is merged when being updated "
|
||||
"via the API.",
|
||||
)
|
||||
level: str | None = Field(
|
||||
level: LevelEnum | None = Field(
|
||||
default=None,
|
||||
description="The level of the span. Can be DEBUG, DEFAULT, WARNING or ERROR. Used for sorting/filtering of "
|
||||
"traces with elevated error levels and for highlighting in the UI.",
|
||||
@@ -222,16 +222,16 @@ class LangfuseGeneration(BaseModel):
|
||||
default=None,
|
||||
description="Identifier of the generation. Useful for sorting/filtering in the UI.",
|
||||
)
|
||||
start_time: datetime | str | None = Field(
|
||||
start_time: datetime | None = Field(
|
||||
default_factory=datetime.now,
|
||||
description="The time at which the generation started, defaults to the current time.",
|
||||
)
|
||||
completion_start_time: datetime | str | None = Field(
|
||||
completion_start_time: datetime | None = Field(
|
||||
default=None,
|
||||
description="The time at which the completion started (streaming). Set it to get latency analytics broken "
|
||||
"down into time until completion started and completion duration.",
|
||||
)
|
||||
end_time: datetime | str | None = Field(
|
||||
end_time: datetime | None = Field(
|
||||
default=None,
|
||||
description="The time at which the generation ended. Automatically set by generation.end().",
|
||||
)
|
||||
|
||||
@@ -18,8 +18,7 @@ except ImportError:
|
||||
from importlib_metadata import version # type: ignore[import-not-found]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.metrics import Meter
|
||||
from opentelemetry.metrics._internal.instrument import Histogram
|
||||
from opentelemetry.metrics import Histogram, Meter
|
||||
from opentelemetry.sdk.metrics.export import MetricReader
|
||||
|
||||
from opentelemetry import trace as trace_api
|
||||
|
||||
@@ -15,11 +15,11 @@ class BaseVector(ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs):
|
||||
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs) -> list[str] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs):
|
||||
def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs) -> list[str]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
@@ -27,14 +27,14 @@ class BaseVector(ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def delete_by_ids(self, ids: list[str]):
|
||||
def delete_by_ids(self, ids: list[str]) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_ids_by_metadata_field(self, key: str, value: str):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def delete_by_metadata_field(self, key: str, value: str):
|
||||
def delete_by_metadata_field(self, key: str, value: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
@@ -46,7 +46,7 @@ class BaseVector(ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def delete(self):
|
||||
def delete(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def _filter_duplicate_texts(self, texts: list[Document]) -> list[Document]:
|
||||
|
||||
@@ -2,7 +2,7 @@ import re
|
||||
from json import dumps as json_dumps
|
||||
from json import loads as json_loads
|
||||
from json.decoder import JSONDecodeError
|
||||
from typing import Any
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import httpx
|
||||
from flask import request
|
||||
@@ -14,6 +14,12 @@ from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolParamet
|
||||
from core.tools.errors import ToolApiSchemaError, ToolNotSupportedError, ToolProviderNotFoundError
|
||||
|
||||
|
||||
class _OpenAPIInterface(TypedDict):
|
||||
path: str
|
||||
method: str
|
||||
operation: dict[str, Any]
|
||||
|
||||
|
||||
class ApiBasedToolSchemaParser:
|
||||
@staticmethod
|
||||
def parse_openapi_to_tool_bundle(
|
||||
@@ -35,17 +41,17 @@ class ApiBasedToolSchemaParser:
|
||||
server_url = matched_servers[0] if matched_servers else server_url
|
||||
|
||||
# list all interfaces
|
||||
interfaces = []
|
||||
interfaces: list[_OpenAPIInterface] = []
|
||||
for path, path_item in openapi["paths"].items():
|
||||
methods = ["get", "post", "put", "delete", "patch", "head", "options", "trace"]
|
||||
for method in methods:
|
||||
if method in path_item:
|
||||
interfaces.append(
|
||||
{
|
||||
"path": path,
|
||||
"method": method,
|
||||
"operation": path_item[method],
|
||||
}
|
||||
_OpenAPIInterface(
|
||||
path=path,
|
||||
method=method,
|
||||
operation=path_item[method],
|
||||
)
|
||||
)
|
||||
|
||||
# get all parameters
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Integration tests for Dataset and Document model properties using testcontainers.
|
||||
|
||||
These tests validate database-backed model properties (total_documents, word_count, etc.)
|
||||
without mocking SQLAlchemy queries, ensuring real query behavior against PostgreSQL.
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.dataset import Dataset, Document, DocumentSegment
|
||||
|
||||
|
||||
class TestDatasetDocumentProperties:
|
||||
"""Integration tests for Dataset and Document model properties."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _auto_rollback(self, db_session_with_containers: Session) -> Generator[None, None, None]:
|
||||
"""Automatically rollback session changes after each test."""
|
||||
yield
|
||||
db_session_with_containers.rollback()
|
||||
|
||||
def test_dataset_with_documents_relationship(self, db_session_with_containers: Session) -> None:
|
||||
"""Test dataset can track its documents."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i in range(3):
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=i + 1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name=f"doc_{i}.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.total_documents == 3
|
||||
|
||||
def test_dataset_available_documents_count(self, db_session_with_containers: Session) -> None:
|
||||
"""Test dataset can count available documents."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc_available = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="available.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
indexing_status="completed",
|
||||
enabled=True,
|
||||
archived=False,
|
||||
)
|
||||
doc_pending = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=2,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="pending.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
indexing_status="waiting",
|
||||
enabled=True,
|
||||
archived=False,
|
||||
)
|
||||
doc_disabled = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=3,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="disabled.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
indexing_status="completed",
|
||||
enabled=False,
|
||||
archived=False,
|
||||
)
|
||||
db_session_with_containers.add_all([doc_available, doc_pending, doc_disabled])
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.total_available_documents == 1
|
||||
|
||||
def test_dataset_word_count_aggregation(self, db_session_with_containers: Session) -> None:
|
||||
"""Test dataset can aggregate word count from documents."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i, wc in enumerate([2000, 3000]):
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=i + 1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name=f"doc_{i}.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
word_count=wc,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.word_count == 5000
|
||||
|
||||
def test_dataset_available_segment_count(self, db_session_with_containers: Session) -> None:
|
||||
"""Test Dataset.available_segment_count counts completed and enabled segments."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="doc.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i in range(2):
|
||||
seg = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=i + 1,
|
||||
content=f"segment {i}",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
status="completed",
|
||||
enabled=True,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg)
|
||||
|
||||
seg_waiting = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=3,
|
||||
content="waiting segment",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
status="waiting",
|
||||
enabled=True,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg_waiting)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.available_segment_count == 2
|
||||
|
||||
def test_document_segment_count_property(self, db_session_with_containers: Session) -> None:
|
||||
"""Test document can count its segments."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="doc.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i in range(3):
|
||||
seg = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=i + 1,
|
||||
content=f"segment {i}",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert doc.segment_count == 3
|
||||
|
||||
def test_document_hit_count_aggregation(self, db_session_with_containers: Session) -> None:
|
||||
"""Test document can aggregate hit count from segments."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="doc.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i, hits in enumerate([10, 15]):
|
||||
seg = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=i + 1,
|
||||
content=f"segment {i}",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
hit_count=hits,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert doc.hit_count == 25
|
||||
@@ -12,7 +12,7 @@ This test suite covers:
|
||||
import json
|
||||
import pickle
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from models.dataset import (
|
||||
@@ -954,156 +954,6 @@ class TestChildChunk:
|
||||
assert child_chunk.index_node_hash == index_node_hash
|
||||
|
||||
|
||||
class TestDatasetDocumentCascadeDeletes:
|
||||
"""Test suite for Dataset-Document cascade delete operations."""
|
||||
|
||||
def test_dataset_with_documents_relationship(self):
|
||||
"""Test dataset can track its documents."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid4())
|
||||
dataset = Dataset(
|
||||
tenant_id=str(uuid4()),
|
||||
name="Test Dataset",
|
||||
data_source_type="upload_file",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
dataset.id = dataset_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.where.return_value.scalar.return_value = 3
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
total_docs = dataset.total_documents
|
||||
|
||||
# Assert
|
||||
assert total_docs == 3
|
||||
|
||||
def test_dataset_available_documents_count(self):
|
||||
"""Test dataset can count available documents."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid4())
|
||||
dataset = Dataset(
|
||||
tenant_id=str(uuid4()),
|
||||
name="Test Dataset",
|
||||
data_source_type="upload_file",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
dataset.id = dataset_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.where.return_value.scalar.return_value = 2
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
available_docs = dataset.total_available_documents
|
||||
|
||||
# Assert
|
||||
assert available_docs == 2
|
||||
|
||||
def test_dataset_word_count_aggregation(self):
|
||||
"""Test dataset can aggregate word count from documents."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid4())
|
||||
dataset = Dataset(
|
||||
tenant_id=str(uuid4()),
|
||||
name="Test Dataset",
|
||||
data_source_type="upload_file",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
dataset.id = dataset_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.with_entities.return_value.where.return_value.scalar.return_value = 5000
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
total_words = dataset.word_count
|
||||
|
||||
# Assert
|
||||
assert total_words == 5000
|
||||
|
||||
def test_dataset_available_segment_count(self):
|
||||
"""Test dataset can count available segments."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid4())
|
||||
dataset = Dataset(
|
||||
tenant_id=str(uuid4()),
|
||||
name="Test Dataset",
|
||||
data_source_type="upload_file",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
dataset.id = dataset_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.where.return_value.scalar.return_value = 15
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
segment_count = dataset.available_segment_count
|
||||
|
||||
# Assert
|
||||
assert segment_count == 15
|
||||
|
||||
def test_document_segment_count_property(self):
|
||||
"""Test document can count its segments."""
|
||||
# Arrange
|
||||
document_id = str(uuid4())
|
||||
document = Document(
|
||||
tenant_id=str(uuid4()),
|
||||
dataset_id=str(uuid4()),
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="test.pdf",
|
||||
created_from="web",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
document.id = document_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.where.return_value.count.return_value = 10
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
segment_count = document.segment_count
|
||||
|
||||
# Assert
|
||||
assert segment_count == 10
|
||||
|
||||
def test_document_hit_count_aggregation(self):
|
||||
"""Test document can aggregate hit count from segments."""
|
||||
# Arrange
|
||||
document_id = str(uuid4())
|
||||
document = Document(
|
||||
tenant_id=str(uuid4()),
|
||||
dataset_id=str(uuid4()),
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="test.pdf",
|
||||
created_from="web",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
document.id = document_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.with_entities.return_value.where.return_value.scalar.return_value = 25
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
hit_count = document.hit_count
|
||||
|
||||
# Assert
|
||||
assert hit_count == 25
|
||||
|
||||
|
||||
class TestDocumentSegmentNavigation:
|
||||
"""Test suite for DocumentSegment navigation properties."""
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
|
||||
import type { IConfigVarProps } from './index'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
@@ -237,7 +237,8 @@ describe('ConfigVar', () => {
|
||||
expect(actionButtons).toHaveLength(2)
|
||||
fireEvent.click(actionButtons[0])
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'common.operation.save' })
|
||||
const editDialog = await screen.findByRole('dialog')
|
||||
const saveButton = within(editDialog).getByRole('button', { name: 'common.operation.save' })
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -323,14 +323,8 @@ describe('CustomizeModal', () => {
|
||||
expect(screen.getByText('appOverview.overview.appInfo.customize.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find the close button by navigating from the heading to the close icon
|
||||
// The close icon is an SVG inside a sibling div of the title
|
||||
const heading = screen.getByRole('heading', { name: /customize\.title/i })
|
||||
const closeIcon = heading.parentElement!.querySelector('svg')
|
||||
|
||||
// Assert - closeIcon must exist for the test to be valid
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
fireEvent.click(closeIcon!)
|
||||
const closeButton = screen.getByTestId('modal-close-button')
|
||||
fireEvent.click(closeButton)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
96
web/app/components/base/alert.spec.tsx
Normal file
96
web/app/components/base/alert.spec.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Alert from './alert'
|
||||
|
||||
describe('Alert', () => {
|
||||
const defaultProps = {
|
||||
message: 'This is an alert message',
|
||||
onHide: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Alert {...defaultProps} />)
|
||||
expect(screen.getByText(defaultProps.message)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the info icon', () => {
|
||||
render(<Alert {...defaultProps} />)
|
||||
const icon = screen.getByTestId('info-icon')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the close icon', () => {
|
||||
render(<Alert {...defaultProps} />)
|
||||
const closeIcon = screen.getByTestId('close-icon')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<Alert {...defaultProps} className="my-custom-class" />)
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv).toHaveClass('my-custom-class')
|
||||
})
|
||||
|
||||
it('should retain base classes when custom className is applied', () => {
|
||||
const { container } = render(<Alert {...defaultProps} className="my-custom-class" />)
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv).toHaveClass('pointer-events-none', 'w-full')
|
||||
})
|
||||
|
||||
it('should default type to info', () => {
|
||||
render(<Alert {...defaultProps} />)
|
||||
const gradientDiv = screen.getByTestId('alert-gradient')
|
||||
expect(gradientDiv).toHaveClass('from-components-badge-status-light-normal-halo')
|
||||
})
|
||||
|
||||
it('should render with explicit type info', () => {
|
||||
render(<Alert {...defaultProps} type="info" />)
|
||||
const gradientDiv = screen.getByTestId('alert-gradient')
|
||||
expect(gradientDiv).toHaveClass('from-components-badge-status-light-normal-halo')
|
||||
})
|
||||
|
||||
it('should display the provided message text', () => {
|
||||
const msg = 'A different alert message'
|
||||
render(<Alert {...defaultProps} message={msg} />)
|
||||
expect(screen.getByText(msg)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onHide when close button is clicked', () => {
|
||||
const onHide = vi.fn()
|
||||
render(<Alert {...defaultProps} onHide={onHide} />)
|
||||
const closeButton = screen.getByTestId('close-icon')
|
||||
fireEvent.click(closeButton)
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onHide when other parts of the alert are clicked', () => {
|
||||
const onHide = vi.fn()
|
||||
render(<Alert {...defaultProps} onHide={onHide} />)
|
||||
fireEvent.click(screen.getByText(defaultProps.message))
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with an empty message string', () => {
|
||||
render(<Alert {...defaultProps} message="" />)
|
||||
const messageDiv = screen.getByTestId('msg-container')
|
||||
expect(messageDiv).toBeInTheDocument()
|
||||
expect(messageDiv).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should render with a very long message', () => {
|
||||
const longMessage = 'A'.repeat(1000)
|
||||
render(<Alert {...defaultProps} message={longMessage} />)
|
||||
expect(screen.getByText(longMessage)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiInformation2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import {
|
||||
memo,
|
||||
@@ -35,13 +31,13 @@ const Alert: React.FC<Props> = ({
|
||||
<div
|
||||
className="relative flex space-x-1 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg"
|
||||
>
|
||||
<div className={cn('pointer-events-none absolute inset-0 bg-gradient-to-r opacity-[0.4]', bgVariants({ type }))}>
|
||||
<div className={cn('pointer-events-none absolute inset-0 bg-gradient-to-r opacity-[0.4]', bgVariants({ type }))} data-testid="alert-gradient">
|
||||
</div>
|
||||
<div className="flex h-6 w-6 items-center justify-center">
|
||||
<RiInformation2Fill className="text-text-accent" />
|
||||
<span className="i-ri-information-2-fill text-text-accent" data-testid="info-icon" />
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
<div className="text-text-secondary system-xs-regular" data-testid="msg-container">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,7 +45,7 @@ const Alert: React.FC<Props> = ({
|
||||
className="pointer-events-auto flex h-6 w-6 cursor-pointer items-center justify-center"
|
||||
onClick={onHide}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" data-testid="close-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
237
web/app/components/base/app-icon-picker/ImageInput.spec.tsx
Normal file
237
web/app/components/base/app-icon-picker/ImageInput.spec.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ImageInput from './ImageInput'
|
||||
|
||||
const createObjectURLMock = vi.fn(() => 'blob:mock-url')
|
||||
const revokeObjectURLMock = vi.fn()
|
||||
const originalCreateObjectURL = globalThis.URL.createObjectURL
|
||||
const originalRevokeObjectURL = globalThis.URL.revokeObjectURL
|
||||
|
||||
const waitForCropperContainer = async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('container')).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
|
||||
const loadCropperImage = async () => {
|
||||
await waitForCropperContainer()
|
||||
const cropperImage = screen.getByTestId('container').querySelector('img')
|
||||
if (!cropperImage)
|
||||
throw new Error('Could not find cropper image')
|
||||
|
||||
fireEvent.load(cropperImage)
|
||||
}
|
||||
|
||||
describe('ImageInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
globalThis.URL.createObjectURL = createObjectURLMock
|
||||
globalThis.URL.revokeObjectURL = revokeObjectURLMock
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.URL.createObjectURL = originalCreateObjectURL
|
||||
globalThis.URL.revokeObjectURL = originalRevokeObjectURL
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render upload prompt when no image is selected', () => {
|
||||
render(<ImageInput />)
|
||||
|
||||
expect(screen.getByText(/drop.*here/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/browse/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/supported/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a hidden file input', () => {
|
||||
render(<ImageInput />)
|
||||
|
||||
const input = screen.getByTestId('image-input')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveClass('hidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<ImageInput className="my-custom-class" />)
|
||||
expect(container.firstChild).toHaveClass('my-custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should trigger file input click when browse button is clicked', () => {
|
||||
render(<ImageInput />)
|
||||
|
||||
const fileInput = screen.getByTestId('image-input')
|
||||
const clickSpy = vi.spyOn(fileInput, 'click')
|
||||
|
||||
fireEvent.click(screen.getByText(/browse/i))
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show Cropper when a static image file is selected', async () => {
|
||||
render(<ImageInput />)
|
||||
|
||||
const file = new File(['image-data'], 'photo.png', { type: 'image/png' })
|
||||
const input = screen.getByTestId('image-input')
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
|
||||
await waitForCropperContainer()
|
||||
|
||||
// Upload prompt should be gone
|
||||
expect(screen.queryByText(/browse/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onImageInput with cropped data when crop completes on static image', async () => {
|
||||
const onImageInput = vi.fn()
|
||||
render(<ImageInput onImageInput={onImageInput} />)
|
||||
|
||||
const file = new File(['image-data'], 'photo.png', { type: 'image/png' })
|
||||
const input = screen.getByTestId('image-input')
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
|
||||
await loadCropperImage()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onImageInput).toHaveBeenCalledWith(
|
||||
true,
|
||||
'blob:mock-url',
|
||||
expect.objectContaining({
|
||||
x: expect.any(Number),
|
||||
y: expect.any(Number),
|
||||
width: expect.any(Number),
|
||||
height: expect.any(Number),
|
||||
}),
|
||||
'photo.png',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show img tag and call onImageInput with isCropped=false for animated GIF', async () => {
|
||||
const onImageInput = vi.fn()
|
||||
render(<ImageInput onImageInput={onImageInput} />)
|
||||
|
||||
const gifBytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
|
||||
const file = new File([gifBytes], 'anim.gif', { type: 'image/gif' })
|
||||
const input = screen.getByTestId('image-input')
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
|
||||
await waitFor(() => {
|
||||
const img = screen.queryByTestId('animated-image') as HTMLImageElement
|
||||
expect(img).toBeInTheDocument()
|
||||
expect(img?.src).toContain('blob:mock-url')
|
||||
})
|
||||
|
||||
// Cropper should NOT be shown
|
||||
expect(screen.queryByTestId('container')).not.toBeInTheDocument()
|
||||
expect(onImageInput).toHaveBeenCalledWith(false, file)
|
||||
})
|
||||
|
||||
it('should not crash when file input has no files', () => {
|
||||
render(<ImageInput />)
|
||||
|
||||
const input = screen.getByTestId('image-input')
|
||||
fireEvent.change(input, { target: { files: null } })
|
||||
|
||||
// Should still show upload prompt
|
||||
expect(screen.getByText(/browse/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset file input value on click', () => {
|
||||
render(<ImageInput />)
|
||||
|
||||
const input = screen.getByTestId('image-input') as HTMLInputElement
|
||||
// Simulate previous value
|
||||
Object.defineProperty(input, 'value', { writable: true, value: 'old-file.png' })
|
||||
fireEvent.click(input)
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drag and Drop', () => {
|
||||
it('should apply active border class on drag enter', () => {
|
||||
render(<ImageInput />)
|
||||
|
||||
const dropZone = screen.getByText(/browse/i).closest('[class*="border-dashed"]') as HTMLElement
|
||||
|
||||
fireEvent.dragEnter(dropZone)
|
||||
expect(dropZone).toHaveClass('border-primary-600')
|
||||
})
|
||||
|
||||
it('should remove active border class on drag leave', () => {
|
||||
render(<ImageInput />)
|
||||
|
||||
const dropZone = screen.getByText(/browse/i).closest('[class*="border-dashed"]') as HTMLElement
|
||||
|
||||
fireEvent.dragEnter(dropZone)
|
||||
expect(dropZone).toHaveClass('border-primary-600')
|
||||
|
||||
fireEvent.dragLeave(dropZone)
|
||||
expect(dropZone).not.toHaveClass('border-primary-600')
|
||||
})
|
||||
|
||||
it('should show image after dropping a file', async () => {
|
||||
render(<ImageInput />)
|
||||
|
||||
const dropZone = screen.getByText(/browse/i).closest('[class*="border-dashed"]') as HTMLElement
|
||||
const file = new File(['image-data'], 'dropped.png', { type: 'image/png' })
|
||||
|
||||
fireEvent.drop(dropZone, {
|
||||
dataTransfer: { files: [file] },
|
||||
})
|
||||
|
||||
await waitForCropperContainer()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cleanup', () => {
|
||||
it('should call URL.revokeObjectURL on unmount when an image was set', async () => {
|
||||
const { unmount } = render(<ImageInput />)
|
||||
|
||||
const file = new File(['image-data'], 'photo.png', { type: 'image/png' })
|
||||
const input = screen.getByTestId('image-input')
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
|
||||
await waitForCropperContainer()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:mock-url')
|
||||
})
|
||||
|
||||
it('should not call URL.revokeObjectURL on unmount when no image was set', () => {
|
||||
const { unmount } = render(<ImageInput />)
|
||||
unmount()
|
||||
expect(revokeObjectURLMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should not crash when onImageInput is not provided', async () => {
|
||||
render(<ImageInput />)
|
||||
|
||||
const file = new File(['image-data'], 'photo.png', { type: 'image/png' })
|
||||
const input = screen.getByTestId('image-input')
|
||||
|
||||
// Should not throw
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
|
||||
await loadCropperImage()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cropper')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should accept the correct file extensions', () => {
|
||||
render(<ImageInput />)
|
||||
|
||||
const input = screen.getByTestId('image-input') as HTMLInputElement
|
||||
expect(input.accept).toContain('.png')
|
||||
expect(input.accept).toContain('.jpg')
|
||||
expect(input.accept).toContain('.jpeg')
|
||||
expect(input.accept).toContain('.webp')
|
||||
expect(input.accept).toContain('.gif')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -72,7 +72,8 @@ const ImageInput: FC<UploaderProps> = ({
|
||||
const handleShowImage = () => {
|
||||
if (isAnimatedImage) {
|
||||
return (
|
||||
<img src={inputImage?.url} alt="" />
|
||||
// eslint-disable-next-line next/no-img-element
|
||||
<img src={inputImage?.url} alt="" data-testid="animated-image" />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -107,7 +108,7 @@ const ImageInput: FC<UploaderProps> = ({
|
||||
<div className="mb-[2px] text-sm font-medium">
|
||||
<span className="pointer-events-none">
|
||||
{t('imageInput.dropImageHere', { ns: 'common' })}
|
||||
|
||||
|
||||
</span>
|
||||
<button type="button" className="text-components-button-primary-bg" onClick={() => inputRef.current?.click()}>{t('imageInput.browse', { ns: 'common' })}</button>
|
||||
<input
|
||||
@@ -117,6 +118,7 @@ const ImageInput: FC<UploaderProps> = ({
|
||||
onClick={e => ((e.target as HTMLInputElement).value = '')}
|
||||
accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')}
|
||||
onChange={handleLocalFileInput}
|
||||
data-testid="image-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none">{t('imageInput.supportedFormats', { ns: 'common' })}</div>
|
||||
|
||||
120
web/app/components/base/app-icon-picker/hooks.spec.tsx
Normal file
120
web/app/components/base/app-icon-picker/hooks.spec.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useDraggableUploader } from './hooks'
|
||||
|
||||
type MockDragEventOverrides = {
|
||||
dataTransfer?: { files: File[] }
|
||||
}
|
||||
|
||||
const createDragEvent = (overrides: MockDragEventOverrides = {}): React.DragEvent<HTMLDivElement> => ({
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer: { files: [] as unknown as FileList },
|
||||
...overrides,
|
||||
} as unknown as React.DragEvent<HTMLDivElement>)
|
||||
|
||||
describe('useDraggableUploader', () => {
|
||||
let setImageFn: ReturnType<typeof vi.fn<(file: File) => void>>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setImageFn = vi.fn<(file: File) => void>()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should return all expected handler functions and isDragActive state', () => {
|
||||
const { result } = renderHook(() => useDraggableUploader(setImageFn))
|
||||
|
||||
expect(result.current.handleDragEnter).toBeInstanceOf(Function)
|
||||
expect(result.current.handleDragOver).toBeInstanceOf(Function)
|
||||
expect(result.current.handleDragLeave).toBeInstanceOf(Function)
|
||||
expect(result.current.handleDrop).toBeInstanceOf(Function)
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drag Events', () => {
|
||||
it('should set isDragActive to true on drag enter', () => {
|
||||
const { result } = renderHook(() => useDraggableUploader(setImageFn))
|
||||
const event = createDragEvent()
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragEnter(event)
|
||||
})
|
||||
|
||||
expect(result.current.isDragActive).toBe(true)
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(event.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call preventDefault and stopPropagation on drag over without changing isDragActive', () => {
|
||||
const { result } = renderHook(() => useDraggableUploader(setImageFn))
|
||||
const event = createDragEvent()
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragOver(event)
|
||||
})
|
||||
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(event.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set isDragActive to false on drag leave', () => {
|
||||
const { result } = renderHook(() => useDraggableUploader(setImageFn))
|
||||
const enterEvent = createDragEvent()
|
||||
const leaveEvent = createDragEvent()
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragEnter(enterEvent)
|
||||
})
|
||||
expect(result.current.isDragActive).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragLeave(leaveEvent)
|
||||
})
|
||||
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
expect(leaveEvent.preventDefault).toHaveBeenCalled()
|
||||
expect(leaveEvent.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drop', () => {
|
||||
it('should call setImageFn with the dropped file and set isDragActive to false', () => {
|
||||
const { result } = renderHook(() => useDraggableUploader(setImageFn))
|
||||
const file = new File(['test'], 'image.png', { type: 'image/png' })
|
||||
const event = createDragEvent({
|
||||
dataTransfer: { files: [file] },
|
||||
})
|
||||
|
||||
// First set isDragActive to true
|
||||
act(() => {
|
||||
result.current.handleDragEnter(createDragEvent())
|
||||
})
|
||||
expect(result.current.isDragActive).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.handleDrop(event)
|
||||
})
|
||||
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
expect(setImageFn).toHaveBeenCalledWith(file)
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(event.stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call setImageFn when no file is dropped', () => {
|
||||
const { result } = renderHook(() => useDraggableUploader(setImageFn))
|
||||
const event = createDragEvent({
|
||||
dataTransfer: { files: [] },
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleDrop(event)
|
||||
})
|
||||
|
||||
expect(setImageFn).not.toHaveBeenCalled()
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
339
web/app/components/base/app-icon-picker/index.spec.tsx
Normal file
339
web/app/components/base/app-icon-picker/index.spec.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import type { Area } from 'react-easy-crop'
|
||||
import type { ImageFile } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import AppIconPicker from './index'
|
||||
import 'vitest-canvas-mock'
|
||||
|
||||
type LocalFileUploaderOptions = {
|
||||
disabled?: boolean
|
||||
limit?: number
|
||||
onUpload: (imageFile: ImageFile) => void
|
||||
}
|
||||
|
||||
class MockLoadedImage {
|
||||
width = 320
|
||||
height = 160
|
||||
private listeners: Record<string, EventListener[]> = {}
|
||||
|
||||
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
|
||||
const eventListener = typeof listener === 'function' ? listener : listener.handleEvent.bind(listener)
|
||||
if (!this.listeners[type])
|
||||
this.listeners[type] = []
|
||||
this.listeners[type].push(eventListener)
|
||||
}
|
||||
|
||||
setAttribute(_name: string, _value: string) { }
|
||||
|
||||
set src(_value: string) {
|
||||
queueMicrotask(() => {
|
||||
for (const listener of this.listeners.load ?? [])
|
||||
listener(new Event('load'))
|
||||
})
|
||||
}
|
||||
|
||||
get src() {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const createImageFile = (overrides: Partial<ImageFile> = {}): ImageFile => ({
|
||||
type: TransferMethod.local_file,
|
||||
_id: 'test-image-id',
|
||||
fileId: 'uploaded-image-id',
|
||||
progress: 100,
|
||||
url: 'https://example.com/uploaded.png',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createCanvasContextMock = (): CanvasRenderingContext2D =>
|
||||
({
|
||||
translate: vi.fn(),
|
||||
rotate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
}) as unknown as CanvasRenderingContext2D
|
||||
|
||||
const createCanvasElementMock = (context: CanvasRenderingContext2D | null, blob: Blob | null = new Blob(['ok'], { type: 'image/png' })) =>
|
||||
({
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn(() => context),
|
||||
toBlob: vi.fn((callback: BlobCallback) => callback(blob)),
|
||||
}) as unknown as HTMLCanvasElement
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
disableUpload: false,
|
||||
uploadResult: null as ImageFile | null,
|
||||
onUpload: null as ((imageFile: ImageFile) => void) | null,
|
||||
handleLocalFileUpload: vi.fn<(file: File) => void>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get DISABLE_UPLOAD_IMAGE_AS_ICON() {
|
||||
return mocks.disableUpload
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('react-easy-crop', () => ({
|
||||
default: ({ onCropComplete }: { onCropComplete: (_area: Area, croppedAreaPixels: Area) => void }) => (
|
||||
<div data-testid="mock-cropper">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="trigger-crop"
|
||||
onClick={() => onCropComplete(
|
||||
{ x: 0, y: 0, width: 100, height: 100 },
|
||||
{ x: 0, y: 0, width: 100, height: 100 },
|
||||
)}
|
||||
>
|
||||
Trigger Crop
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../image-uploader/hooks', () => ({
|
||||
useLocalFileUploader: (options: LocalFileUploaderOptions) => {
|
||||
mocks.onUpload = options.onUpload
|
||||
return { handleLocalFileUpload: mocks.handleLocalFileUpload }
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/emoji', () => ({
|
||||
searchEmoji: vi.fn().mockResolvedValue(['grinning', 'sunglasses']),
|
||||
}))
|
||||
|
||||
describe('AppIconPicker', () => {
|
||||
const originalCreateElement = document.createElement.bind(document)
|
||||
const originalCreateObjectURL = globalThis.URL.createObjectURL
|
||||
const originalRevokeObjectURL = globalThis.URL.revokeObjectURL
|
||||
let originalImage: typeof Image
|
||||
|
||||
const mockCanvasCreation = (canvases: HTMLCanvasElement[]) => {
|
||||
vi.spyOn(document, 'createElement').mockImplementation((...args: Parameters<Document['createElement']>) => {
|
||||
if (args[0] === 'canvas') {
|
||||
const nextCanvas = canvases.shift()
|
||||
if (!nextCanvas)
|
||||
throw new Error('Unexpected canvas creation')
|
||||
return nextCanvas as ReturnType<Document['createElement']>
|
||||
}
|
||||
return originalCreateElement(...args)
|
||||
})
|
||||
}
|
||||
|
||||
const renderPicker = () => {
|
||||
const onSelect = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
const { container } = render(<AppIconPicker onSelect={onSelect} onClose={onClose} />)
|
||||
|
||||
return { onSelect, onClose, container }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.disableUpload = false
|
||||
mocks.uploadResult = createImageFile()
|
||||
mocks.onUpload = null
|
||||
mocks.handleLocalFileUpload.mockImplementation(() => {
|
||||
if (mocks.uploadResult)
|
||||
mocks.onUpload?.(mocks.uploadResult)
|
||||
})
|
||||
|
||||
originalImage = globalThis.Image
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock-url')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.Image = originalImage
|
||||
globalThis.URL.createObjectURL = originalCreateObjectURL
|
||||
globalThis.URL.revokeObjectURL = originalRevokeObjectURL
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render emoji and image tabs when upload is enabled', async () => {
|
||||
renderPicker()
|
||||
|
||||
expect(await screen.findByText(/emoji/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/image/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/ok/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the image tab when upload is disabled', () => {
|
||||
mocks.disableUpload = true
|
||||
renderPicker()
|
||||
|
||||
expect(screen.queryByText(/image/i)).not.toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClose when cancel is clicked', async () => {
|
||||
const { onClose } = renderPicker()
|
||||
|
||||
await userEvent.click(screen.getByText(/cancel/i))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should switch between emoji and image tabs', async () => {
|
||||
renderPicker()
|
||||
|
||||
await userEvent.click(screen.getByText(/image/i))
|
||||
expect(screen.getByText(/drop.*here/i)).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByText(/emoji/i))
|
||||
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSelect with emoji data after emoji selection', async () => {
|
||||
const { onSelect } = renderPicker()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByTestId(/emoji-container-/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
const firstEmoji = screen.queryAllByTestId(/emoji-container-/i)[0]
|
||||
if (!firstEmoji)
|
||||
throw new Error('Could not find emoji option')
|
||||
|
||||
await userEvent.click(firstEmoji)
|
||||
await userEvent.click(screen.getByText(/ok/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'emoji',
|
||||
icon: expect.any(String),
|
||||
background: expect.any(String),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onSelect when no emoji has been selected', async () => {
|
||||
const { onSelect } = renderPicker()
|
||||
|
||||
await userEvent.click(screen.getByText(/ok/i))
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Image Upload', () => {
|
||||
it('should return early when image tab is active and no file has been selected', async () => {
|
||||
const { onSelect } = renderPicker()
|
||||
|
||||
await userEvent.click(screen.getByText(/image/i))
|
||||
await userEvent.click(screen.getByText(/ok/i))
|
||||
|
||||
expect(mocks.handleLocalFileUpload).not.toHaveBeenCalled()
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should upload cropped static image and emit selected image metadata', async () => {
|
||||
globalThis.Image = MockLoadedImage as unknown as typeof Image
|
||||
|
||||
const sourceCanvas = createCanvasElementMock(createCanvasContextMock())
|
||||
const croppedBlob = new Blob(['cropped-image'], { type: 'image/png' })
|
||||
const croppedCanvas = createCanvasElementMock(createCanvasContextMock(), croppedBlob)
|
||||
mockCanvasCreation([sourceCanvas, croppedCanvas])
|
||||
|
||||
const { onSelect } = renderPicker()
|
||||
await userEvent.click(screen.getByText(/image/i))
|
||||
|
||||
const input = screen.queryByTestId('image-input')
|
||||
if (!input)
|
||||
throw new Error('Could not find image input')
|
||||
|
||||
fireEvent.change(input, { target: { files: [new File(['png'], 'avatar.png', { type: 'image/png' })] } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mock-cropper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('trigger-crop'))
|
||||
await userEvent.click(screen.getByText(/ok/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.handleLocalFileUpload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const uploadedFile = mocks.handleLocalFileUpload.mock.calls[0][0]
|
||||
expect(uploadedFile).toBeInstanceOf(File)
|
||||
expect(uploadedFile.name).toBe('avatar.png')
|
||||
expect(uploadedFile.type).toBe('image/png')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
type: 'image',
|
||||
fileId: 'uploaded-image-id',
|
||||
url: 'https://example.com/uploaded.png',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should upload animated image directly without crop', async () => {
|
||||
const { onSelect } = renderPicker()
|
||||
await userEvent.click(screen.getByText(/image/i))
|
||||
|
||||
const gifBytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
|
||||
const gifFile = new File([gifBytes], 'animated.gif', { type: 'image/gif' })
|
||||
|
||||
const input = screen.queryByTestId('image-input')
|
||||
if (!input)
|
||||
throw new Error('Could not find image input')
|
||||
|
||||
fireEvent.change(input, { target: { files: [gifFile] } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('mock-cropper')).not.toBeInTheDocument()
|
||||
const preview = screen.queryByTestId('animated-image')
|
||||
expect(preview).toBeInTheDocument()
|
||||
expect(preview?.getAttribute('src')).toContain('blob:mock-url')
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText(/ok/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(gifFile)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
type: 'image',
|
||||
fileId: 'uploaded-image-id',
|
||||
url: 'https://example.com/uploaded.png',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call onSelect when upload callback returns image without fileId', async () => {
|
||||
mocks.uploadResult = createImageFile({ fileId: '' })
|
||||
const { onSelect } = renderPicker()
|
||||
await userEvent.click(screen.getByText(/image/i))
|
||||
|
||||
const gifBytes = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
|
||||
const gifFile = new File([gifBytes], 'no-file-id.gif', { type: 'image/gif' })
|
||||
|
||||
const input = screen.queryByTestId('image-input')
|
||||
if (!input)
|
||||
throw new Error('Could not find image input')
|
||||
|
||||
fireEvent.change(input, { target: { files: [gifFile] } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('mock-cropper')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText(/ok/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(gifFile)
|
||||
})
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
364
web/app/components/base/app-icon-picker/utils.spec.ts
Normal file
364
web/app/components/base/app-icon-picker/utils.spec.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import getCroppedImg, { checkIsAnimatedImage, createImage, getMimeType, getRadianAngle, rotateSize } from './utils'
|
||||
|
||||
type ImageLoadEventType = 'load' | 'error'
|
||||
|
||||
class MockImageElement {
|
||||
static nextEvent: ImageLoadEventType = 'load'
|
||||
width = 320
|
||||
height = 160
|
||||
crossOriginValue = ''
|
||||
srcValue = ''
|
||||
private listeners: Record<string, EventListener[]> = {}
|
||||
|
||||
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
|
||||
const eventListener = typeof listener === 'function' ? listener : listener.handleEvent.bind(listener)
|
||||
if (!this.listeners[type])
|
||||
this.listeners[type] = []
|
||||
this.listeners[type].push(eventListener)
|
||||
}
|
||||
|
||||
setAttribute(name: string, value: string) {
|
||||
if (name === 'crossOrigin')
|
||||
this.crossOriginValue = value
|
||||
}
|
||||
|
||||
set src(value: string) {
|
||||
this.srcValue = value
|
||||
queueMicrotask(() => {
|
||||
const event = new Event(MockImageElement.nextEvent)
|
||||
for (const listener of this.listeners[MockImageElement.nextEvent] ?? [])
|
||||
listener(event)
|
||||
})
|
||||
}
|
||||
|
||||
get src() {
|
||||
return this.srcValue
|
||||
}
|
||||
}
|
||||
|
||||
type CanvasMock = {
|
||||
element: HTMLCanvasElement
|
||||
getContextMock: ReturnType<typeof vi.fn>
|
||||
toBlobMock: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const createCanvasMock = (context: CanvasRenderingContext2D | null, blob: Blob | null = new Blob(['ok'])): CanvasMock => {
|
||||
const getContextMock = vi.fn(() => context)
|
||||
const toBlobMock = vi.fn((callback: BlobCallback) => callback(blob))
|
||||
return {
|
||||
element: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: getContextMock,
|
||||
toBlob: toBlobMock,
|
||||
} as unknown as HTMLCanvasElement,
|
||||
getContextMock,
|
||||
toBlobMock,
|
||||
}
|
||||
}
|
||||
|
||||
const createCanvasContextMock = (): CanvasRenderingContext2D =>
|
||||
({
|
||||
translate: vi.fn(),
|
||||
rotate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
}) as unknown as CanvasRenderingContext2D
|
||||
|
||||
describe('utils', () => {
|
||||
const originalCreateElement = document.createElement.bind(document)
|
||||
let originalImage: typeof Image
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
originalImage = globalThis.Image
|
||||
MockImageElement.nextEvent = 'load'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.Image = originalImage
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const mockCanvasCreation = (canvases: HTMLCanvasElement[]) => {
|
||||
vi.spyOn(document, 'createElement').mockImplementation((...args: Parameters<Document['createElement']>) => {
|
||||
if (args[0] === 'canvas') {
|
||||
const nextCanvas = canvases.shift()
|
||||
if (!nextCanvas)
|
||||
throw new Error('Unexpected canvas creation')
|
||||
return nextCanvas as ReturnType<Document['createElement']>
|
||||
}
|
||||
return originalCreateElement(...args)
|
||||
})
|
||||
}
|
||||
|
||||
describe('createImage', () => {
|
||||
it('should resolve image when load event fires', async () => {
|
||||
globalThis.Image = MockImageElement as unknown as typeof Image
|
||||
|
||||
const image = await createImage('https://example.com/image.png')
|
||||
const mockImage = image as unknown as MockImageElement
|
||||
|
||||
expect(mockImage.crossOriginValue).toBe('anonymous')
|
||||
expect(mockImage.src).toBe('https://example.com/image.png')
|
||||
})
|
||||
|
||||
it('should reject when error event fires', async () => {
|
||||
globalThis.Image = MockImageElement as unknown as typeof Image
|
||||
MockImageElement.nextEvent = 'error'
|
||||
|
||||
await expect(createImage('https://example.com/broken.png')).rejects.toBeInstanceOf(Event)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMimeType', () => {
|
||||
it('should return image/png for .png files', () => {
|
||||
expect(getMimeType('photo.png')).toBe('image/png')
|
||||
})
|
||||
|
||||
it('should return image/jpeg for .jpg files', () => {
|
||||
expect(getMimeType('photo.jpg')).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
it('should return image/jpeg for .jpeg files', () => {
|
||||
expect(getMimeType('photo.jpeg')).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
it('should return image/gif for .gif files', () => {
|
||||
expect(getMimeType('animation.gif')).toBe('image/gif')
|
||||
})
|
||||
|
||||
it('should return image/webp for .webp files', () => {
|
||||
expect(getMimeType('photo.webp')).toBe('image/webp')
|
||||
})
|
||||
|
||||
it('should return image/jpeg as default for unknown extensions', () => {
|
||||
expect(getMimeType('file.bmp')).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
it('should return image/jpeg for files with no extension', () => {
|
||||
expect(getMimeType('file')).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
it('should handle uppercase extensions via toLowerCase', () => {
|
||||
expect(getMimeType('photo.PNG')).toBe('image/png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRadianAngle', () => {
|
||||
it('should return 0 for 0 degrees', () => {
|
||||
expect(getRadianAngle(0)).toBe(0)
|
||||
})
|
||||
|
||||
it('should return PI/2 for 90 degrees', () => {
|
||||
expect(getRadianAngle(90)).toBeCloseTo(Math.PI / 2)
|
||||
})
|
||||
|
||||
it('should return PI for 180 degrees', () => {
|
||||
expect(getRadianAngle(180)).toBeCloseTo(Math.PI)
|
||||
})
|
||||
|
||||
it('should return 2*PI for 360 degrees', () => {
|
||||
expect(getRadianAngle(360)).toBeCloseTo(2 * Math.PI)
|
||||
})
|
||||
|
||||
it('should handle negative angles', () => {
|
||||
expect(getRadianAngle(-90)).toBeCloseTo(-Math.PI / 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rotateSize', () => {
|
||||
it('should return same dimensions for 0 degree rotation', () => {
|
||||
const result = rotateSize(100, 200, 0)
|
||||
expect(result.width).toBeCloseTo(100)
|
||||
expect(result.height).toBeCloseTo(200)
|
||||
})
|
||||
|
||||
it('should swap dimensions for 90 degree rotation', () => {
|
||||
const result = rotateSize(100, 200, 90)
|
||||
expect(result.width).toBeCloseTo(200)
|
||||
expect(result.height).toBeCloseTo(100)
|
||||
})
|
||||
|
||||
it('should return same dimensions for 180 degree rotation', () => {
|
||||
const result = rotateSize(100, 200, 180)
|
||||
expect(result.width).toBeCloseTo(100)
|
||||
expect(result.height).toBeCloseTo(200)
|
||||
})
|
||||
|
||||
it('should handle square dimensions', () => {
|
||||
const result = rotateSize(100, 100, 45)
|
||||
// 45° rotation of a square produces a larger bounding box
|
||||
const expected = Math.abs(Math.cos(Math.PI / 4) * 100) + Math.abs(Math.sin(Math.PI / 4) * 100)
|
||||
expect(result.width).toBeCloseTo(expected)
|
||||
expect(result.height).toBeCloseTo(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCroppedImg', () => {
|
||||
it('should return a blob when canvas operations succeed', async () => {
|
||||
globalThis.Image = MockImageElement as unknown as typeof Image
|
||||
|
||||
const sourceContext = createCanvasContextMock()
|
||||
const croppedContext = createCanvasContextMock()
|
||||
const sourceCanvas = createCanvasMock(sourceContext)
|
||||
const expectedBlob = new Blob(['cropped'], { type: 'image/webp' })
|
||||
const croppedCanvas = createCanvasMock(croppedContext, expectedBlob)
|
||||
mockCanvasCreation([sourceCanvas.element, croppedCanvas.element])
|
||||
|
||||
const result = await getCroppedImg(
|
||||
'https://example.com/image.webp',
|
||||
{ x: 10, y: 20, width: 50, height: 40 },
|
||||
'avatar.webp',
|
||||
90,
|
||||
{ horizontal: true, vertical: false },
|
||||
)
|
||||
|
||||
expect(result).toBe(expectedBlob)
|
||||
expect(croppedCanvas.toBlobMock).toHaveBeenCalledWith(expect.any(Function), 'image/webp')
|
||||
expect(sourceContext.translate).toHaveBeenCalled()
|
||||
expect(sourceContext.rotate).toHaveBeenCalled()
|
||||
expect(sourceContext.scale).toHaveBeenCalledWith(-1, 1)
|
||||
expect(croppedContext.drawImage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply vertical flip when vertical option is true', async () => {
|
||||
globalThis.Image = MockImageElement as unknown as typeof Image
|
||||
|
||||
const sourceContext = createCanvasContextMock()
|
||||
const croppedContext = createCanvasContextMock()
|
||||
const sourceCanvas = createCanvasMock(sourceContext)
|
||||
const croppedCanvas = createCanvasMock(croppedContext)
|
||||
mockCanvasCreation([sourceCanvas.element, croppedCanvas.element])
|
||||
|
||||
await getCroppedImg(
|
||||
'https://example.com/image.png',
|
||||
{ x: 0, y: 0, width: 20, height: 20 },
|
||||
'avatar.png',
|
||||
0,
|
||||
{ horizontal: false, vertical: true },
|
||||
)
|
||||
|
||||
expect(sourceContext.scale).toHaveBeenCalledWith(1, -1)
|
||||
})
|
||||
|
||||
it('should throw when source canvas context is unavailable', async () => {
|
||||
globalThis.Image = MockImageElement as unknown as typeof Image
|
||||
|
||||
const sourceCanvas = createCanvasMock(null)
|
||||
mockCanvasCreation([sourceCanvas.element])
|
||||
|
||||
await expect(
|
||||
getCroppedImg('https://example.com/image.png', { x: 0, y: 0, width: 10, height: 10 }, 'avatar.png'),
|
||||
).rejects.toThrow('Could not create a canvas context')
|
||||
})
|
||||
|
||||
it('should throw when cropped canvas context is unavailable', async () => {
|
||||
globalThis.Image = MockImageElement as unknown as typeof Image
|
||||
|
||||
const sourceCanvas = createCanvasMock(createCanvasContextMock())
|
||||
const croppedCanvas = createCanvasMock(null)
|
||||
mockCanvasCreation([sourceCanvas.element, croppedCanvas.element])
|
||||
|
||||
await expect(
|
||||
getCroppedImg('https://example.com/image.png', { x: 0, y: 0, width: 10, height: 10 }, 'avatar.png'),
|
||||
).rejects.toThrow('Could not create a canvas context')
|
||||
})
|
||||
|
||||
it('should reject when blob creation fails', async () => {
|
||||
globalThis.Image = MockImageElement as unknown as typeof Image
|
||||
|
||||
const sourceCanvas = createCanvasMock(createCanvasContextMock())
|
||||
const croppedCanvas = createCanvasMock(createCanvasContextMock(), null)
|
||||
mockCanvasCreation([sourceCanvas.element, croppedCanvas.element])
|
||||
|
||||
await expect(
|
||||
getCroppedImg('https://example.com/image.jpg', { x: 0, y: 0, width: 10, height: 10 }, 'avatar.jpg'),
|
||||
).rejects.toThrow('Could not create a blob')
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkIsAnimatedImage', () => {
|
||||
let originalFileReader: typeof FileReader
|
||||
beforeEach(() => {
|
||||
originalFileReader = globalThis.FileReader
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.FileReader = originalFileReader
|
||||
})
|
||||
it('should return true for .gif files', async () => {
|
||||
const gifFile = new File([new Uint8Array([0x47, 0x49, 0x46])], 'animation.gif', { type: 'image/gif' })
|
||||
const result = await checkIsAnimatedImage(gifFile)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for non-gif, non-webp files', async () => {
|
||||
const pngFile = new File([new Uint8Array([0x89, 0x50, 0x4E, 0x47])], 'image.png', { type: 'image/png' })
|
||||
const result = await checkIsAnimatedImage(pngFile)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true for animated WebP files with ANIM chunk', async () => {
|
||||
// Build a minimal WebP header with ANIM chunk
|
||||
// RIFF....WEBP....ANIM
|
||||
const bytes = new Uint8Array(20)
|
||||
// RIFF signature
|
||||
bytes[0] = 0x52 // R
|
||||
bytes[1] = 0x49 // I
|
||||
bytes[2] = 0x46 // F
|
||||
bytes[3] = 0x46 // F
|
||||
// WEBP signature
|
||||
bytes[8] = 0x57 // W
|
||||
bytes[9] = 0x45 // E
|
||||
bytes[10] = 0x42 // B
|
||||
bytes[11] = 0x50 // P
|
||||
// ANIM chunk at offset 12
|
||||
bytes[12] = 0x41 // A
|
||||
bytes[13] = 0x4E // N
|
||||
bytes[14] = 0x49 // I
|
||||
bytes[15] = 0x4D // M
|
||||
|
||||
const webpFile = new File([bytes], 'animated.webp', { type: 'image/webp' })
|
||||
const result = await checkIsAnimatedImage(webpFile)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for static WebP files without ANIM chunk', async () => {
|
||||
const bytes = new Uint8Array(20)
|
||||
// RIFF signature
|
||||
bytes[0] = 0x52
|
||||
bytes[1] = 0x49
|
||||
bytes[2] = 0x46
|
||||
bytes[3] = 0x46
|
||||
// WEBP signature
|
||||
bytes[8] = 0x57
|
||||
bytes[9] = 0x45
|
||||
bytes[10] = 0x42
|
||||
bytes[11] = 0x50
|
||||
// No ANIM chunk
|
||||
|
||||
const webpFile = new File([bytes], 'static.webp', { type: 'image/webp' })
|
||||
const result = await checkIsAnimatedImage(webpFile)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject when FileReader encounters an error', async () => {
|
||||
const file = new File([], 'test.png', { type: 'image/png' })
|
||||
|
||||
globalThis.FileReader = class {
|
||||
onerror: ((error: ProgressEvent<FileReader>) => void) | null = null
|
||||
onload: ((event: ProgressEvent<FileReader>) => void) | null = null
|
||||
|
||||
readAsArrayBuffer(_blob: Blob) {
|
||||
const errorEvent = new ProgressEvent('error') as ProgressEvent<FileReader>
|
||||
setTimeout(() => {
|
||||
this.onerror?.(errorEvent)
|
||||
}, 0)
|
||||
}
|
||||
} as unknown as typeof FileReader
|
||||
|
||||
await expect(checkIsAnimatedImage(file)).rejects.toBeInstanceOf(ProgressEvent)
|
||||
})
|
||||
})
|
||||
})
|
||||
82
web/app/components/base/app-unavailable.spec.tsx
Normal file
82
web/app/components/base/app-unavailable.spec.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import AppUnavailable from './app-unavailable'
|
||||
|
||||
describe('AppUnavailable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<AppUnavailable />)
|
||||
expect(screen.getByText(/404/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the error code in a heading', () => {
|
||||
render(<AppUnavailable />)
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toHaveTextContent(/404/)
|
||||
})
|
||||
|
||||
it('should render the default unavailable message', () => {
|
||||
render(<AppUnavailable />)
|
||||
expect(screen.getByText(/unavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should display custom error code', () => {
|
||||
render(<AppUnavailable code={500} />)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('500')
|
||||
})
|
||||
|
||||
it('should accept string error code', () => {
|
||||
render(<AppUnavailable code="403" />)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('403')
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<AppUnavailable className="my-custom" />)
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv).toHaveClass('my-custom')
|
||||
})
|
||||
|
||||
it('should retain base classes when custom className is applied', () => {
|
||||
const { container } = render(<AppUnavailable className="my-custom" />)
|
||||
const outerDiv = container.firstChild as HTMLElement
|
||||
expect(outerDiv).toHaveClass('flex', 'h-screen', 'w-screen', 'items-center', 'justify-center')
|
||||
})
|
||||
|
||||
it('should display unknownReason when provided', () => {
|
||||
render(<AppUnavailable unknownReason="Custom error occurred" />)
|
||||
expect(screen.getByText(/Custom error occurred/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display unknown error translation when isUnknownReason is true', () => {
|
||||
render(<AppUnavailable isUnknownReason />)
|
||||
expect(screen.getByText(/share.common.appUnknownError/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prioritize unknownReason over isUnknownReason', () => {
|
||||
render(<AppUnavailable isUnknownReason unknownReason="My custom reason" />)
|
||||
expect(screen.getByText(/My custom reason/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show appUnavailable translation when isUnknownReason is false', () => {
|
||||
render(<AppUnavailable isUnknownReason={false} />)
|
||||
expect(screen.getByText(/share.common.appUnavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with code 0', () => {
|
||||
render(<AppUnavailable code={0} />)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('0')
|
||||
})
|
||||
|
||||
it('should render with an empty unknownReason and fall back to translation', () => {
|
||||
render(<AppUnavailable unknownReason="" />)
|
||||
expect(screen.getByText(/share.common.appUnavailable/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
49
web/app/components/base/audio-gallery/index.spec.tsx
Normal file
49
web/app/components/base/audio-gallery/index.spec.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
// AudioGallery.spec.tsx
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import AudioGallery from './index'
|
||||
|
||||
// Mock AudioPlayer so we only assert prop forwarding
|
||||
const audioPlayerMock = vi.fn()
|
||||
|
||||
vi.mock('./AudioPlayer', () => ({
|
||||
default: (props: { srcs: string[] }) => {
|
||||
audioPlayerMock(props)
|
||||
return <div data-testid="audio-player" />
|
||||
},
|
||||
}))
|
||||
|
||||
describe('AudioGallery', () => {
|
||||
afterEach(() => {
|
||||
audioPlayerMock.mockClear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('returns null when srcs array is empty', () => {
|
||||
const { container } = render(<AudioGallery srcs={[]} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(screen.queryByTestId('audio-player')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when all srcs are falsy', () => {
|
||||
const { container } = render(<AudioGallery srcs={['', '', '']} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(screen.queryByTestId('audio-player')).toBeNull()
|
||||
})
|
||||
|
||||
it('filters out falsy srcs and passes valid srcs to AudioPlayer', () => {
|
||||
render(<AudioGallery srcs={['a.mp3', '', 'b.mp3']} />)
|
||||
expect(screen.getByTestId('audio-player')).toBeInTheDocument()
|
||||
expect(audioPlayerMock).toHaveBeenCalledTimes(1)
|
||||
expect(audioPlayerMock).toHaveBeenCalledWith({ srcs: ['a.mp3', 'b.mp3'] })
|
||||
})
|
||||
|
||||
it('wraps AudioPlayer inside container with expected class', () => {
|
||||
const { container } = render(<AudioGallery srcs={['a.mp3']} />)
|
||||
const root = container.firstChild as HTMLElement
|
||||
expect(root).toBeTruthy()
|
||||
expect(root.className).toContain('my-3')
|
||||
})
|
||||
})
|
||||
201
web/app/components/base/auto-height-textarea/index.spec.tsx
Normal file
201
web/app/components/base/auto-height-textarea/index.spec.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { sleep } from '@/utils'
|
||||
import AutoHeightTextarea from './index'
|
||||
|
||||
vi.mock('@/utils', async () => {
|
||||
const actual = await vi.importActual('@/utils')
|
||||
return {
|
||||
...actual,
|
||||
sleep: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('AutoHeightTextarea', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} />)
|
||||
const textarea = container.querySelector('textarea')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with placeholder when value is empty', () => {
|
||||
render(<AutoHeightTextarea placeholder="Enter text" value="" onChange={vi.fn()} />)
|
||||
expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with value', () => {
|
||||
render(<AutoHeightTextarea value="Hello World" onChange={vi.fn()} />)
|
||||
const textarea = screen.getByDisplayValue('Hello World')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className to textarea', () => {
|
||||
const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} className="custom-class" />)
|
||||
const textarea = container.querySelector('textarea')
|
||||
expect(textarea).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should apply custom wrapperClassName to wrapper div', () => {
|
||||
const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} wrapperClassName="wrapper-class" />)
|
||||
const wrapper = container.querySelector('div.relative')
|
||||
expect(wrapper).toHaveClass('wrapper-class')
|
||||
})
|
||||
|
||||
it('should apply minHeight and maxHeight styles to hidden div', () => {
|
||||
const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} minHeight={50} maxHeight={200} />)
|
||||
const hiddenDiv = container.querySelector('div.invisible')
|
||||
expect(hiddenDiv).toHaveStyle({ minHeight: '50px', maxHeight: '200px' })
|
||||
})
|
||||
|
||||
it('should use default minHeight and maxHeight when not provided', () => {
|
||||
const { container } = render(<AutoHeightTextarea value="" onChange={vi.fn()} />)
|
||||
const hiddenDiv = container.querySelector('div.invisible')
|
||||
expect(hiddenDiv).toHaveStyle({ minHeight: '36px', maxHeight: '96px' })
|
||||
})
|
||||
|
||||
it('should set autoFocus on textarea', () => {
|
||||
const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus')
|
||||
render(<AutoHeightTextarea value="" onChange={vi.fn()} autoFocus />)
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
focusSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange when textarea value changes', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<AutoHeightTextarea value="" onChange={handleChange} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(textarea, { target: { value: 'new value' } })
|
||||
|
||||
expect(handleChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onKeyDown when key is pressed', () => {
|
||||
const handleKeyDown = vi.fn()
|
||||
render(<AutoHeightTextarea value="" onChange={vi.fn()} onKeyDown={handleKeyDown} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
|
||||
expect(handleKeyDown).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onKeyUp when key is released', () => {
|
||||
const handleKeyUp = vi.fn()
|
||||
render(<AutoHeightTextarea value="" onChange={vi.fn()} onKeyUp={handleKeyUp} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.keyUp(textarea, { key: 'Enter' })
|
||||
|
||||
expect(handleKeyUp).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty string value', () => {
|
||||
render(<AutoHeightTextarea value="" onChange={vi.fn()} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle whitespace-only value', () => {
|
||||
render(<AutoHeightTextarea value=" " onChange={vi.fn()} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveValue(' ')
|
||||
})
|
||||
|
||||
it('should handle very long text (>10000 chars)', () => {
|
||||
const longText = 'a'.repeat(10001)
|
||||
render(<AutoHeightTextarea value={longText} onChange={vi.fn()} />)
|
||||
const textarea = screen.getByDisplayValue(longText)
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle newlines in value', () => {
|
||||
const textWithNewlines = 'line1\nline2\nline3'
|
||||
render(<AutoHeightTextarea value={textWithNewlines} onChange={vi.fn()} />)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveValue(textWithNewlines)
|
||||
})
|
||||
|
||||
it('should handle special characters in value', () => {
|
||||
const specialChars = '!@#$%^&*()_+-=[]{}|;:,.<>?'
|
||||
render(<AutoHeightTextarea value={specialChars} onChange={vi.fn()} />)
|
||||
const textarea = screen.getByDisplayValue(specialChars)
|
||||
expect(textarea).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Ref forwarding', () => {
|
||||
it('should accept ref and allow focusing', () => {
|
||||
const ref = { current: null as HTMLTextAreaElement | null }
|
||||
render(<AutoHeightTextarea ref={ref as React.RefObject<HTMLTextAreaElement>} value="" onChange={vi.fn()} />)
|
||||
|
||||
expect(ref.current).not.toBeNull()
|
||||
expect(ref.current?.tagName).toBe('TEXTAREA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('controlFocus prop', () => {
|
||||
it('should call focus when controlFocus changes', () => {
|
||||
const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus')
|
||||
const { rerender } = render(<AutoHeightTextarea value="" onChange={vi.fn()} controlFocus={1} />)
|
||||
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<AutoHeightTextarea value="" onChange={vi.fn()} controlFocus={2} />)
|
||||
|
||||
expect(focusSpy).toHaveBeenCalledTimes(2)
|
||||
focusSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should retry focus recursively when ref is not ready during autoFocus', async () => {
|
||||
const delayedRef = {} as React.RefObject<HTMLTextAreaElement>
|
||||
let assignedNode: HTMLTextAreaElement | null = null
|
||||
let exposedNode: HTMLTextAreaElement | null = null
|
||||
|
||||
Object.defineProperty(delayedRef, 'current', {
|
||||
get: () => exposedNode,
|
||||
set: (value: HTMLTextAreaElement | null) => {
|
||||
assignedNode = value
|
||||
},
|
||||
})
|
||||
|
||||
const sleepMock = vi.mocked(sleep)
|
||||
let sleepCalls = 0
|
||||
sleepMock.mockImplementation(async () => {
|
||||
sleepCalls += 1
|
||||
if (sleepCalls === 2)
|
||||
exposedNode = assignedNode
|
||||
})
|
||||
|
||||
const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'focus')
|
||||
const setSelectionRangeSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'setSelectionRange')
|
||||
|
||||
render(<AutoHeightTextarea ref={delayedRef} value="" onChange={vi.fn()} autoFocus />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sleepMock).toHaveBeenCalledTimes(2)
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
expect(setSelectionRangeSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
focusSpy.mockRestore()
|
||||
setSelectionRangeSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('displayName', () => {
|
||||
it('should have displayName set', () => {
|
||||
expect(AutoHeightTextarea.displayName).toBe('AutoHeightTextarea')
|
||||
})
|
||||
})
|
||||
})
|
||||
86
web/app/components/base/badge.spec.tsx
Normal file
86
web/app/components/base/badge.spec.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Badge from './badge'
|
||||
|
||||
describe('Badge', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Badge text="beta" />)
|
||||
expect(screen.getByText(/beta/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with children instead of text', () => {
|
||||
render(<Badge><span>child content</span></Badge>)
|
||||
expect(screen.getByText(/child content/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with no text or children', () => {
|
||||
const { container } = render(<Badge />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveTextContent('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<Badge text="test" className="my-custom" />)
|
||||
const badge = container.firstChild as HTMLElement
|
||||
expect(badge).toHaveClass('my-custom')
|
||||
})
|
||||
|
||||
it('should retain base classes when custom className is applied', () => {
|
||||
const { container } = render(<Badge text="test" className="my-custom" />)
|
||||
const badge = container.firstChild as HTMLElement
|
||||
expect(badge).toHaveClass('relative', 'inline-flex', 'h-5', 'items-center')
|
||||
})
|
||||
|
||||
it('should apply uppercase class by default', () => {
|
||||
const { container } = render(<Badge text="test" />)
|
||||
const badge = container.firstChild as HTMLElement
|
||||
expect(badge).toHaveClass('system-2xs-medium-uppercase')
|
||||
})
|
||||
|
||||
it('should apply non-uppercase class when uppercase is false', () => {
|
||||
const { container } = render(<Badge text="test" uppercase={false} />)
|
||||
const badge = container.firstChild as HTMLElement
|
||||
expect(badge).toHaveClass('system-xs-medium')
|
||||
expect(badge).not.toHaveClass('system-2xs-medium-uppercase')
|
||||
})
|
||||
|
||||
it('should render red corner mark when hasRedCornerMark is true', () => {
|
||||
const { container } = render(<Badge text="test" hasRedCornerMark />)
|
||||
const mark = container.querySelector('.bg-components-badge-status-light-error-bg')
|
||||
expect(mark).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render red corner mark by default', () => {
|
||||
const { container } = render(<Badge text="test" />)
|
||||
const mark = container.querySelector('.bg-components-badge-status-light-error-bg')
|
||||
expect(mark).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prioritize children over text', () => {
|
||||
render(<Badge text="text content"><span>child wins</span></Badge>)
|
||||
expect(screen.getByText(/child wins/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/text content/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ReactNode as text prop', () => {
|
||||
render(<Badge text={<strong>bold badge</strong>} />)
|
||||
expect(screen.getByText(/bold badge/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render with empty string text', () => {
|
||||
const { container } = render(<Badge text="" />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveTextContent('')
|
||||
})
|
||||
|
||||
it('should render with hasRedCornerMark false explicitly', () => {
|
||||
const { container } = render(<Badge text="test" hasRedCornerMark={false} />)
|
||||
const mark = container.querySelector('.bg-components-badge-status-light-error-bg')
|
||||
expect(mark).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
226
web/app/components/base/block-input/index.spec.tsx
Normal file
226
web/app/components/base/block-input/index.spec.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import BlockInput, { getInputKeys } from './index'
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
checkKeys: vi.fn((_keys: string[]) => ({
|
||||
isValid: true,
|
||||
errorMessageKey: '',
|
||||
errorKey: '',
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('BlockInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(Toast, 'notify')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<BlockInput value="" />)
|
||||
const wrapper = screen.getByTestId('block-input')
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with initial value', () => {
|
||||
const { container } = render(<BlockInput value="Hello World" />)
|
||||
expect(container.textContent).toContain('Hello World')
|
||||
})
|
||||
|
||||
it('should render variable highlights', () => {
|
||||
render(<BlockInput value="Hello {{name}}" />)
|
||||
const nameElement = screen.getByText('name')
|
||||
expect(nameElement).toBeInTheDocument()
|
||||
expect(nameElement.parentElement).toHaveClass('text-primary-600')
|
||||
})
|
||||
|
||||
it('should render multiple variable highlights', () => {
|
||||
render(<BlockInput value="{{foo}} and {{bar}}" />)
|
||||
expect(screen.getByText('foo')).toBeInTheDocument()
|
||||
expect(screen.getByText('bar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display character count in footer when not readonly', () => {
|
||||
render(<BlockInput value="Hello" />)
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide footer in readonly mode', () => {
|
||||
render(<BlockInput value="Hello" readonly />)
|
||||
expect(screen.queryByText('5')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<BlockInput value="test" className="custom-class" />)
|
||||
const innerContent = screen.getByTestId('block-input-content')
|
||||
expect(innerContent).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should apply readonly prop with max height', () => {
|
||||
render(<BlockInput value="test" readonly />)
|
||||
const contentDiv = screen.getByTestId('block-input').firstChild as Element
|
||||
expect(contentDiv).toHaveClass('max-h-[180px]')
|
||||
})
|
||||
|
||||
it('should have default empty value', () => {
|
||||
render(<BlockInput value="" />)
|
||||
const contentDiv = screen.getByTestId('block-input')
|
||||
expect(contentDiv).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should enter edit mode when clicked', async () => {
|
||||
render(<BlockInput value="Hello" />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should update value when typing in edit mode', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { checkKeys } = await import('@/utils/var')
|
||||
; (checkKeys as ReturnType<typeof vi.fn>).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' })
|
||||
|
||||
render(<BlockInput value="Hello" onConfirm={onConfirm} />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: 'Hello World' } })
|
||||
|
||||
expect(textarea).toHaveValue('Hello World')
|
||||
})
|
||||
|
||||
it('should call onConfirm on value change with valid keys', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { checkKeys } = await import('@/utils/var')
|
||||
; (checkKeys as ReturnType<typeof vi.fn>).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' })
|
||||
|
||||
render(<BlockInput value="initial" onConfirm={onConfirm} />)
|
||||
|
||||
const contentArea = screen.getByText('initial')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '{{name}}' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalledWith('{{name}}', ['name'])
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast on value change with invalid keys', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const { checkKeys } = await import('@/utils/var');
|
||||
(checkKeys as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
isValid: false,
|
||||
errorMessageKey: 'invalidKey',
|
||||
errorKey: 'test_key',
|
||||
})
|
||||
|
||||
render(<BlockInput value="initial" onConfirm={onConfirm} />)
|
||||
|
||||
const contentArea = screen.getByText('initial')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
const textarea = await screen.findByRole('textbox')
|
||||
fireEvent.change(textarea, { target: { value: '{{invalid}}' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Toast.notify).toHaveBeenCalled()
|
||||
})
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not enter edit mode when readonly is true', () => {
|
||||
render(<BlockInput value="Hello" readonly />)
|
||||
|
||||
const contentArea = screen.getByText('Hello')
|
||||
fireEvent.click(contentArea)
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty string value', () => {
|
||||
const { container } = render(<BlockInput value="" />)
|
||||
expect(container.textContent).toBe('0')
|
||||
const span = screen.getByTestId('block-input').querySelector('span')
|
||||
expect(span).toBeInTheDocument()
|
||||
expect(span).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should handle value without variables', () => {
|
||||
render(<BlockInput value="plain text" />)
|
||||
expect(screen.getByText('plain text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle newlines in value', () => {
|
||||
render(<BlockInput value="line1\nline2" />)
|
||||
expect(screen.getByText(/line1/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple same variables', () => {
|
||||
render(<BlockInput value="{{name}} and {{name}}" />)
|
||||
const highlights = screen.getAllByText('name')
|
||||
expect(highlights).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle value with only variables', () => {
|
||||
render(<BlockInput value="{{foo}}{{bar}}{{baz}}" />)
|
||||
expect(screen.getByText('foo')).toBeInTheDocument()
|
||||
expect(screen.getByText('bar')).toBeInTheDocument()
|
||||
expect(screen.getByText('baz')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle text adjacent to variables', () => {
|
||||
render(<BlockInput value="prefix {{var}} suffix" />)
|
||||
expect(screen.getByText(/prefix/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/suffix/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputKeys', () => {
|
||||
it('should extract keys from {{}} syntax', () => {
|
||||
const keys = getInputKeys('Hello {{name}}')
|
||||
expect(keys).toEqual(['name'])
|
||||
})
|
||||
|
||||
it('should extract multiple keys', () => {
|
||||
const keys = getInputKeys('{{foo}} and {{bar}}')
|
||||
expect(keys).toEqual(['foo', 'bar'])
|
||||
})
|
||||
|
||||
it('should remove duplicate keys', () => {
|
||||
const keys = getInputKeys('{{name}} and {{name}}')
|
||||
expect(keys).toEqual(['name'])
|
||||
})
|
||||
|
||||
it('should return empty array for no variables', () => {
|
||||
const keys = getInputKeys('plain text')
|
||||
expect(keys).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array for empty string', () => {
|
||||
const keys = getInputKeys('')
|
||||
expect(keys).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle keys with underscores and numbers', () => {
|
||||
const keys = getInputKeys('{{user_1}} and {{user_2}}')
|
||||
expect(keys).toEqual(['user_1', 'user_2'])
|
||||
})
|
||||
})
|
||||
@@ -63,7 +63,7 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
}, [isEditing])
|
||||
|
||||
const style = cn({
|
||||
'block px-4 py-2 w-full h-full text-sm text-gray-900 outline-0 border-0 break-all': true,
|
||||
'block h-full w-full break-all border-0 px-4 py-2 text-sm text-gray-900 outline-0': true,
|
||||
'block-input--editing': isEditing,
|
||||
})
|
||||
|
||||
@@ -111,7 +111,7 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
// Prevent rerendering caused cursor to jump to the start of the contentEditable element
|
||||
const TextAreaContentView = () => {
|
||||
return (
|
||||
<div className={cn(style, className)}>
|
||||
<div className={cn(style, className)} data-testid="block-input-content">
|
||||
{renderSafeContent(currentValue || '')}
|
||||
</div>
|
||||
)
|
||||
@@ -121,7 +121,7 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
const editAreaClassName = 'focus:outline-none bg-transparent text-sm'
|
||||
|
||||
const textAreaContent = (
|
||||
<div className={cn(readonly ? 'max-h-[180px] pb-5' : 'h-[180px]', ' overflow-y-auto')} onClick={() => !readonly && setIsEditing(true)}>
|
||||
<div className={cn(readonly ? 'max-h-[180px] pb-5' : 'h-[180px]', 'overflow-y-auto')} onClick={() => !readonly && setIsEditing(true)}>
|
||||
{isEditing
|
||||
? (
|
||||
<div className="h-full px-4 py-2">
|
||||
@@ -134,10 +134,10 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
onBlur={() => {
|
||||
blur()
|
||||
setIsEditing(false)
|
||||
// click confirm also make blur. Then outer value is change. So below code has problem.
|
||||
// setTimeout(() => {
|
||||
// handleCancel()
|
||||
// }, 1000)
|
||||
// click confirm also make blur. Then outer value is change. So below code has problem.
|
||||
// setTimeout(() => {
|
||||
// handleCancel()
|
||||
// }, 1000)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -147,7 +147,7 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('block-input w-full overflow-y-auto rounded-xl border-none bg-white')}>
|
||||
<div className={cn('block-input w-full overflow-y-auto rounded-xl border-none bg-white')} data-testid="block-input">
|
||||
{textAreaContent}
|
||||
{/* footer */}
|
||||
{!readonly && (
|
||||
|
||||
49
web/app/components/base/button/add-button.spec.tsx
Normal file
49
web/app/components/base/button/add-button.spec.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AddButton from './add-button'
|
||||
|
||||
describe('AddButton', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<AddButton onClick={vi.fn()} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render an add icon', () => {
|
||||
render(<AddButton onClick={vi.fn()} />)
|
||||
const iconSpan = screen.getByTestId('add-button').querySelector('span')
|
||||
expect(iconSpan).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<AddButton onClick={vi.fn()} className="my-custom" />)
|
||||
expect(container.firstChild).toHaveClass('my-custom')
|
||||
})
|
||||
|
||||
it('should retain base classes when custom className is applied', () => {
|
||||
const { container } = render(<AddButton onClick={vi.fn()} className="my-custom" />)
|
||||
expect(container.firstChild).toHaveClass('cursor-pointer')
|
||||
expect(container.firstChild).toHaveClass('rounded-md')
|
||||
expect(container.firstChild).toHaveClass('select-none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(<AddButton onClick={onClick} />)
|
||||
fireEvent.click(container.firstChild!)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClick multiple times on repeated clicks', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(<AddButton onClick={onClick} />)
|
||||
fireEvent.click(container.firstChild!)
|
||||
fireEvent.click(container.firstChild!)
|
||||
fireEvent.click(container.firstChild!)
|
||||
expect(onClick).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
@@ -14,8 +13,8 @@ const AddButton: FC<Props> = ({
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick}>
|
||||
<RiAddLine className="h-4 w-4 text-text-tertiary" />
|
||||
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick} data-testid="add-button">
|
||||
<span className="i-ri-add-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
52
web/app/components/base/button/sync-button.spec.tsx
Normal file
52
web/app/components/base/button/sync-button.spec.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SyncButton from './sync-button'
|
||||
|
||||
describe('SyncButton', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<SyncButton onClick={vi.fn()} />)
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a refresh icon', () => {
|
||||
render(<SyncButton onClick={vi.fn()} />)
|
||||
const iconSpan = screen.getByTestId('sync-button').querySelector('span')
|
||||
expect(iconSpan).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<SyncButton onClick={vi.fn()} className="my-custom" />)
|
||||
const clickableDiv = screen.getByTestId('sync-button')
|
||||
expect(clickableDiv).toHaveClass('my-custom')
|
||||
})
|
||||
|
||||
it('should retain base classes when custom className is applied', () => {
|
||||
render(<SyncButton onClick={vi.fn()} className="my-custom" />)
|
||||
const clickableDiv = screen.getByTestId('sync-button')
|
||||
expect(clickableDiv).toHaveClass('rounded-md')
|
||||
expect(clickableDiv).toHaveClass('select-none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClick when clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<SyncButton onClick={onClick} />)
|
||||
const clickableDiv = screen.getByTestId('sync-button')
|
||||
fireEvent.click(clickableDiv)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClick multiple times on repeated clicks', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<SyncButton onClick={onClick} />)
|
||||
const clickableDiv = screen.getByTestId('sync-button')
|
||||
fireEvent.click(clickableDiv)
|
||||
fireEvent.click(clickableDiv)
|
||||
fireEvent.click(clickableDiv)
|
||||
expect(onClick).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiRefreshLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import TooltipPlus from '@/app/components/base/tooltip'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@@ -18,8 +17,8 @@ const SyncButton: FC<Props> = ({
|
||||
}) => {
|
||||
return (
|
||||
<TooltipPlus popupContent={popupContent}>
|
||||
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick}>
|
||||
<RiRefreshLine className="h-4 w-4 text-text-tertiary" />
|
||||
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick} data-testid="sync-button">
|
||||
<span className="i-ri-refresh-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</TooltipPlus>
|
||||
)
|
||||
|
||||
231
web/app/components/base/carousel/index.spec.tsx
Normal file
231
web/app/components/base/carousel/index.spec.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import useEmblaCarousel from 'embla-carousel-react'
|
||||
import { Carousel, useCarousel } from './index'
|
||||
|
||||
vi.mock('embla-carousel-react', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
type EmblaEventName = 'reInit' | 'select'
|
||||
type EmblaListener = (api: MockEmblaApi | undefined) => void
|
||||
|
||||
type MockEmblaApi = {
|
||||
scrollPrev: Mock
|
||||
scrollNext: Mock
|
||||
scrollTo: Mock
|
||||
selectedScrollSnap: Mock
|
||||
canScrollPrev: Mock
|
||||
canScrollNext: Mock
|
||||
slideNodes: Mock
|
||||
on: Mock
|
||||
off: Mock
|
||||
}
|
||||
|
||||
let mockCanScrollPrev = false
|
||||
let mockCanScrollNext = false
|
||||
let mockSelectedIndex = 0
|
||||
let mockSlideCount = 3
|
||||
let listeners: Record<EmblaEventName, EmblaListener[]>
|
||||
let mockApi: MockEmblaApi
|
||||
const mockCarouselRef = vi.fn()
|
||||
|
||||
const mockedUseEmblaCarousel = vi.mocked(useEmblaCarousel)
|
||||
|
||||
const createMockEmblaApi = (): MockEmblaApi => ({
|
||||
scrollPrev: vi.fn(),
|
||||
scrollNext: vi.fn(),
|
||||
scrollTo: vi.fn(),
|
||||
selectedScrollSnap: vi.fn(() => mockSelectedIndex),
|
||||
canScrollPrev: vi.fn(() => mockCanScrollPrev),
|
||||
canScrollNext: vi.fn(() => mockCanScrollNext),
|
||||
slideNodes: vi.fn(() =>
|
||||
Array.from({ length: mockSlideCount }, () => document.createElement('div')),
|
||||
),
|
||||
on: vi.fn((event: EmblaEventName, callback: EmblaListener) => {
|
||||
listeners[event].push(callback)
|
||||
}),
|
||||
off: vi.fn((event: EmblaEventName, callback: EmblaListener) => {
|
||||
listeners[event] = listeners[event].filter(listener => listener !== callback)
|
||||
}),
|
||||
})
|
||||
|
||||
const emitEmblaEvent = (event: EmblaEventName, api: MockEmblaApi | undefined = mockApi) => {
|
||||
listeners[event].forEach((callback) => {
|
||||
callback(api)
|
||||
})
|
||||
}
|
||||
|
||||
const renderCarouselWithControls = (orientation: 'horizontal' | 'vertical' = 'horizontal') => {
|
||||
return render(
|
||||
<Carousel orientation={orientation}>
|
||||
<Carousel.Content data-testid="carousel-content">
|
||||
<Carousel.Item>Slide 1</Carousel.Item>
|
||||
<Carousel.Item>Slide 2</Carousel.Item>
|
||||
<Carousel.Item>Slide 3</Carousel.Item>
|
||||
</Carousel.Content>
|
||||
<Carousel.Previous>Prev</Carousel.Previous>
|
||||
<Carousel.Next>Next</Carousel.Next>
|
||||
<Carousel.Dot>Dot</Carousel.Dot>
|
||||
</Carousel>,
|
||||
)
|
||||
}
|
||||
|
||||
const mockPlugin = () => ({
|
||||
name: 'mock',
|
||||
options: {},
|
||||
init: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
})
|
||||
|
||||
describe('Carousel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanScrollPrev = false
|
||||
mockCanScrollNext = false
|
||||
mockSelectedIndex = 0
|
||||
mockSlideCount = 3
|
||||
listeners = { reInit: [], select: [] }
|
||||
mockApi = createMockEmblaApi()
|
||||
|
||||
mockedUseEmblaCarousel.mockReturnValue(
|
||||
[mockCarouselRef, mockApi] as unknown as ReturnType<typeof useEmblaCarousel>,
|
||||
)
|
||||
})
|
||||
|
||||
// Rendering and basic semantic structure.
|
||||
describe('Rendering', () => {
|
||||
it('should render region and slides when used with content and items', () => {
|
||||
renderCarouselWithControls()
|
||||
|
||||
expect(screen.getByRole('region')).toHaveAttribute('aria-roledescription', 'carousel')
|
||||
expect(screen.getByTestId('carousel-content')).toHaveClass('flex')
|
||||
screen.getAllByRole('group').forEach((slide) => {
|
||||
expect(slide).toHaveAttribute('aria-roledescription', 'slide')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Props should be translated into Embla options and visible layout.
|
||||
describe('Props', () => {
|
||||
it('should configure embla with horizontal axis when orientation is omitted', () => {
|
||||
const plugin = mockPlugin()
|
||||
render(
|
||||
<Carousel opts={{ loop: true }} plugins={[plugin]}>
|
||||
<Carousel.Content />
|
||||
</Carousel>,
|
||||
)
|
||||
|
||||
expect(mockedUseEmblaCarousel).toHaveBeenCalledWith(
|
||||
{ loop: true, axis: 'x' },
|
||||
[plugin],
|
||||
)
|
||||
})
|
||||
|
||||
it('should configure embla with vertical axis and vertical content classes when orientation is vertical', () => {
|
||||
renderCarouselWithControls('vertical')
|
||||
|
||||
expect(mockedUseEmblaCarousel).toHaveBeenCalledWith(
|
||||
{ axis: 'y' },
|
||||
undefined,
|
||||
)
|
||||
expect(screen.getByTestId('carousel-content')).toHaveClass('flex-col')
|
||||
})
|
||||
})
|
||||
|
||||
// Users can move slides through previous and next controls.
|
||||
describe('User interactions', () => {
|
||||
it('should call scroll handlers when previous and next buttons are clicked', () => {
|
||||
mockCanScrollPrev = true
|
||||
mockCanScrollNext = true
|
||||
|
||||
renderCarouselWithControls()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Prev' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }))
|
||||
|
||||
expect(mockApi.scrollPrev).toHaveBeenCalledTimes(1)
|
||||
expect(mockApi.scrollNext).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call scrollTo with clicked index when a dot is clicked', () => {
|
||||
renderCarouselWithControls()
|
||||
const dots = screen.getAllByRole('button', { name: 'Dot' })
|
||||
|
||||
fireEvent.click(dots[2])
|
||||
|
||||
expect(mockApi.scrollTo).toHaveBeenCalledWith(2)
|
||||
})
|
||||
})
|
||||
|
||||
// Embla events should keep control states and selected index in sync.
|
||||
describe('State synchronization', () => {
|
||||
it('should update disabled states and active dot when select event is emitted', () => {
|
||||
renderCarouselWithControls()
|
||||
|
||||
mockCanScrollPrev = true
|
||||
mockCanScrollNext = true
|
||||
mockSelectedIndex = 2
|
||||
|
||||
act(() => {
|
||||
emitEmblaEvent('select')
|
||||
})
|
||||
|
||||
const dots = screen.getAllByRole('button', { name: 'Dot' })
|
||||
expect(screen.getByRole('button', { name: 'Prev' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled()
|
||||
expect(dots[2]).toHaveAttribute('data-state', 'active')
|
||||
})
|
||||
|
||||
it('should subscribe to embla events and unsubscribe from select on unmount', () => {
|
||||
const { unmount } = renderCarouselWithControls()
|
||||
|
||||
const selectCallback = mockApi.on.mock.calls.find(
|
||||
call => call[0] === 'select',
|
||||
)?.[1] as EmblaListener
|
||||
|
||||
expect(mockApi.on).toHaveBeenCalledWith('reInit', expect.any(Function))
|
||||
expect(mockApi.on).toHaveBeenCalledWith('select', expect.any(Function))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockApi.off).toHaveBeenCalledWith('select', selectCallback)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge-case behavior for missing providers or missing embla api values.
|
||||
describe('Edge cases', () => {
|
||||
it('should throw when useCarousel is used outside Carousel provider', () => {
|
||||
const InvalidConsumer = () => {
|
||||
useCarousel()
|
||||
return null
|
||||
}
|
||||
|
||||
expect(() => render(<InvalidConsumer />)).toThrowError(
|
||||
'useCarousel must be used within a <Carousel />',
|
||||
)
|
||||
})
|
||||
|
||||
it('should render with disabled controls and no dots when embla api is undefined', () => {
|
||||
mockedUseEmblaCarousel.mockReturnValue(
|
||||
[mockCarouselRef, undefined] as unknown as ReturnType<typeof useEmblaCarousel>,
|
||||
)
|
||||
|
||||
renderCarouselWithControls()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Prev' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: 'Dot' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore select callback when embla emits an undefined api', () => {
|
||||
renderCarouselWithControls()
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
emitEmblaEvent('select', undefined)
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
114
web/app/components/base/chat/chat/answer/agent-content.spec.tsx
Normal file
114
web/app/components/base/chat/chat/answer/agent-content.spec.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { ChatItem } from '../../types'
|
||||
import type { IThoughtProps } from '@/app/components/base/chat/chat/thought'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { MarkdownProps } from '@/app/components/base/markdown'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import AgentContent from './agent-content'
|
||||
|
||||
// Mock Markdown component used only in tests
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: (props: MarkdownProps & { 'data-testid'?: string }) => (
|
||||
<div data-testid={props['data-testid'] || 'markdown'} data-content={String(props.content)} className={props.className}>
|
||||
{String(props.content)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock Thought
|
||||
vi.mock('@/app/components/base/chat/chat/thought', () => ({
|
||||
default: ({ thought, isFinished }: IThoughtProps) => (
|
||||
<div data-testid="thought-component" data-finished={isFinished}>
|
||||
{thought.thought}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock FileList and Utils
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileList: ({ files }: { files: FileEntity[] }) => (
|
||||
<div data-testid="file-list-component">
|
||||
{files.map(f => f.name).join(', ')}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader/utils', () => ({
|
||||
getProcessedFilesFromResponse: (files: FileEntity[]) => files.map(f => ({ ...f, name: `processed-${f.id}` })),
|
||||
}))
|
||||
|
||||
describe('AgentContent', () => {
|
||||
const mockItem: ChatItem = {
|
||||
id: '1',
|
||||
content: '',
|
||||
isAnswer: true,
|
||||
}
|
||||
|
||||
it('renders logAnnotation if present', () => {
|
||||
const itemWithAnnotation = {
|
||||
...mockItem,
|
||||
annotation: {
|
||||
logAnnotation: { content: 'Log Annotation Content' },
|
||||
},
|
||||
}
|
||||
render(<AgentContent item={itemWithAnnotation as ChatItem} />)
|
||||
expect(screen.getByTestId('agent-content-markdown')).toHaveTextContent('Log Annotation Content')
|
||||
})
|
||||
|
||||
it('renders content prop if provided and no annotation', () => {
|
||||
render(<AgentContent item={mockItem} content="Direct Content" />)
|
||||
expect(screen.getByTestId('agent-content-markdown')).toHaveTextContent('Direct Content')
|
||||
})
|
||||
|
||||
it('renders agent_thoughts if content is absent', () => {
|
||||
const itemWithThoughts = {
|
||||
...mockItem,
|
||||
agent_thoughts: [
|
||||
{ thought: 'Thought 1', tool: 'tool1' },
|
||||
{ thought: 'Thought 2' },
|
||||
],
|
||||
}
|
||||
render(<AgentContent item={itemWithThoughts as ChatItem} responding={false} />)
|
||||
const items = screen.getAllByTestId('agent-thought-item')
|
||||
expect(items).toHaveLength(2)
|
||||
const thoughtMarkdowns = screen.getAllByTestId('agent-thought-markdown')
|
||||
expect(thoughtMarkdowns[0]).toHaveTextContent('Thought 1')
|
||||
expect(thoughtMarkdowns[1]).toHaveTextContent('Thought 2')
|
||||
expect(screen.getByTestId('thought-component')).toHaveTextContent('Thought 1')
|
||||
})
|
||||
|
||||
it('passes correct isFinished to Thought component', () => {
|
||||
const itemWithThoughts = {
|
||||
...mockItem,
|
||||
agent_thoughts: [
|
||||
{ thought: 'T1', tool: 'tool1', observation: 'obs1' }, // finished by observation
|
||||
{ thought: 'T2', tool: 'tool2' }, // finished by responding=false
|
||||
],
|
||||
}
|
||||
const { rerender } = render(<AgentContent item={itemWithThoughts as ChatItem} responding={true} />)
|
||||
const thoughts = screen.getAllByTestId('thought-component')
|
||||
expect(thoughts[0]).toHaveAttribute('data-finished', 'true')
|
||||
expect(thoughts[1]).toHaveAttribute('data-finished', 'false')
|
||||
|
||||
rerender(<AgentContent item={itemWithThoughts as ChatItem} responding={false} />)
|
||||
expect(screen.getAllByTestId('thought-component')[1]).toHaveAttribute('data-finished', 'true')
|
||||
})
|
||||
|
||||
it('renders FileList if thought has message_files', () => {
|
||||
const itemWithFiles = {
|
||||
...mockItem,
|
||||
agent_thoughts: [
|
||||
{
|
||||
thought: 'T1',
|
||||
message_files: [{ id: 'file1' }, { id: 'file2' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
render(<AgentContent item={itemWithFiles as ChatItem} />)
|
||||
expect(screen.getByTestId('file-list-component')).toHaveTextContent('processed-file1, processed-file2')
|
||||
})
|
||||
|
||||
it('renders nothing if no annotation, content, or thoughts', () => {
|
||||
render(<AgentContent item={mockItem} />)
|
||||
expect(screen.getByTestId('agent-content-container')).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -23,15 +23,29 @@ const AgentContent: FC<AgentContentProps> = ({
|
||||
agent_thoughts,
|
||||
} = item
|
||||
|
||||
if (annotation?.logAnnotation)
|
||||
return <Markdown content={annotation?.logAnnotation.content || ''} />
|
||||
if (annotation?.logAnnotation) {
|
||||
return (
|
||||
<Markdown
|
||||
content={annotation?.logAnnotation.content || ''}
|
||||
data-testid="agent-content-markdown"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{content ? <Markdown content={content} /> : agent_thoughts?.map((thought, index) => (
|
||||
<div key={index} className="px-2 py-1">
|
||||
<div data-testid="agent-content-container">
|
||||
{content ? (
|
||||
<Markdown
|
||||
content={content}
|
||||
data-testid="agent-content-markdown"
|
||||
/>
|
||||
) : agent_thoughts?.map((thought, index) => (
|
||||
<div key={index} className="px-2 py-1" data-testid="agent-thought-item">
|
||||
{thought.thought && (
|
||||
<Markdown content={thought.thought} />
|
||||
<Markdown
|
||||
content={thought.thought}
|
||||
data-testid="agent-thought-markdown"
|
||||
/>
|
||||
)}
|
||||
{/* {item.tool} */}
|
||||
{/* perhaps not use tool */}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ChatItem } from '../../types'
|
||||
import type { MarkdownProps } from '@/app/components/base/markdown'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import BasicContent from './basic-content'
|
||||
|
||||
// Mock Markdown component used only in tests
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: ({ content, className }: MarkdownProps) => (
|
||||
<div data-testid="basic-content-markdown" data-content={String(content)} className={className}>
|
||||
{String(content)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('BasicContent', () => {
|
||||
const mockItem = {
|
||||
id: '1',
|
||||
content: 'Hello World',
|
||||
isAnswer: true,
|
||||
}
|
||||
|
||||
it('renders content correctly', () => {
|
||||
render(<BasicContent item={mockItem as ChatItem} />)
|
||||
const markdown = screen.getByTestId('basic-content-markdown')
|
||||
expect(markdown).toHaveAttribute('data-content', 'Hello World')
|
||||
})
|
||||
|
||||
it('renders logAnnotation content if present', () => {
|
||||
const itemWithAnnotation = {
|
||||
...mockItem,
|
||||
annotation: {
|
||||
logAnnotation: {
|
||||
content: 'Annotated Content',
|
||||
},
|
||||
},
|
||||
}
|
||||
render(<BasicContent item={itemWithAnnotation as ChatItem} />)
|
||||
const markdown = screen.getByTestId('basic-content-markdown')
|
||||
expect(markdown).toHaveAttribute('data-content', 'Annotated Content')
|
||||
})
|
||||
|
||||
it('wraps Windows UNC paths in backticks', () => {
|
||||
const itemWithUNC = {
|
||||
...mockItem,
|
||||
content: '\\\\server\\share\\file.txt',
|
||||
}
|
||||
render(<BasicContent item={itemWithUNC as ChatItem} />)
|
||||
const markdown = screen.getByTestId('basic-content-markdown')
|
||||
expect(markdown).toHaveAttribute('data-content', '`\\\\server\\share\\file.txt`')
|
||||
})
|
||||
|
||||
it('does not wrap content in backticks if it already is', () => {
|
||||
const itemWithBackticks = {
|
||||
...mockItem,
|
||||
content: '`\\\\server\\share\\file.txt`',
|
||||
}
|
||||
render(<BasicContent item={itemWithBackticks as ChatItem} />)
|
||||
const markdown = screen.getByTestId('basic-content-markdown')
|
||||
expect(markdown).toHaveAttribute('data-content', '`\\\\server\\share\\file.txt`')
|
||||
})
|
||||
|
||||
it('does not wrap backslash strings that are not UNC paths', () => {
|
||||
const itemWithBackslashes = {
|
||||
...mockItem,
|
||||
content: '\\not-a-unc',
|
||||
}
|
||||
render(<BasicContent item={itemWithBackslashes as ChatItem} />)
|
||||
const markdown = screen.getByTestId('basic-content-markdown')
|
||||
expect(markdown).toHaveAttribute('data-content', '\\not-a-unc')
|
||||
})
|
||||
|
||||
it('applies error class when isError is true', () => {
|
||||
const errorItem = {
|
||||
...mockItem,
|
||||
isError: true,
|
||||
}
|
||||
render(<BasicContent item={errorItem as ChatItem} />)
|
||||
const markdown = screen.getByTestId('basic-content-markdown')
|
||||
expect(markdown).toHaveClass('!text-[#F04438]')
|
||||
})
|
||||
|
||||
it('renders non-string content without attempting to wrap (covers typeof !== "string" branch)', () => {
|
||||
const itemWithNonStringContent = {
|
||||
...mockItem,
|
||||
content: 12345,
|
||||
}
|
||||
render(<BasicContent item={itemWithNonStringContent as unknown as ChatItem} />)
|
||||
const markdown = screen.getByTestId('basic-content-markdown')
|
||||
expect(markdown).toHaveAttribute('data-content', '12345')
|
||||
})
|
||||
})
|
||||
@@ -15,8 +15,14 @@ const BasicContent: FC<BasicContentProps> = ({
|
||||
content,
|
||||
} = item
|
||||
|
||||
if (annotation?.logAnnotation)
|
||||
return <Markdown content={annotation?.logAnnotation.content || ''} />
|
||||
if (annotation?.logAnnotation) {
|
||||
return (
|
||||
<Markdown
|
||||
content={annotation?.logAnnotation.content || ''}
|
||||
data-testid="basic-content-markdown"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Preserve Windows UNC paths and similar backslash-heavy strings by
|
||||
// wrapping them in inline code so Markdown renders backslashes verbatim.
|
||||
@@ -31,6 +37,7 @@ const BasicContent: FC<BasicContentProps> = ({
|
||||
item.isError && '!text-[#F04438]',
|
||||
)}
|
||||
content={displayContent}
|
||||
data-testid="basic-content-markdown"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ContentItem from './content-item'
|
||||
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: ({ content }: { content: string }) => <div data-testid="mock-markdown">{content}</div>,
|
||||
}))
|
||||
|
||||
describe('ContentItem', () => {
|
||||
const mockOnInputChange = vi.fn()
|
||||
const mockFormInputFields: FormInputItem[] = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
output_variable_name: 'user_bio',
|
||||
default: {
|
||||
type: 'constant',
|
||||
value: '',
|
||||
selector: [],
|
||||
},
|
||||
} as FormInputItem,
|
||||
]
|
||||
const mockInputs = {
|
||||
user_bio: 'Initial bio',
|
||||
}
|
||||
|
||||
it('should render Markdown for literal content', () => {
|
||||
render(
|
||||
<ContentItem
|
||||
content="Hello world"
|
||||
formInputFields={[]}
|
||||
inputs={{}}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('mock-markdown')).toHaveTextContent('Hello world')
|
||||
expect(screen.queryByTestId('content-item-textarea')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render Textarea for valid output variable content', () => {
|
||||
render(
|
||||
<ContentItem
|
||||
content="{{#$output.user_bio#}}"
|
||||
formInputFields={mockFormInputFields}
|
||||
inputs={mockInputs}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByTestId('content-item-textarea')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
expect(textarea).toHaveValue('Initial bio')
|
||||
expect(screen.queryByTestId('mock-markdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onInputChange when textarea value changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<ContentItem
|
||||
content="{{#$output.user_bio#}}"
|
||||
formInputFields={mockFormInputFields}
|
||||
inputs={mockInputs}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByTestId('content-item-textarea')
|
||||
await user.type(textarea, 'x')
|
||||
|
||||
expect(mockOnInputChange).toHaveBeenCalledWith('user_bio', 'Initial biox')
|
||||
})
|
||||
|
||||
it('should render nothing if field name is valid but not found in formInputFields', () => {
|
||||
const { container } = render(
|
||||
<ContentItem
|
||||
content="{{#$output.unknown_field#}}"
|
||||
formInputFields={mockFormInputFields}
|
||||
inputs={mockInputs}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render nothing if input type is not supported', () => {
|
||||
const { container } = render(
|
||||
<ContentItem
|
||||
content="{{#$output.user_bio#}}"
|
||||
formInputFields={[
|
||||
{
|
||||
type: 'text-input',
|
||||
output_variable_name: 'user_bio',
|
||||
default: {
|
||||
type: 'constant',
|
||||
value: '',
|
||||
selector: [],
|
||||
},
|
||||
} as FormInputItem,
|
||||
]}
|
||||
inputs={mockInputs}
|
||||
onInputChange={mockOnInputChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('[data-testid="content-item-textarea"]')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.py-3')?.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -45,6 +45,7 @@ const ContentItem = ({
|
||||
className="h-[104px] sm:text-xs"
|
||||
value={inputs[fieldName]}
|
||||
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
|
||||
data-testid="content-item-textarea"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ContentWrapper from './content-wrapper'
|
||||
|
||||
describe('ContentWrapper', () => {
|
||||
const defaultProps = {
|
||||
nodeTitle: 'Human Input Node',
|
||||
children: <div data-testid="child-content">Child Content</div>,
|
||||
}
|
||||
|
||||
it('should render node title and children by default when not collapsible', () => {
|
||||
render(<ContentWrapper {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Human Input Node')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('child-content')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('expand-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show/hide content when toggling expansion', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ContentWrapper {...defaultProps} showExpandIcon={true} expanded={false} />)
|
||||
|
||||
// Initially collapsed
|
||||
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument()
|
||||
const expandToggle = screen.getByTestId('expand-icon')
|
||||
expect(expandToggle.querySelector('.i-ri-arrow-right-s-line')).toBeInTheDocument()
|
||||
|
||||
// Expand
|
||||
await user.click(expandToggle)
|
||||
expect(screen.getByTestId('child-content')).toBeInTheDocument()
|
||||
expect(expandToggle.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
|
||||
|
||||
// Collapse
|
||||
await user.click(expandToggle)
|
||||
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children initially if expanded is true', () => {
|
||||
render(<ContentWrapper {...defaultProps} showExpandIcon={true} expanded={true} />)
|
||||
|
||||
expect(screen.getByTestId('child-content')).toBeInTheDocument()
|
||||
const expandToggle = screen.getByTestId('expand-icon')
|
||||
expect(expandToggle.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
@@ -26,26 +25,33 @@ const ContentWrapper = ({
|
||||
}, [isExpanded])
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', className)}>
|
||||
<div
|
||||
className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', className)}
|
||||
data-testid="content-wrapper"
|
||||
>
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
{/* node icon */}
|
||||
<BlockIcon type={BlockEnum.HumanInput} className="shrink-0" />
|
||||
{/* node name */}
|
||||
<div
|
||||
className="system-sm-semibold-uppercase grow truncate text-text-primary"
|
||||
className="grow truncate text-text-primary system-sm-semibold-uppercase"
|
||||
title={nodeTitle}
|
||||
>
|
||||
{nodeTitle}
|
||||
</div>
|
||||
{showExpandIcon && (
|
||||
<div className="shrink-0 cursor-pointer" onClick={handleToggleExpand}>
|
||||
<div
|
||||
className="shrink-0 cursor-pointer"
|
||||
onClick={handleToggleExpand}
|
||||
data-testid="expand-icon"
|
||||
>
|
||||
{
|
||||
isExpanded
|
||||
? (
|
||||
<RiArrowDownSLine className="size-4" />
|
||||
<div className="i-ri-arrow-down-s-line size-4" />
|
||||
)
|
||||
: (
|
||||
<RiArrowRightSLine className="size-4" />
|
||||
<div className="i-ri-arrow-right-s-line size-4" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ExecutedAction from './executed-action'
|
||||
|
||||
describe('ExecutedAction', () => {
|
||||
it('should render the triggered action information', () => {
|
||||
const executedAction = {
|
||||
id: 'btn_1',
|
||||
title: 'Submit',
|
||||
}
|
||||
|
||||
render(<ExecutedAction executedAction={executedAction} />)
|
||||
|
||||
expect(screen.getByTestId('executed-action')).toBeInTheDocument()
|
||||
|
||||
// Trans component mock from i18n-mock.ts renders a span with data-i18n-key
|
||||
const trans = screen.getByTestId('executed-action').querySelector('span')
|
||||
expect(trans).toHaveAttribute('data-i18n-key', 'nodes.humanInput.userActions.triggered')
|
||||
|
||||
// Check for the trigger icon class
|
||||
expect(screen.getByTestId('executed-action').querySelector('.i-custom-vender-workflow-trigger-all')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,6 @@ import type { ExecutedAction as ExecutedActionType } from './type'
|
||||
import { memo } from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
|
||||
|
||||
type ExecutedActionProps = {
|
||||
executedAction: ExecutedActionType
|
||||
@@ -12,14 +11,14 @@ const ExecutedAction = ({
|
||||
executedAction,
|
||||
}: ExecutedActionProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1 py-1">
|
||||
<div className="flex flex-col gap-y-1 py-1" data-testid="executed-action">
|
||||
<Divider className="mb-2 mt-1 w-[30px]" />
|
||||
<div className="system-xs-regular flex items-center gap-x-1 text-text-tertiary">
|
||||
<TriggerAll className="size-3.5 shrink-0" />
|
||||
<div className="flex items-center gap-x-1 text-text-tertiary system-xs-regular">
|
||||
<div className="i-custom-vender-workflow-trigger-all size-3.5 shrink-0" />
|
||||
<Trans
|
||||
i18nKey="nodes.humanInput.userActions.triggered"
|
||||
ns="workflow"
|
||||
components={{ strong: <span className="system-xs-medium text-text-secondary"></span> }}
|
||||
components={{ strong: <span className="text-text-secondary system-xs-medium"></span> }}
|
||||
values={{ actionName: executedAction.id }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ExpirationTime from './expiration-time'
|
||||
import * as utils from './utils'
|
||||
|
||||
// Mock utils to control time-based logic
|
||||
vi.mock('./utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./utils')>()
|
||||
return {
|
||||
...actual,
|
||||
getRelativeTime: vi.fn(),
|
||||
isRelativeTimeSameOrAfter: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('ExpirationTime', () => {
|
||||
it('should render "Future" state with relative time', () => {
|
||||
vi.mocked(utils.getRelativeTime).mockReturnValue('in 2 hours')
|
||||
vi.mocked(utils.isRelativeTimeSameOrAfter).mockReturnValue(true)
|
||||
|
||||
const { container } = render(<ExpirationTime expirationTime={1234567890} />)
|
||||
|
||||
expect(screen.getByTestId('expiration-time')).toHaveClass('text-text-tertiary')
|
||||
expect(screen.getByText('share.humanInput.expirationTimeNowOrFuture:{"relativeTime":"in 2 hours"}')).toBeInTheDocument()
|
||||
expect(container.querySelector('.i-ri-time-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "Expired" state when time is in the past', () => {
|
||||
vi.mocked(utils.getRelativeTime).mockReturnValue('2 hours ago')
|
||||
vi.mocked(utils.isRelativeTimeSameOrAfter).mockReturnValue(false)
|
||||
|
||||
const { container } = render(<ExpirationTime expirationTime={1234567890} />)
|
||||
|
||||
expect(screen.getByTestId('expiration-time')).toHaveClass('text-text-warning')
|
||||
expect(screen.getByText('share.humanInput.expiredTip')).toBeInTheDocument()
|
||||
expect(container.querySelector('.i-ri-alert-fill')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
'use client'
|
||||
import { RiAlertFill, RiTimeLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@@ -19,8 +18,9 @@ const ExpirationTime = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="expiration-time"
|
||||
className={cn(
|
||||
'system-xs-regular mt-1 flex items-center gap-x-1 text-text-tertiary',
|
||||
'mt-1 flex items-center gap-x-1 text-text-tertiary system-xs-regular',
|
||||
!isSameOrAfter && 'text-text-warning',
|
||||
)}
|
||||
>
|
||||
@@ -28,13 +28,13 @@ const ExpirationTime = ({
|
||||
isSameOrAfter
|
||||
? (
|
||||
<>
|
||||
<RiTimeLine className="size-3.5" />
|
||||
<div className="i-ri-time-line size-3.5" />
|
||||
<span>{t('humanInput.expirationTimeNowOrFuture', { relativeTime, ns: 'share' })}</span>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<RiAlertFill className="size-3.5" />
|
||||
<div className="i-ri-alert-fill size-3.5" />
|
||||
<span>{t('humanInput.expiredTip', { ns: 'share' })}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import HumanInputForm from './human-input-form'
|
||||
|
||||
vi.mock('./content-item', () => ({
|
||||
default: ({ content, onInputChange }: { content: string, onInputChange: (name: string, value: string) => void }) => (
|
||||
<div data-testid="mock-content-item">
|
||||
{content}
|
||||
<button data-testid="update-input" onClick={() => onInputChange('field1', 'new value')}>Update</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('HumanInputForm', () => {
|
||||
const mockFormData: HumanInputFormData = {
|
||||
form_id: 'form_1',
|
||||
node_id: 'node_1',
|
||||
node_title: 'Title',
|
||||
display_in_ui: true,
|
||||
expiration_time: 0,
|
||||
form_token: 'token_123',
|
||||
form_content: 'Part 1 {{#$output.field1#}} Part 2',
|
||||
inputs: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
output_variable_name: 'field1',
|
||||
default: { type: 'constant', value: 'initial', selector: [] },
|
||||
} as FormInputItem,
|
||||
],
|
||||
actions: [
|
||||
{ id: 'action_1', title: 'Submit', button_style: UserActionButtonType.Primary },
|
||||
{ id: 'action_2', title: 'Cancel', button_style: UserActionButtonType.Default },
|
||||
{ id: 'action_3', title: 'Accent', button_style: UserActionButtonType.Accent },
|
||||
{ id: 'action_4', title: 'Ghost', button_style: UserActionButtonType.Ghost },
|
||||
],
|
||||
resolved_default_values: {},
|
||||
}
|
||||
|
||||
it('should render content parts and action buttons', () => {
|
||||
render(<HumanInputForm formData={mockFormData} />)
|
||||
|
||||
// splitByOutputVar should yield 3 parts: "Part 1 ", "{{#$output.field1#}}", " Part 2"
|
||||
const contentItems = screen.getAllByTestId('mock-content-item')
|
||||
expect(contentItems).toHaveLength(3)
|
||||
expect(contentItems[0]).toHaveTextContent('Part 1')
|
||||
expect(contentItems[1]).toHaveTextContent('{{#$output.field1#}}')
|
||||
expect(contentItems[2]).toHaveTextContent('Part 2')
|
||||
|
||||
const buttons = screen.getAllByTestId('action-button')
|
||||
expect(buttons).toHaveLength(4)
|
||||
expect(buttons[0]).toHaveTextContent('Submit')
|
||||
expect(buttons[1]).toHaveTextContent('Cancel')
|
||||
expect(buttons[2]).toHaveTextContent('Accent')
|
||||
expect(buttons[3]).toHaveTextContent('Ghost')
|
||||
})
|
||||
|
||||
it('should handle input changes and submit correctly', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
|
||||
render(<HumanInputForm formData={mockFormData} onSubmit={mockOnSubmit} />)
|
||||
|
||||
// Update input via mock ContentItem
|
||||
await user.click(screen.getAllByTestId('update-input')[0])
|
||||
|
||||
// Submit
|
||||
const submitButton = screen.getByRole('button', { name: 'Submit' })
|
||||
await user.click(submitButton)
|
||||
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith('token_123', {
|
||||
action: 'action_1',
|
||||
inputs: { field1: 'new value' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable buttons during submission', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveSubmit: (value: void | PromiseLike<void>) => void
|
||||
const submitPromise = new Promise<void>((resolve) => {
|
||||
resolveSubmit = resolve
|
||||
})
|
||||
const mockOnSubmit = vi.fn().mockReturnValue(submitPromise)
|
||||
|
||||
render(<HumanInputForm formData={mockFormData} onSubmit={mockOnSubmit} />)
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: 'Submit' })
|
||||
const cancelButton = screen.getByRole('button', { name: 'Cancel' })
|
||||
|
||||
await user.click(submitButton)
|
||||
|
||||
expect(submitButton).toBeDisabled()
|
||||
expect(cancelButton).toBeDisabled()
|
||||
|
||||
// Finish submission
|
||||
await act(async () => {
|
||||
resolveSubmit!(undefined)
|
||||
})
|
||||
|
||||
expect(submitButton).not.toBeDisabled()
|
||||
expect(cancelButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should handle missing resolved_default_values', () => {
|
||||
const formDataWithoutDefaults = { ...mockFormData, resolved_default_values: undefined }
|
||||
render(<HumanInputForm formData={formDataWithoutDefaults as unknown as HumanInputFormData} />)
|
||||
expect(screen.getAllByTestId('mock-content-item')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should handle unsupported input types in initializeInputs', () => {
|
||||
const formDataWithUnsupported = {
|
||||
...mockFormData,
|
||||
inputs: [
|
||||
{
|
||||
type: 'text-input',
|
||||
output_variable_name: 'field2',
|
||||
default: { type: 'variable', value: '', selector: [] },
|
||||
} as FormInputItem,
|
||||
{
|
||||
type: 'number',
|
||||
output_variable_name: 'field3',
|
||||
default: { type: 'constant', value: '0', selector: [] },
|
||||
} as FormInputItem,
|
||||
],
|
||||
resolved_default_values: { field2: 'default value' },
|
||||
}
|
||||
render(<HumanInputForm formData={formDataWithUnsupported} />)
|
||||
expect(screen.getAllByTestId('mock-content-item')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -49,6 +49,7 @@ const HumanInputForm = ({
|
||||
disabled={isSubmitting}
|
||||
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
|
||||
onClick={() => submit(formToken, action.id, inputs)}
|
||||
data-testid="action-button"
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import SubmittedContent from './submitted-content'
|
||||
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: ({ content }: { content: string }) => <div data-testid="mock-markdown">{content}</div>,
|
||||
}))
|
||||
|
||||
describe('SubmittedContent', () => {
|
||||
it('should render Markdown with the provided content', () => {
|
||||
const content = '## Test Content'
|
||||
render(<SubmittedContent content={content} />)
|
||||
|
||||
expect(screen.getByTestId('submitted-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('mock-markdown')).toHaveTextContent(content)
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,9 @@ const SubmittedContent = ({
|
||||
content,
|
||||
}: SubmittedContentProps) => {
|
||||
return (
|
||||
<Markdown content={content} />
|
||||
<div data-testid="submitted-content">
|
||||
<Markdown content={content} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { HumanInputFilledFormData } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SubmittedHumanInputContent } from './submitted'
|
||||
|
||||
vi.mock('@/app/components/base/markdown', () => ({
|
||||
Markdown: ({ content }: { content: string }) => <div data-testid="mock-markdown">{content}</div>,
|
||||
}))
|
||||
|
||||
describe('SubmittedHumanInputContent Integration', () => {
|
||||
const mockFormData: HumanInputFilledFormData = {
|
||||
rendered_content: 'Rendered **Markdown** content',
|
||||
action_id: 'btn_1',
|
||||
action_text: 'Submit Action',
|
||||
node_id: 'node_1',
|
||||
node_title: 'Node Title',
|
||||
}
|
||||
|
||||
it('should render both content and executed action', () => {
|
||||
render(<SubmittedHumanInputContent formData={mockFormData} />)
|
||||
|
||||
// Verify SubmittedContent rendering
|
||||
expect(screen.getByTestId('submitted-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('mock-markdown')).toHaveTextContent('Rendered **Markdown** content')
|
||||
|
||||
// Verify ExecutedAction rendering
|
||||
expect(screen.getByTestId('executed-action')).toBeInTheDocument()
|
||||
// Trans component for triggered action. The mock usually renders the key.
|
||||
expect(screen.getByText('nodes.humanInput.userActions.triggered')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import Tips from './tips'
|
||||
|
||||
// Mock AppContext's useSelector to control user profile data
|
||||
vi.mock('@/context/app-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/app-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useSelector: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Tips', () => {
|
||||
const mockEmail = 'test@example.com'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useSelector).mockImplementation((selector: (value: AppContextValue) => unknown) => {
|
||||
return selector({
|
||||
userProfile: {
|
||||
email: mockEmail,
|
||||
},
|
||||
} as AppContextValue)
|
||||
})
|
||||
})
|
||||
|
||||
it('should render email tip in normal mode', () => {
|
||||
render(
|
||||
<Tips
|
||||
showEmailTip={true}
|
||||
isEmailDebugMode={false}
|
||||
showDebugModeTip={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.common.humanInputEmailTip')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.humanInputEmailTipInDebugMode')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.common.humanInputWebappTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render email tip in debug mode', () => {
|
||||
render(
|
||||
<Tips
|
||||
showEmailTip={true}
|
||||
isEmailDebugMode={true}
|
||||
showDebugModeTip={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.humanInputEmailTipInDebugMode')).toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.common.humanInputEmailTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render debug mode tip', () => {
|
||||
render(
|
||||
<Tips
|
||||
showEmailTip={false}
|
||||
isEmailDebugMode={false}
|
||||
showDebugModeTip={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.common.humanInputWebappTip')).toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.common.humanInputEmailTip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when all flags are false', () => {
|
||||
const { container } = render(
|
||||
<Tips
|
||||
showEmailTip={false}
|
||||
isEmailDebugMode={false}
|
||||
showDebugModeTip={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('tips')).toBeEmptyDOMElement()
|
||||
// Divider is outside of tips container, but within the fragment
|
||||
expect(container.querySelector('.v-divider')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -20,12 +20,12 @@ const Tips = ({
|
||||
return (
|
||||
<>
|
||||
<Divider className="!my-2 w-[30px]" />
|
||||
<div className="space-y-1 pt-1">
|
||||
<div className="space-y-1 pt-1" data-testid="tips">
|
||||
{showEmailTip && !isEmailDebugMode && (
|
||||
<div className="system-xs-regular text-text-secondary">{t('common.humanInputEmailTip', { ns: 'workflow' })}</div>
|
||||
<div className="text-text-secondary system-xs-regular">{t('common.humanInputEmailTip', { ns: 'workflow' })}</div>
|
||||
)}
|
||||
{showEmailTip && isEmailDebugMode && (
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
<div className="text-text-secondary system-xs-regular">
|
||||
<Trans
|
||||
i18nKey="common.humanInputEmailTipInDebugMode"
|
||||
ns="workflow"
|
||||
@@ -34,7 +34,7 @@ const Tips = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showDebugModeTip && <div className="system-xs-medium text-text-warning">{t('common.humanInputWebappTip', { ns: 'workflow' })}</div>}
|
||||
{showDebugModeTip && <div className="text-text-warning system-xs-medium">{t('common.humanInputWebappTip', { ns: 'workflow' })}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { InputVarType } from '@/app/components/workflow/types'
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import { UnsubmittedHumanInputContent } from './unsubmitted'
|
||||
|
||||
// Mock AppContext's useSelector to control user profile data
|
||||
vi.mock('@/context/app-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/app-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useSelector: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('UnsubmittedHumanInputContent Integration', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Helper to create valid form data
|
||||
const createMockFormData = (overrides = {}): HumanInputFormData => ({
|
||||
form_id: 'form_123',
|
||||
node_id: 'node_456',
|
||||
node_title: 'Input Form',
|
||||
form_content: 'Fill this out: {{#$output.user_name#}}',
|
||||
inputs: [
|
||||
{
|
||||
type: 'paragraph' as InputVarType,
|
||||
output_variable_name: 'user_name',
|
||||
default: {
|
||||
type: 'constant',
|
||||
value: 'Default value',
|
||||
selector: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{ id: 'btn_1', title: 'Submit', button_style: UserActionButtonType.Primary },
|
||||
],
|
||||
form_token: 'token_123',
|
||||
resolved_default_values: {},
|
||||
expiration_time: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
|
||||
display_in_ui: true,
|
||||
...overrides,
|
||||
} as unknown as HumanInputFormData)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useSelector).mockImplementation((selector: (value: AppContextValue) => unknown) => {
|
||||
return selector({
|
||||
userProfile: {
|
||||
id: 'user_123',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
is_password_set: false,
|
||||
},
|
||||
} as AppContextValue)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render form, tips, and expiration time when all conditions met', () => {
|
||||
render(
|
||||
<UnsubmittedHumanInputContent
|
||||
formData={createMockFormData()}
|
||||
showEmailTip={true}
|
||||
showDebugModeTip={true}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Submit')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tips')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('expiration-time')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.common.humanInputWebappTip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide ExpirationTime when expiration_time is not a number', () => {
|
||||
const data = createMockFormData({ expiration_time: undefined })
|
||||
render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByTestId('expiration-time')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide Tips when both tip flags are false', () => {
|
||||
render(
|
||||
<UnsubmittedHumanInputContent
|
||||
formData={createMockFormData()}
|
||||
showEmailTip={false}
|
||||
showDebugModeTip={false}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('tips')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render different email tips based on debug mode', () => {
|
||||
const { rerender } = render(
|
||||
<UnsubmittedHumanInputContent
|
||||
formData={createMockFormData()}
|
||||
showEmailTip={true}
|
||||
isEmailDebugMode={false}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.common.humanInputEmailTip')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<UnsubmittedHumanInputContent
|
||||
formData={createMockFormData()}
|
||||
showEmailTip={true}
|
||||
isEmailDebugMode={true}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.humanInputEmailTipInDebugMode')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "Expired" state when expiration time is in the past', () => {
|
||||
const data = createMockFormData({ expiration_time: Math.floor(Date.now() / 1000) - 3600 })
|
||||
render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('share.humanInput.expiredTip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should update input values and call onSubmit', async () => {
|
||||
const handleSubmit = vi.fn().mockImplementation(() => Promise.resolve())
|
||||
const data = createMockFormData()
|
||||
|
||||
render(<UnsubmittedHumanInputContent formData={data} onSubmit={handleSubmit} />)
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.clear(textarea)
|
||||
await user.type(textarea, 'New Value')
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: 'Submit' })
|
||||
await user.click(submitBtn)
|
||||
|
||||
expect(handleSubmit).toHaveBeenCalledWith('token_123', {
|
||||
action: 'btn_1',
|
||||
inputs: { user_name: 'New Value' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle loading state during submission', async () => {
|
||||
let resolveSubmit: (value: void | PromiseLike<void>) => void
|
||||
const handleSubmit = vi.fn().mockImplementation(() => new Promise<void>((resolve) => {
|
||||
resolveSubmit = resolve
|
||||
}))
|
||||
const data = createMockFormData()
|
||||
|
||||
render(<UnsubmittedHumanInputContent formData={data} onSubmit={handleSubmit} />)
|
||||
|
||||
const submitBtn = screen.getByRole('button', { name: 'Submit' })
|
||||
await user.click(submitBtn)
|
||||
|
||||
expect(submitBtn).toBeDisabled()
|
||||
expect(handleSubmit).toHaveBeenCalled()
|
||||
|
||||
await waitFor(() => {
|
||||
resolveSubmit!()
|
||||
})
|
||||
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle missing resolved_default_values', () => {
|
||||
const data = createMockFormData({ resolved_default_values: undefined })
|
||||
render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByText('Submit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return null in ContentItem if field is not found', () => {
|
||||
const data = createMockFormData({
|
||||
form_content: '{{#$output.unknown_field#}}',
|
||||
inputs: [],
|
||||
})
|
||||
const { container } = render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
|
||||
// The form will be empty (except for buttons) because unknown_field is not in inputs
|
||||
expect(container.querySelector('textarea')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text-input type in initializeInputs correctly', () => {
|
||||
const data = createMockFormData({
|
||||
inputs: [
|
||||
{
|
||||
type: 'text-input',
|
||||
output_variable_name: 'var1',
|
||||
label: 'Var 1',
|
||||
required: true,
|
||||
default: { type: 'fixed', value: 'fixed_val' },
|
||||
},
|
||||
],
|
||||
})
|
||||
render(<UnsubmittedHumanInputContent formData={data} onSubmit={vi.fn()} />)
|
||||
// initializeInputs is tested indirectly here.
|
||||
// We can't easily assert the internal state of HumanInputForm, but we can verify it doesn't crash.
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { HumanInputFilledFormData } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import HumanInputFilledFormList from './human-input-filled-form-list'
|
||||
|
||||
/**
|
||||
* Type-safe factory.
|
||||
* Forces test data to match real interface.
|
||||
*/
|
||||
const createFormData = (
|
||||
overrides: Partial<HumanInputFilledFormData> = {},
|
||||
): HumanInputFilledFormData => ({
|
||||
node_id: 'node-1',
|
||||
node_title: 'Node Title',
|
||||
|
||||
// 👇 IMPORTANT
|
||||
// DO NOT guess properties like `inputs`
|
||||
// Only include fields that actually exist in your project type.
|
||||
// Leave everything else empty via spread.
|
||||
...overrides,
|
||||
} as HumanInputFilledFormData)
|
||||
|
||||
describe('HumanInputFilledFormList', () => {
|
||||
it('renders nothing when list is empty', () => {
|
||||
render(<HumanInputFilledFormList humanInputFilledFormDataList={[]} />)
|
||||
|
||||
expect(screen.queryByText('Node Title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders one form item', () => {
|
||||
const data = [createFormData()]
|
||||
|
||||
render(<HumanInputFilledFormList humanInputFilledFormDataList={data} />)
|
||||
|
||||
expect(screen.getByText('Node Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders multiple form items', () => {
|
||||
const data = [
|
||||
createFormData({ node_id: '1', node_title: 'First' }),
|
||||
createFormData({ node_id: '2', node_title: 'Second' }),
|
||||
]
|
||||
|
||||
render(<HumanInputFilledFormList humanInputFilledFormDataList={data} />)
|
||||
|
||||
expect(screen.getByText('First')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders wrapper container', () => {
|
||||
const { container } = render(
|
||||
<HumanInputFilledFormList humanInputFilledFormDataList={[createFormData()]} />,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toHaveClass('flex')
|
||||
expect(container.firstChild).toHaveClass('flex-col')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import HumanInputFormList from './human-input-form-list'
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./human-input-content/content-wrapper', () => ({
|
||||
default: ({ children, nodeTitle }: { children: React.ReactNode, nodeTitle: string }) => (
|
||||
<div data-testid="content-wrapper" data-nodetitle={nodeTitle}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./human-input-content/unsubmitted', () => ({
|
||||
UnsubmittedHumanInputContent: ({ showEmailTip, isEmailDebugMode, showDebugModeTip }: { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }) => (
|
||||
<div data-testid="unsubmitted-content">
|
||||
<span data-testid="email-tip">{showEmailTip ? 'true' : 'false'}</span>
|
||||
<span data-testid="email-debug">{isEmailDebugMode ? 'true' : 'false'}</span>
|
||||
<span data-testid="debug-tip">{showDebugModeTip ? 'true' : 'false'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('HumanInputFormList', () => {
|
||||
const mockFormData = [
|
||||
{
|
||||
form_id: 'form1',
|
||||
node_id: 'node1',
|
||||
node_title: 'Title 1',
|
||||
display_in_ui: true,
|
||||
},
|
||||
{
|
||||
form_id: 'form2',
|
||||
node_id: 'node2',
|
||||
node_title: 'Title 2',
|
||||
display_in_ui: false,
|
||||
},
|
||||
]
|
||||
|
||||
const mockGetNodeData = vi.fn()
|
||||
|
||||
it('should render empty list when no form data is provided', () => {
|
||||
render(<HumanInputFormList humanInputFormDataList={[]} />)
|
||||
expect(screen.getByTestId('human-input-form-list')).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render only items with display_in_ui set to true', () => {
|
||||
mockGetNodeData.mockReturnValue({
|
||||
data: {
|
||||
delivery_methods: [],
|
||||
},
|
||||
})
|
||||
render(
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={mockFormData as HumanInputFormData[]}
|
||||
getHumanInputNodeData={mockGetNodeData}
|
||||
/>,
|
||||
)
|
||||
const items = screen.getAllByTestId('human-input-form-item')
|
||||
expect(items).toHaveLength(1)
|
||||
expect(screen.getByTestId('content-wrapper')).toHaveAttribute('data-nodetitle', 'Title 1')
|
||||
})
|
||||
|
||||
describe('Delivery Methods Config', () => {
|
||||
it('should set default tips when node data is not found', () => {
|
||||
mockGetNodeData.mockReturnValue(undefined)
|
||||
render(
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
|
||||
getHumanInputNodeData={mockGetNodeData}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('email-tip')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('email-debug')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('debug-tip')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should set default tips when delivery_methods is empty', () => {
|
||||
mockGetNodeData.mockReturnValue({ data: { delivery_methods: [] } })
|
||||
render(
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
|
||||
getHumanInputNodeData={mockGetNodeData}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('email-tip')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('email-debug')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('debug-tip')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should show tips correctly based on delivery methods', () => {
|
||||
mockGetNodeData.mockReturnValue({
|
||||
data: {
|
||||
delivery_methods: [
|
||||
{ type: DeliveryMethodType.WebApp, enabled: true },
|
||||
{ type: DeliveryMethodType.Email, enabled: true, config: { debug_mode: true } },
|
||||
],
|
||||
},
|
||||
})
|
||||
render(
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
|
||||
getHumanInputNodeData={mockGetNodeData}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('email-tip')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('email-debug')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('debug-tip')).toHaveTextContent('false') // WebApp is enabled
|
||||
})
|
||||
|
||||
it('should show debug mode tip if WebApp is disabled', () => {
|
||||
mockGetNodeData.mockReturnValue({
|
||||
data: {
|
||||
delivery_methods: [
|
||||
{ type: DeliveryMethodType.WebApp, enabled: false },
|
||||
{ type: DeliveryMethodType.Email, enabled: false },
|
||||
],
|
||||
},
|
||||
})
|
||||
render(
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={[mockFormData[0]] as HumanInputFormData[]}
|
||||
getHumanInputNodeData={mockGetNodeData}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('email-tip')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('debug-tip')).toHaveTextContent('true')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -45,22 +45,28 @@ const HumanInputFormList = ({
|
||||
const filteredHumanInputFormDataList = humanInputFormDataList.filter(formData => formData.display_in_ui)
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-y-2">
|
||||
<div
|
||||
className="mt-2 flex flex-col gap-y-2"
|
||||
data-testid="human-input-form-list"
|
||||
>
|
||||
{
|
||||
filteredHumanInputFormDataList.map(formData => (
|
||||
<ContentWrapper
|
||||
<div
|
||||
key={formData.form_id}
|
||||
nodeTitle={formData.node_title}
|
||||
data-testid="human-input-form-item"
|
||||
>
|
||||
<UnsubmittedHumanInputContent
|
||||
key={formData.form_id}
|
||||
formData={formData}
|
||||
showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip}
|
||||
isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode}
|
||||
showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip}
|
||||
onSubmit={onHumanInputFormSubmit}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
<ContentWrapper
|
||||
nodeTitle={formData.node_title}
|
||||
>
|
||||
<UnsubmittedHumanInputContent
|
||||
formData={formData}
|
||||
showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip}
|
||||
isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode}
|
||||
showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip}
|
||||
onSubmit={onHumanInputFormSubmit}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
65
web/app/components/base/chat/chat/answer/more.spec.tsx
Normal file
65
web/app/components/base/chat/chat/answer/more.spec.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import More from './more'
|
||||
|
||||
describe('More', () => {
|
||||
const mockMoreData = {
|
||||
latency: 0.5,
|
||||
tokens: 100,
|
||||
tokens_per_second: 200,
|
||||
time: '2023-10-27 10:00:00',
|
||||
}
|
||||
|
||||
it('should render all details when all data is provided', () => {
|
||||
render(<More more={mockMoreData} />)
|
||||
|
||||
expect(screen.getByTestId('more-container')).toBeInTheDocument()
|
||||
|
||||
// Check latency
|
||||
expect(screen.getByTestId('more-latency')).toBeInTheDocument()
|
||||
expect(screen.getByText(/timeConsuming/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/0.5/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/second/i)).toBeInTheDocument()
|
||||
|
||||
// Check tokens
|
||||
expect(screen.getByTestId('more-tokens')).toBeInTheDocument()
|
||||
expect(screen.getByText(/tokenCost/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/100/)).toBeInTheDocument()
|
||||
|
||||
// Check tokens per second
|
||||
expect(screen.getByTestId('more-tps')).toBeInTheDocument()
|
||||
expect(screen.getByText(/200 tokens\/s/i)).toBeInTheDocument()
|
||||
|
||||
// Check time
|
||||
expect(screen.getByTestId('more-time')).toBeInTheDocument()
|
||||
expect(screen.getByText('2023-10-27 10:00:00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render tokens per second when it is missing', () => {
|
||||
const dataWithoutTPS = { ...mockMoreData, tokens_per_second: 0 }
|
||||
render(<More more={dataWithoutTPS} />)
|
||||
|
||||
expect(screen.queryByTestId('more-tps')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing inside container if more prop is missing', () => {
|
||||
render(<More more={undefined} />)
|
||||
const containerDiv = screen.getByTestId('more-container')
|
||||
expect(containerDiv).toBeInTheDocument()
|
||||
expect(containerDiv.children.length).toBe(0)
|
||||
})
|
||||
|
||||
it('should apply group-hover opacity classes', () => {
|
||||
render(<More more={mockMoreData} />)
|
||||
const container = screen.getByTestId('more-container')
|
||||
expect(container).toHaveClass('opacity-0')
|
||||
expect(container).toHaveClass('group-hover:opacity-100')
|
||||
})
|
||||
|
||||
it('should correctly format large token counts', () => {
|
||||
const dataWithLargeTokens = { ...mockMoreData, tokens: 1234567 }
|
||||
render(<More more={dataWithLargeTokens} />)
|
||||
|
||||
// formatNumber(1234567) should return '1,234,567'
|
||||
expect(screen.getByText(/1,234,567/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -13,19 +13,24 @@ const More: FC<MoreProps> = ({
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="system-xs-regular mt-1 flex items-center text-text-quaternary opacity-0 group-hover:opacity-100">
|
||||
<div
|
||||
className="mt-1 flex items-center text-text-quaternary opacity-0 system-xs-regular group-hover:opacity-100"
|
||||
data-testid="more-container"
|
||||
>
|
||||
{
|
||||
more && (
|
||||
<>
|
||||
<div
|
||||
className="mr-2 max-w-[25%] shrink-0 truncate"
|
||||
title={`${t('detail.timeConsuming', { ns: 'appLog' })} ${more.latency}${t('detail.second', { ns: 'appLog' })}`}
|
||||
data-testid="more-latency"
|
||||
>
|
||||
{`${t('detail.timeConsuming', { ns: 'appLog' })} ${more.latency}${t('detail.second', { ns: 'appLog' })}`}
|
||||
</div>
|
||||
<div
|
||||
className="mr-2 max-w-[25%] shrink-0 truncate"
|
||||
title={`${t('detail.tokenCost', { ns: 'appLog' })} ${formatNumber(more.tokens)}`}
|
||||
data-testid="more-tokens"
|
||||
>
|
||||
{`${t('detail.tokenCost', { ns: 'appLog' })} ${formatNumber(more.tokens)}`}
|
||||
</div>
|
||||
@@ -33,6 +38,7 @@ const More: FC<MoreProps> = ({
|
||||
<div
|
||||
className="mr-2 max-w-[25%] shrink-0 truncate"
|
||||
title={`${more.tokens_per_second} tokens/s`}
|
||||
data-testid="more-tps"
|
||||
>
|
||||
{`${more.tokens_per_second} tokens/s`}
|
||||
</div>
|
||||
@@ -41,6 +47,7 @@ const More: FC<MoreProps> = ({
|
||||
<div
|
||||
className="max-w-[25%] shrink-0 truncate"
|
||||
title={more.time}
|
||||
data-testid="more-time"
|
||||
>
|
||||
{more.time}
|
||||
</div>
|
||||
|
||||
726
web/app/components/base/chat/chat/answer/operation.spec.tsx
Normal file
726
web/app/components/base/chat/chat/answer/operation.spec.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
import type { ChatConfig, ChatItem } from '../../types'
|
||||
import type { ChatContextValue } from '../context'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import Operation from './operation'
|
||||
|
||||
const {
|
||||
mockSetShowAnnotationFullModal,
|
||||
mockProviderContext,
|
||||
mockT,
|
||||
mockAddAnnotation,
|
||||
} = vi.hoisted(() => {
|
||||
return {
|
||||
mockAddAnnotation: vi.fn(),
|
||||
mockSetShowAnnotationFullModal: vi.fn(),
|
||||
mockT: vi.fn((key: string): string => key),
|
||||
mockProviderContext: {
|
||||
plan: {
|
||||
usage: { annotatedResponse: 0 },
|
||||
total: { annotatedResponse: 100 },
|
||||
},
|
||||
enableBilling: false,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('copy-to-clipboard', () => ({ default: vi.fn() }))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowAnnotationFullModal: mockSetShowAnnotationFullModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderContext,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/annotation', () => ({
|
||||
addAnnotation: mockAddAnnotation,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
|
||||
AudioPlayerManager: {
|
||||
getInstance: vi.fn(() => ({
|
||||
getAudioPlayer: vi.fn(() => ({
|
||||
playAudio: vi.fn(),
|
||||
pauseAudio: vi.fn(),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/annotation/edit-annotation-modal', () => ({
|
||||
default: ({ isShow, onHide, onEdited, onAdded, onRemove }: {
|
||||
isShow: boolean
|
||||
onHide: () => void
|
||||
onEdited: (q: string, a: string) => void
|
||||
onAdded: (id: string, name: string, q: string, a: string) => void
|
||||
onRemove: () => void
|
||||
}) =>
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="edit-reply-modal">
|
||||
<button data-testid="modal-hide" onClick={onHide}>Close</button>
|
||||
<button data-testid="modal-edit" onClick={() => onEdited('eq', 'ea')}>Edit</button>
|
||||
<button data-testid="modal-add" onClick={() => onAdded('a1', 'author', 'eq', 'ea')}>Add</button>
|
||||
<button data-testid="modal-remove" onClick={onRemove}>Remove</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button', () => ({
|
||||
default: function AnnotationCtrlMock({ onAdded, onEdit, cached }: {
|
||||
onAdded: (id: string, authorName: string) => void
|
||||
onEdit: () => void
|
||||
cached: boolean
|
||||
}) {
|
||||
const { setShowAnnotationFullModal } = useModalContext()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const handleAdd = () => {
|
||||
if (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse) {
|
||||
setShowAnnotationFullModal()
|
||||
return
|
||||
}
|
||||
onAdded('ann-new', 'Test User')
|
||||
}
|
||||
return (
|
||||
<div data-testid="annotation-ctrl">
|
||||
{cached
|
||||
? (
|
||||
<button data-testid="annotation-edit-btn" onClick={onEdit}>Edit</button>
|
||||
)
|
||||
: (
|
||||
<button data-testid="annotation-add-btn" onClick={handleAdd}>Add</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/new-audio-button', () => ({
|
||||
default: () => <button data-testid="audio-btn">Play</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/log', () => ({
|
||||
default: () => <button data-testid="log-btn"><div className="i-ri-file-list-3-line" /></button>,
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: vi.fn(() => ({ appId: 'test-app' })),
|
||||
usePathname: vi.fn(() => '/apps/test-app'),
|
||||
}))
|
||||
|
||||
const makeChatConfig = (overrides: Partial<ChatConfig> = {}): ChatConfig => ({
|
||||
opening_statement: '',
|
||||
pre_prompt: '',
|
||||
prompt_type: 'simple' as ChatConfig['prompt_type'],
|
||||
user_input_form: [],
|
||||
dataset_query_variable: '',
|
||||
more_like_this: { enabled: false },
|
||||
suggested_questions_after_answer: { enabled: false },
|
||||
speech_to_text: { enabled: false },
|
||||
text_to_speech: { enabled: false },
|
||||
retriever_resource: { enabled: false },
|
||||
sensitive_word_avoidance: { enabled: false },
|
||||
agent_mode: { enabled: false, tools: [] },
|
||||
dataset_configs: { retrieval_model: 'single' } as ChatConfig['dataset_configs'],
|
||||
system_parameters: {
|
||||
audio_file_size_limit: 10,
|
||||
file_size_limit: 10,
|
||||
image_file_size_limit: 10,
|
||||
video_file_size_limit: 10,
|
||||
workflow_file_upload_limit: 10,
|
||||
},
|
||||
supportFeedback: false,
|
||||
supportAnnotation: false,
|
||||
...overrides,
|
||||
} as ChatConfig)
|
||||
|
||||
const mockContextValue: ChatContextValue = {
|
||||
chatList: [],
|
||||
config: makeChatConfig({ supportFeedback: true }),
|
||||
onFeedback: vi.fn().mockResolvedValue(undefined),
|
||||
onRegenerate: vi.fn(),
|
||||
onAnnotationAdded: vi.fn(),
|
||||
onAnnotationEdited: vi.fn(),
|
||||
onAnnotationRemoved: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
useChatContext: () => mockContextValue,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: mockT,
|
||||
}),
|
||||
}))
|
||||
|
||||
type OperationProps = {
|
||||
item: ChatItem
|
||||
question: string
|
||||
index: number
|
||||
showPromptLog?: boolean
|
||||
maxSize: number
|
||||
contentWidth: number
|
||||
hasWorkflowProcess: boolean
|
||||
noChatInput?: boolean
|
||||
}
|
||||
|
||||
const baseItem: ChatItem = {
|
||||
id: 'msg-1',
|
||||
content: 'Hello world',
|
||||
isAnswer: true,
|
||||
}
|
||||
|
||||
const baseProps: OperationProps = {
|
||||
item: baseItem,
|
||||
question: 'What is this?',
|
||||
index: 0,
|
||||
maxSize: 500,
|
||||
contentWidth: 300,
|
||||
hasWorkflowProcess: false,
|
||||
}
|
||||
|
||||
describe('Operation', () => {
|
||||
const renderOperation = (props = baseProps) => {
|
||||
return render(
|
||||
<div className="group">
|
||||
<Operation {...props} />
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockContextValue.config = makeChatConfig({ supportFeedback: true })
|
||||
mockContextValue.onFeedback = vi.fn().mockResolvedValue(undefined)
|
||||
mockContextValue.onRegenerate = vi.fn()
|
||||
mockContextValue.onAnnotationAdded = vi.fn()
|
||||
mockContextValue.onAnnotationEdited = vi.fn()
|
||||
mockContextValue.onAnnotationRemoved = vi.fn()
|
||||
mockProviderContext.plan.usage.annotatedResponse = 0
|
||||
mockProviderContext.enableBilling = false
|
||||
mockAddAnnotation.mockResolvedValue({ id: 'ann-new', account: { name: 'Test User' } })
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should hide action buttons for opening statements', () => {
|
||||
const item = { ...baseItem, isOpeningStatement: true }
|
||||
renderOperation({ ...baseProps, item })
|
||||
expect(screen.queryByTestId('operation-actions')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show copy and regenerate buttons', () => {
|
||||
renderOperation()
|
||||
expect(screen.getByTestId('copy-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide regenerate button when noChatInput is true', () => {
|
||||
renderOperation({ ...baseProps, noChatInput: true })
|
||||
expect(screen.queryByTestId('regenerate-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show TTS button when text_to_speech is enabled', () => {
|
||||
mockContextValue.config = makeChatConfig({ text_to_speech: { enabled: true } })
|
||||
renderOperation()
|
||||
expect(screen.getByTestId('audio-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show annotation button when config supports it', () => {
|
||||
mockContextValue.config = makeChatConfig({
|
||||
supportAnnotation: true,
|
||||
annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true },
|
||||
})
|
||||
renderOperation()
|
||||
expect(screen.getByTestId('annotation-ctrl')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show prompt log when showPromptLog is true', () => {
|
||||
renderOperation({ ...baseProps, showPromptLog: true })
|
||||
expect(screen.getByTestId('log-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show prompt log for opening statements', () => {
|
||||
const item = { ...baseItem, isOpeningStatement: true }
|
||||
renderOperation({ ...baseProps, item, showPromptLog: true })
|
||||
expect(screen.queryByTestId('log-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Copy functionality', () => {
|
||||
it('should copy content on copy click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
await user.click(screen.getByTestId('copy-btn'))
|
||||
expect(copy).toHaveBeenCalledWith('Hello world')
|
||||
})
|
||||
|
||||
it('should aggregate agent_thoughts for copy content', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item: ChatItem = {
|
||||
...baseItem,
|
||||
content: 'ignored',
|
||||
agent_thoughts: [
|
||||
{ id: '1', thought: 'Hello ', tool: '', tool_input: '', observation: '', message_id: '', conversation_id: '', position: 0 },
|
||||
{ id: '2', thought: 'World', tool: '', tool_input: '', observation: '', message_id: '', conversation_id: '', position: 1 },
|
||||
],
|
||||
}
|
||||
renderOperation({ ...baseProps, item })
|
||||
await user.click(screen.getByTestId('copy-btn'))
|
||||
expect(copy).toHaveBeenCalledWith('Hello World')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Regenerate', () => {
|
||||
it('should call onRegenerate on regenerate click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
await user.click(screen.getByTestId('regenerate-btn'))
|
||||
expect(mockContextValue.onRegenerate).toHaveBeenCalledWith(baseItem)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hiding controls with humanInputFormDataList', () => {
|
||||
it('should hide TTS/copy/annotation when humanInputFormDataList is present', () => {
|
||||
mockContextValue.config = makeChatConfig({
|
||||
supportFeedback: false,
|
||||
text_to_speech: { enabled: true },
|
||||
supportAnnotation: true,
|
||||
annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true },
|
||||
})
|
||||
const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem
|
||||
renderOperation({ ...baseProps, item })
|
||||
expect(screen.queryByTestId('audio-btn')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('copy-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User feedback (no annotation support)', () => {
|
||||
beforeEach(() => {
|
||||
mockContextValue.config = makeChatConfig({ supportFeedback: true, supportAnnotation: false })
|
||||
})
|
||||
|
||||
it('should show like/dislike buttons', () => {
|
||||
renderOperation()
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelector('.i-ri-thumb-up-line')).toBeInTheDocument()
|
||||
expect(bar.querySelector('.i-ri-thumb-down-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onFeedback with like on like click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
|
||||
await user.click(thumbUp)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined })
|
||||
})
|
||||
|
||||
it('should open feedback modal on dislike click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDown)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should submit dislike feedback from modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDown)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.type(textarea, 'Bad response')
|
||||
const confirmBtn = screen.getByText(/submit/i)
|
||||
await user.click(confirmBtn)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'dislike', content: 'Bad response' })
|
||||
})
|
||||
|
||||
it('should cancel feedback modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDown)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
const cancelBtn = screen.getByText(/cancel/i)
|
||||
await user.click(cancelBtn)
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show existing like feedback and allow undo', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, feedback: { rating: 'like' as const } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
|
||||
await user.click(thumbUp)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should show existing dislike feedback and allow undo', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, feedback: { rating: 'dislike' as const, content: 'bad' } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDown)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should undo like when already liked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
// First click to like
|
||||
const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
|
||||
await user.click(thumbUp)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined })
|
||||
|
||||
// Second click to undo - re-query as it might be a different node
|
||||
const thumbUpUndo = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
|
||||
await user.click(thumbUpUndo)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should undo dislike when already disliked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDown)
|
||||
const submitBtn = screen.getByText(/submit/i)
|
||||
await user.click(submitBtn)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'dislike', content: '' })
|
||||
|
||||
// Re-query for undo
|
||||
const thumbDownUndo = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDownUndo)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should show tooltip with dislike and content', () => {
|
||||
const item = { ...baseItem, feedback: { rating: 'dislike' as const, content: 'Too slow' } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelector('.i-ri-thumb-down-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show tooltip with only rating', () => {
|
||||
const item = { ...baseItem, feedback: { rating: 'like' as const } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelector('.i-ri-thumb-up-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show feedback bar for opening statements', () => {
|
||||
const item = { ...baseItem, isOpeningStatement: true }
|
||||
renderOperation({ ...baseProps, item })
|
||||
expect(screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show user feedback bar when humanInputFormDataList is present', () => {
|
||||
const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem
|
||||
renderOperation({ ...baseProps, item })
|
||||
expect(screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not call feedback when supportFeedback is disabled', async () => {
|
||||
mockContextValue.config = makeChatConfig({ supportFeedback: false })
|
||||
mockContextValue.onFeedback = undefined
|
||||
renderOperation()
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelectorAll('.i-ri-thumb-up-line').length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Admin feedback (with annotation support)', () => {
|
||||
beforeEach(() => {
|
||||
mockContextValue.config = makeChatConfig({ supportFeedback: true, supportAnnotation: true })
|
||||
})
|
||||
|
||||
it('should show admin like/dislike buttons', () => {
|
||||
renderOperation()
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelectorAll('.i-ri-thumb-up-line').length).toBeGreaterThanOrEqual(1)
|
||||
expect(bar.querySelectorAll('.i-ri-thumb-down-line').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should call onFeedback with like for admin', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line')
|
||||
const adminThumb = thumbs[thumbs.length - 1].closest('button')!
|
||||
await user.click(adminThumb)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined })
|
||||
})
|
||||
|
||||
it('should open feedback modal on admin dislike click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-down-line')
|
||||
const adminThumb = thumbs[thumbs.length - 1].closest('button')!
|
||||
await user.click(adminThumb)
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show user feedback read-only in admin bar when user has liked', () => {
|
||||
const item = { ...baseItem, feedback: { rating: 'like' as const } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelectorAll('.i-ri-thumb-up-line').length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('should show separator in admin bar when user has feedback', () => {
|
||||
const item = { ...baseItem, feedback: { rating: 'dislike' as const } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelector('.bg-components-actionbar-border')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show existing admin like feedback and allow undo', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, adminFeedback: { rating: 'like' as const } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const thumbUp = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-up-line')!.closest('button')!
|
||||
await user.click(thumbUp)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should show existing admin dislike and allow undo', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, adminFeedback: { rating: 'dislike' as const } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDown)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should undo admin like when already liked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line')
|
||||
const adminThumb = thumbs[thumbs.length - 1].closest('button')!
|
||||
await user.click(adminThumb)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'like', content: undefined })
|
||||
|
||||
const thumbsUndo = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line')
|
||||
const adminThumbUndo = thumbsUndo[thumbsUndo.length - 1].closest('button')!
|
||||
await user.click(adminThumbUndo)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should undo admin dislike when already disliked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const thumbs = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-down-line')
|
||||
const adminThumb = thumbs[thumbs.length - 1].closest('button')!
|
||||
await user.click(adminThumb)
|
||||
const submitBtn = screen.getByText(/submit/i)
|
||||
await user.click(submitBtn)
|
||||
|
||||
const thumbsUndo = screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-down-line')
|
||||
const adminThumbUndo = thumbsUndo[thumbsUndo.length - 1].closest('button')!
|
||||
await user.click(adminThumbUndo)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: null, content: undefined })
|
||||
})
|
||||
|
||||
it('should not show admin feedback bar when humanInputFormDataList is present', () => {
|
||||
const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem
|
||||
renderOperation({ ...baseProps, item })
|
||||
expect(screen.getByTestId('operation-bar').querySelectorAll('.i-ri-thumb-up-line').length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Positioning and layout', () => {
|
||||
it('should position right when operationWidth < maxSize', () => {
|
||||
renderOperation({ ...baseProps, maxSize: 500 })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.style.left).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should position bottom when operationWidth >= maxSize', () => {
|
||||
renderOperation({ ...baseProps, maxSize: 1 })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.style.left).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should apply workflow process class when hasWorkflowProcess is true', () => {
|
||||
renderOperation({ ...baseProps, hasWorkflowProcess: true })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.className).toContain('-bottom-4')
|
||||
})
|
||||
|
||||
it('should calculate width correctly for all features combined', () => {
|
||||
mockContextValue.config = makeChatConfig({
|
||||
text_to_speech: { enabled: true },
|
||||
supportAnnotation: true,
|
||||
annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true },
|
||||
supportFeedback: true,
|
||||
})
|
||||
const item = { ...baseItem, feedback: { rating: 'like' as const }, adminFeedback: { rating: 'dislike' as const } }
|
||||
renderOperation({ ...baseProps, item, showPromptLog: true })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show separator when user has feedback in admin mode', () => {
|
||||
mockContextValue.config = makeChatConfig({ supportFeedback: true, supportAnnotation: true })
|
||||
const item = { ...baseItem, feedback: { rating: 'like' as const } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelector('.bg-components-actionbar-border')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle missing translation fallbacks in buildFeedbackTooltip', () => {
|
||||
// Mock t to return null for specific keys
|
||||
mockT.mockImplementation((key: string): string => {
|
||||
if (key.includes('Rate') || key.includes('like'))
|
||||
return '' // Safe string fallback
|
||||
|
||||
return key
|
||||
})
|
||||
|
||||
renderOperation()
|
||||
expect(screen.getByTestId('operation-bar')).toBeInTheDocument()
|
||||
|
||||
// Reset to default behavior
|
||||
mockT.mockImplementation(key => key)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Annotation integration', () => {
|
||||
beforeEach(() => {
|
||||
mockContextValue.config = makeChatConfig({
|
||||
supportAnnotation: true,
|
||||
annotation_reply: { id: 'ar-1', score_threshold: 0.5, embedding_model: { embedding_provider_name: '', embedding_model_name: '' }, enabled: true },
|
||||
appId: 'test-app',
|
||||
})
|
||||
})
|
||||
|
||||
it('should add annotation via annotation ctrl button', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOperation()
|
||||
const addBtn = screen.getByTestId('annotation-add-btn')
|
||||
await user.click(addBtn)
|
||||
expect(mockContextValue.onAnnotationAdded).toHaveBeenCalledWith('ann-new', 'Test User', 'What is this?', 'Hello world', 0)
|
||||
})
|
||||
|
||||
it('should show annotation full modal when limit reached', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockProviderContext.enableBilling = true
|
||||
mockProviderContext.plan.usage.annotatedResponse = 100
|
||||
renderOperation()
|
||||
const addBtn = screen.getByTestId('annotation-add-btn')
|
||||
await user.click(addBtn)
|
||||
expect(mockSetShowAnnotationFullModal).toHaveBeenCalled()
|
||||
expect(mockAddAnnotation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open edit reply modal when cached annotation exists', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const editBtn = screen.getByTestId('annotation-edit-btn')
|
||||
await user.click(editBtn)
|
||||
expect(screen.getByTestId('edit-reply-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onAnnotationEdited from edit reply modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const editBtn = screen.getByTestId('annotation-edit-btn')
|
||||
await user.click(editBtn)
|
||||
await user.click(screen.getByTestId('modal-edit'))
|
||||
expect(mockContextValue.onAnnotationEdited).toHaveBeenCalledWith('eq', 'ea', 0)
|
||||
})
|
||||
|
||||
it('should call onAnnotationAdded from edit reply modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const editBtn = screen.getByTestId('annotation-edit-btn')
|
||||
await user.click(editBtn)
|
||||
await user.click(screen.getByTestId('modal-add'))
|
||||
expect(mockContextValue.onAnnotationAdded).toHaveBeenCalledWith('a1', 'author', 'eq', 'ea', 0)
|
||||
})
|
||||
|
||||
it('should call onAnnotationRemoved from edit reply modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const editBtn = screen.getByTestId('annotation-edit-btn')
|
||||
await user.click(editBtn)
|
||||
await user.click(screen.getByTestId('modal-remove'))
|
||||
expect(mockContextValue.onAnnotationRemoved).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should close edit reply modal via onHide', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item = { ...baseItem, annotation: { id: 'ann-1', created_at: 123, authorName: 'test author' } }
|
||||
renderOperation({ ...baseProps, item })
|
||||
const editBtn = screen.getByTestId('annotation-edit-btn')
|
||||
await user.click(editBtn)
|
||||
expect(screen.getByTestId('edit-reply-modal')).toBeInTheDocument()
|
||||
await user.click(screen.getByTestId('modal-hide'))
|
||||
expect(screen.queryByTestId('edit-reply-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TTS audio button', () => {
|
||||
beforeEach(() => {
|
||||
mockContextValue.config = makeChatConfig({ text_to_speech: { enabled: true, voice: 'test-voice' } })
|
||||
})
|
||||
|
||||
it('should show audio play button when TTS enabled', () => {
|
||||
renderOperation()
|
||||
expect(screen.getByTestId('audio-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show audio button for humanInputFormDataList', () => {
|
||||
const item = { ...baseItem, humanInputFormDataList: [{}] } as ChatItem
|
||||
renderOperation({ ...baseProps, item })
|
||||
expect(screen.queryByTestId('audio-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle feedback content with only whitespace', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockContextValue.config = makeChatConfig({ supportFeedback: true })
|
||||
renderOperation()
|
||||
const thumbDown = screen.getByTestId('operation-bar').querySelector('.i-ri-thumb-down-line')!.closest('button')!
|
||||
await user.click(thumbDown)
|
||||
const textarea = screen.getByRole('textbox')
|
||||
await user.type(textarea, ' ')
|
||||
const confirmBtn = screen.getByText(/submit/i)
|
||||
await user.click(confirmBtn)
|
||||
expect(mockContextValue.onFeedback).toHaveBeenCalledWith('msg-1', { rating: 'dislike', content: ' ' })
|
||||
})
|
||||
|
||||
it('should handle missing onFeedback callback gracefully', async () => {
|
||||
mockContextValue.onFeedback = undefined
|
||||
mockContextValue.config = makeChatConfig({ supportFeedback: true })
|
||||
renderOperation()
|
||||
const bar = screen.getByTestId('operation-bar')
|
||||
expect(bar.querySelector('.i-ri-thumb-up-line')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty agent_thoughts array', async () => {
|
||||
const user = userEvent.setup()
|
||||
const item: ChatItem = { ...baseItem, agent_thoughts: [] }
|
||||
renderOperation({ ...baseProps, item })
|
||||
await user.click(screen.getByTestId('copy-btn'))
|
||||
expect(copy).toHaveBeenCalledWith('Hello world')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,12 +3,6 @@ import type {
|
||||
ChatItem,
|
||||
Feedback,
|
||||
} from '../../types'
|
||||
import {
|
||||
RiClipboardLine,
|
||||
RiResetLeftLine,
|
||||
RiThumbDownLine,
|
||||
RiThumbUpLine,
|
||||
} from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import {
|
||||
memo,
|
||||
@@ -127,20 +121,10 @@ const Operation: FC<OperationProps> = ({
|
||||
}
|
||||
|
||||
const handleLikeClick = (target: 'user' | 'admin') => {
|
||||
const currentRating = target === 'admin' ? adminLocalFeedback?.rating : displayUserFeedback?.rating
|
||||
if (currentRating === 'like') {
|
||||
handleFeedback(null, undefined, target)
|
||||
return
|
||||
}
|
||||
handleFeedback('like', undefined, target)
|
||||
}
|
||||
|
||||
const handleDislikeClick = (target: 'user' | 'admin') => {
|
||||
const currentRating = target === 'admin' ? adminLocalFeedback?.rating : displayUserFeedback?.rating
|
||||
if (currentRating === 'dislike') {
|
||||
handleFeedback(null, undefined, target)
|
||||
return
|
||||
}
|
||||
setFeedbackTarget(target)
|
||||
setIsShowFeedbackModal(true)
|
||||
}
|
||||
@@ -186,6 +170,7 @@ const Operation: FC<OperationProps> = ({
|
||||
!hasWorkflowProcess && positionRight && '!top-[9px]',
|
||||
)}
|
||||
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
|
||||
data-testid="operation-bar"
|
||||
>
|
||||
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
|
||||
<div className={cn(
|
||||
@@ -204,8 +189,8 @@ const Operation: FC<OperationProps> = ({
|
||||
onClick={() => handleFeedback(null, undefined, 'user')}
|
||||
>
|
||||
{displayUserFeedback?.rating === 'like'
|
||||
? <RiThumbUpLine className="h-4 w-4" />
|
||||
: <RiThumbDownLine className="h-4 w-4" />}
|
||||
? <div className="i-ri-thumb-up-line h-4 w-4" />
|
||||
: <div className="i-ri-thumb-down-line h-4 w-4" />}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
@@ -215,13 +200,13 @@ const Operation: FC<OperationProps> = ({
|
||||
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
|
||||
onClick={() => handleLikeClick('user')}
|
||||
>
|
||||
<RiThumbUpLine className="h-4 w-4" />
|
||||
<div className="i-ri-thumb-up-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
state={displayUserFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
|
||||
onClick={() => handleDislikeClick('user')}
|
||||
>
|
||||
<RiThumbDownLine className="h-4 w-4" />
|
||||
<div className="i-ri-thumb-down-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
</>
|
||||
)}
|
||||
@@ -242,12 +227,12 @@ const Operation: FC<OperationProps> = ({
|
||||
{displayUserFeedback.rating === 'like'
|
||||
? (
|
||||
<ActionButton state={ActionButtonState.Active}>
|
||||
<RiThumbUpLine className="h-4 w-4" />
|
||||
<div className="i-ri-thumb-up-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
)
|
||||
: (
|
||||
<ActionButton state={ActionButtonState.Destructive}>
|
||||
<RiThumbDownLine className="h-4 w-4" />
|
||||
<div className="i-ri-thumb-down-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
@@ -266,8 +251,8 @@ const Operation: FC<OperationProps> = ({
|
||||
onClick={() => handleFeedback(null, undefined, 'admin')}
|
||||
>
|
||||
{adminLocalFeedback?.rating === 'like'
|
||||
? <RiThumbUpLine className="h-4 w-4" />
|
||||
: <RiThumbDownLine className="h-4 w-4" />}
|
||||
? <div className="i-ri-thumb-up-line h-4 w-4" />
|
||||
: <div className="i-ri-thumb-down-line h-4 w-4" />}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)
|
||||
@@ -281,7 +266,7 @@ const Operation: FC<OperationProps> = ({
|
||||
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
|
||||
onClick={() => handleLikeClick('admin')}
|
||||
>
|
||||
<RiThumbUpLine className="h-4 w-4" />
|
||||
<div className="i-ri-thumb-up-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
@@ -292,7 +277,7 @@ const Operation: FC<OperationProps> = ({
|
||||
state={adminLocalFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
|
||||
onClick={() => handleDislikeClick('admin')}
|
||||
>
|
||||
<RiThumbDownLine className="h-4 w-4" />
|
||||
<div className="i-ri-thumb-down-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
@@ -305,7 +290,7 @@ const Operation: FC<OperationProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{!isOpeningStatement && (
|
||||
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex">
|
||||
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex" data-testid="operation-actions">
|
||||
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (
|
||||
<NewAudioButton
|
||||
id={id}
|
||||
@@ -314,17 +299,19 @@ const Operation: FC<OperationProps> = ({
|
||||
/>
|
||||
)}
|
||||
{!humanInputFormDataList?.length && (
|
||||
<ActionButton onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
data-testid="copy-btn"
|
||||
>
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
<div className="i-ri-clipboard-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{!noChatInput && (
|
||||
<ActionButton onClick={() => onRegenerate?.(item)}>
|
||||
<RiResetLeftLine className="h-4 w-4" />
|
||||
<ActionButton onClick={() => onRegenerate?.(item)} data-testid="regenerate-btn">
|
||||
<div className="i-ri-reset-left-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && (
|
||||
@@ -366,7 +353,7 @@ const Operation: FC<OperationProps> = ({
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="system-sm-semibold mb-2 block text-text-secondary">
|
||||
<label className="mb-2 block text-text-secondary system-sm-semibold">
|
||||
{t('feedback.content', { ns: 'common' }) || 'Feedback Content'}
|
||||
</label>
|
||||
<Textarea
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Mock } from 'vitest' // Or 'jest' if using Jest
|
||||
import type { IChatItem } from '../type'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useChatContext } from '../context'
|
||||
import SuggestedQuestions from './suggested-questions'
|
||||
|
||||
// Mock the chat context
|
||||
vi.mock('../context', () => ({
|
||||
useChatContext: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('SuggestedQuestions', () => {
|
||||
const mockOnSend = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Use 'as Mock' instead of 'as any'
|
||||
(useChatContext as Mock).mockReturnValue({
|
||||
onSend: mockOnSend,
|
||||
readonly: false,
|
||||
})
|
||||
})
|
||||
|
||||
const mockItem: IChatItem = {
|
||||
id: '1',
|
||||
content: '',
|
||||
isAnswer: true,
|
||||
isOpeningStatement: true,
|
||||
suggestedQuestions: ['What is Dify?', 'How to use it?', ' ', ''],
|
||||
}
|
||||
|
||||
it('should render suggested questions and filter out empty ones', () => {
|
||||
render(<SuggestedQuestions item={mockItem} />)
|
||||
|
||||
const questions = screen.getAllByTestId('suggested-question')
|
||||
expect(questions).toHaveLength(2)
|
||||
expect(questions[0]).toHaveTextContent('What is Dify?')
|
||||
expect(questions[1]).toHaveTextContent('How to use it?')
|
||||
})
|
||||
|
||||
it('should call onSend when a question is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SuggestedQuestions item={mockItem} />)
|
||||
|
||||
const questions = screen.getAllByTestId('suggested-question')
|
||||
await user.click(questions[0])
|
||||
|
||||
expect(mockOnSend).toHaveBeenCalledWith('What is Dify?')
|
||||
})
|
||||
|
||||
it('should not render if isOpeningStatement is false', () => {
|
||||
render(<SuggestedQuestions item={{ ...mockItem, isOpeningStatement: false }} />)
|
||||
expect(screen.queryByTestId('suggested-question')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render if suggestedQuestions is missing or empty', () => {
|
||||
render(<SuggestedQuestions item={{ ...mockItem, suggestedQuestions: [] }} />)
|
||||
expect(screen.queryByTestId('suggested-question')).not.toBeInTheDocument()
|
||||
|
||||
// Use 'as IChatItem' instead of 'as any'
|
||||
render(<SuggestedQuestions item={{ ...mockItem, suggestedQuestions: undefined } as IChatItem} />)
|
||||
expect(screen.queryByTestId('suggested-question')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should be disabled and not call onSend when readonly is true', async () => {
|
||||
const user = userEvent.setup();
|
||||
// Use 'as Mock' instead of 'as any'
|
||||
(useChatContext as Mock).mockReturnValue({
|
||||
onSend: mockOnSend,
|
||||
readonly: true,
|
||||
})
|
||||
|
||||
render(<SuggestedQuestions item={mockItem} />)
|
||||
|
||||
const questions = screen.getAllByTestId('suggested-question')
|
||||
expect(questions[0]).toHaveClass('pointer-events-none')
|
||||
expect(questions[0]).toHaveClass('opacity-50')
|
||||
|
||||
await user.click(questions[0])
|
||||
expect(mockOnSend).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -26,10 +26,11 @@ const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'system-sm-medium mr-1 mt-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 text-components-button-secondary-accent-text shadow-xs last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover',
|
||||
'mr-1 mt-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 text-components-button-secondary-accent-text shadow-xs system-sm-medium last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover',
|
||||
readonly && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
onClick={() => !readonly && onSend?.(question)}
|
||||
data-testid="suggested-question"
|
||||
>
|
||||
{question}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ToolInfoInThought } from '../type'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ToolDetail from './tool-detail'
|
||||
|
||||
describe('ToolDetail', () => {
|
||||
const mockPayload: ToolInfoInThought = {
|
||||
name: 'test_tool',
|
||||
label: 'Test Tool Label',
|
||||
input: 'test input content',
|
||||
output: 'test output content',
|
||||
isFinished: true,
|
||||
}
|
||||
|
||||
const datasetPayload: ToolInfoInThought = {
|
||||
...mockPayload,
|
||||
name: 'dataset_123',
|
||||
label: 'Dataset Label',
|
||||
}
|
||||
|
||||
it('should render the tool label and "used" state when finished', () => {
|
||||
render(<ToolDetail payload={mockPayload} />)
|
||||
|
||||
expect(screen.getByText('Test Tool Label')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.thought.used')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the knowledge label and "using" state when not finished and name is a dataset', () => {
|
||||
render(<ToolDetail payload={{ ...datasetPayload, isFinished: false }} />)
|
||||
|
||||
expect(screen.getByText('dataset.knowledge')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.thought.using')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle expansion and show request/response details on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ToolDetail payload={mockPayload} />)
|
||||
|
||||
// Initially collapsed: request/response titles should not be visible
|
||||
expect(screen.queryByText('tools.thought.requestTitle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(mockPayload.input)).not.toBeInTheDocument()
|
||||
|
||||
// Click to expand
|
||||
const label = screen.getByText('Test Tool Label')
|
||||
await user.click(label)
|
||||
|
||||
// Now expanded
|
||||
expect(screen.getByText('tools.thought.requestTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockPayload.input)).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.thought.responseTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockPayload.output)).toBeInTheDocument()
|
||||
|
||||
// Click again to collapse
|
||||
await user.click(label)
|
||||
expect(screen.queryByText('tools.thought.requestTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply different styles when expanded', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(<ToolDetail payload={mockPayload} />)
|
||||
const rootDiv = container.firstChild as HTMLElement
|
||||
const label = screen.getByText('Test Tool Label')
|
||||
const headerDiv = label.parentElement!
|
||||
|
||||
// Initial styles
|
||||
expect(rootDiv).toHaveClass('bg-workflow-process-bg')
|
||||
expect(headerDiv).not.toHaveClass('pb-1.5')
|
||||
|
||||
// Expand
|
||||
await user.click(label)
|
||||
expect(rootDiv).toHaveClass('bg-background-section-burn')
|
||||
expect(headerDiv).toHaveClass('pb-1.5')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { WorkflowProcess } from '../../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import WorkflowProcessItem from './workflow-process'
|
||||
|
||||
// Mock TracingPanel as it's a complex child component
|
||||
vi.mock('@/app/components/workflow/run/tracing-panel', () => ({
|
||||
default: () => <div data-testid="tracing-panel">Tracing Panel</div>,
|
||||
}))
|
||||
|
||||
describe('WorkflowProcessItem', () => {
|
||||
const mockData = {
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
tracing: [
|
||||
{ id: '1', title: 'Start' },
|
||||
{ id: '2', title: 'End' },
|
||||
],
|
||||
}
|
||||
|
||||
it('should render the latest node title when collapsed', () => {
|
||||
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={false} />)
|
||||
expect(screen.getByTestId('workflow-process-title')).toHaveTextContent('End')
|
||||
expect(screen.queryByTestId('tracing-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render "Workflow Process" title and TracingPanel when expanded', () => {
|
||||
// We expect t('common.workflowProcess', { ns: 'workflow' }) to be called
|
||||
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={true} />)
|
||||
expect(screen.getByText(/workflowProcess/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tracing-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle collapse state on header click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<WorkflowProcessItem data={mockData as WorkflowProcess} expand={false} />)
|
||||
|
||||
const header = screen.getByTestId('workflow-process-header')
|
||||
|
||||
// Expand
|
||||
await user.click(header)
|
||||
expect(screen.getByTestId('tracing-panel')).toBeInTheDocument()
|
||||
expect(screen.getByText(/workflowProcess/i)).toBeInTheDocument()
|
||||
|
||||
// Collapse
|
||||
await user.click(header)
|
||||
expect(screen.queryByTestId('tracing-panel')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-process-title')).toHaveTextContent('End')
|
||||
})
|
||||
|
||||
it('should render nothing if readonly is true', () => {
|
||||
const { container } = render(<WorkflowProcessItem data={mockData as WorkflowProcess} readonly={true} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
describe('Status Icons', () => {
|
||||
it('should show running spinner when status is Running', () => {
|
||||
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Running } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('status-icon-running')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show success circle when status is Succeeded', () => {
|
||||
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('status-icon-success')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error warning when status is Failed', () => {
|
||||
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('status-icon-failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error warning when status is Stopped', () => {
|
||||
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Stopped } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('status-icon-failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show pause circle when status is Paused', () => {
|
||||
render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('status-icon-paused')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Background Colors', () => {
|
||||
it('should apply correct background when collapsed for different statuses', () => {
|
||||
const { rerender } = render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-bg')
|
||||
|
||||
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-paused-bg')
|
||||
|
||||
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} />)
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-workflow-process-failed-bg')
|
||||
})
|
||||
|
||||
it('should apply correct background when expanded for different statuses', () => {
|
||||
const { rerender } = render(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Running } as WorkflowProcess} expand={true} />)
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-background-section-burn')
|
||||
|
||||
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Succeeded } as WorkflowProcess} expand={true} />)
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-state-success-hover')
|
||||
|
||||
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Failed } as WorkflowProcess} expand={true} />)
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-state-destructive-hover')
|
||||
|
||||
rerender(<WorkflowProcessItem data={{ ...mockData, status: WorkflowRunningStatus.Paused } as WorkflowProcess} expand={true} />)
|
||||
expect(screen.getByTestId('workflow-process-item')).toHaveClass('bg-state-warning-hover')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,10 @@
|
||||
import type { ChatItem, WorkflowProcess } from '../../types'
|
||||
import {
|
||||
RiArrowRightSLine,
|
||||
RiErrorWarningFill,
|
||||
RiLoader2Line,
|
||||
RiPauseCircleFill,
|
||||
} from '@remixicon/react'
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import TracingPanel from '@/app/components/workflow/run/tracing-panel'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@@ -58,35 +52,52 @@ const WorkflowProcessItem = ({
|
||||
collapse && paused && 'bg-workflow-process-paused-bg',
|
||||
collapse && failed && 'bg-workflow-process-failed-bg',
|
||||
)}
|
||||
data-testid="workflow-process-item"
|
||||
>
|
||||
<div
|
||||
className={cn('flex cursor-pointer items-center', !collapse && 'px-1.5')}
|
||||
onClick={() => setCollapse(!collapse)}
|
||||
data-testid="workflow-process-header"
|
||||
>
|
||||
{
|
||||
running && (
|
||||
<RiLoader2Line className="mr-1 h-3.5 w-3.5 shrink-0 animate-spin text-text-tertiary" />
|
||||
<div
|
||||
className="i-ri-loader-2-line mr-1 h-3.5 w-3.5 shrink-0 animate-spin text-text-tertiary"
|
||||
data-testid="status-icon-running"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
succeeded && (
|
||||
<CheckCircle className="mr-1 h-3.5 w-3.5 shrink-0 text-text-success" />
|
||||
<div
|
||||
className="i-custom-vender-solid-general-check-circle mr-1 h-3.5 w-3.5 shrink-0 text-text-success"
|
||||
data-testid="status-icon-success"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
failed && (
|
||||
<RiErrorWarningFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive" />
|
||||
<div
|
||||
className="i-ri-error-warning-fill mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive"
|
||||
data-testid="status-icon-failed"
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
paused && (
|
||||
<RiPauseCircleFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-warning-secondary" />
|
||||
<div
|
||||
className="i-ri-pause-circle-fill mr-1 h-3.5 w-3.5 shrink-0 text-text-warning-secondary"
|
||||
data-testid="status-icon-paused"
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}>
|
||||
<div
|
||||
className={cn('text-text-secondary system-xs-medium', !collapse && 'grow')}
|
||||
data-testid="workflow-process-title"
|
||||
>
|
||||
{!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title}
|
||||
</div>
|
||||
<RiArrowRightSLine className={cn('ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
|
||||
<div className={cn('i-ri-arrow-right-s-line ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
|
||||
</div>
|
||||
{
|
||||
!collapse && (
|
||||
|
||||
568
web/app/components/base/chat/chat/chat-input-area/index.spec.tsx
Normal file
568
web/app/components/base/chat/chat/chat-input-area/index.spec.tsx
Normal file
@@ -0,0 +1,568 @@
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { TransferMethod } from '@/types/app'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import ChatInputArea from './index'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist shared mock references so they are available inside vi.mock factories
|
||||
// ---------------------------------------------------------------------------
|
||||
const { mockGetPermission, mockNotify } = vi.hoisted(() => ({
|
||||
mockGetPermission: vi.fn().mockResolvedValue(undefined),
|
||||
mockNotify: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External dependency mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('js-audio-recorder', () => ({
|
||||
default: class {
|
||||
static getPermission = mockGetPermission
|
||||
start = vi.fn()
|
||||
stop = vi.fn()
|
||||
getWAVBlob = vi.fn().mockReturnValue(new Blob([''], { type: 'audio/wav' }))
|
||||
getRecordAnalyseData = vi.fn().mockReturnValue(new Uint8Array(128))
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/share', () => ({
|
||||
audioToText: vi.fn().mockResolvedValue({ text: 'Converted text' }),
|
||||
AppSourceType: { webApp: 'webApp', installedApp: 'installedApp' },
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File-uploader store – shared mutable state so individual tests can mutate it
|
||||
// ---------------------------------------------------------------------------
|
||||
const mockFileStore: { files: FileEntity[], setFiles: ReturnType<typeof vi.fn> } = {
|
||||
files: [],
|
||||
setFiles: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader/store', () => ({
|
||||
useFileStore: () => ({ getState: () => mockFileStore }),
|
||||
useStore: (selector: (s: typeof mockFileStore) => unknown) => selector(mockFileStore),
|
||||
FileContextProvider: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File-uploader hooks – provide stable drag/drop handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
vi.mock('@/app/components/base/file-uploader/hooks', () => ({
|
||||
useFile: () => ({
|
||||
handleDragFileEnter: vi.fn(),
|
||||
handleDragFileLeave: vi.fn(),
|
||||
handleDragFileOver: vi.fn(),
|
||||
handleDropFile: vi.fn(),
|
||||
handleClipboardPasteFile: vi.fn(),
|
||||
isDragActive: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Features context hook – avoids needing FeaturesContext.Provider in the tree
|
||||
// ---------------------------------------------------------------------------
|
||||
// FeatureBar calls: useFeatures(s => s.features)
|
||||
// So the selector receives the store state object; we must nest the features
|
||||
// under a `features` key to match what the real store exposes.
|
||||
const mockFeaturesState = {
|
||||
features: {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
file: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeatures: (selector: (s: typeof mockFeaturesState) => unknown) =>
|
||||
selector(mockFeaturesState),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toast context
|
||||
// ---------------------------------------------------------------------------
|
||||
vi.mock('@/app/components/base/toast', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/app/components/base/toast')>(
|
||||
'@/app/components/base/toast',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
useToastContext: () => ({ notify: mockNotify }),
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal layout hook – controls single/multi-line textarea mode
|
||||
// ---------------------------------------------------------------------------
|
||||
let mockIsMultipleLine = false
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
useTextAreaHeight: () => ({
|
||||
wrapperRef: { current: document.createElement('div') },
|
||||
textareaRef: { current: document.createElement('textarea') },
|
||||
textValueRef: { current: document.createElement('div') },
|
||||
holdSpaceRef: { current: document.createElement('div') },
|
||||
handleTextareaResize: vi.fn(),
|
||||
get isMultipleLine() {
|
||||
return mockIsMultipleLine
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input-forms validation hook – always passes by default
|
||||
// ---------------------------------------------------------------------------
|
||||
vi.mock('../check-input-forms-hooks', () => ({
|
||||
useCheckInputsForms: () => ({
|
||||
checkInputsForm: vi.fn().mockReturnValue(true),
|
||||
}),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Next.js navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ token: 'test-token' }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/test',
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixture – typed as FileUpload to avoid implicit any
|
||||
// ---------------------------------------------------------------------------
|
||||
// const mockVisionConfig: FileUpload = {
|
||||
// fileUploadConfig: {
|
||||
// image_file_size_limit: 10,
|
||||
// file_size_limit: 10,
|
||||
// audio_file_size_limit: 10,
|
||||
// video_file_size_limit: 10,
|
||||
// workflow_file_upload_limit: 10,
|
||||
// },
|
||||
// allowed_file_types: [],
|
||||
// allowed_file_extensions: [],
|
||||
// enabled: true,
|
||||
// number_limits: 3,
|
||||
// transfer_methods: ['local_file', 'remote_url'],
|
||||
// } as FileUpload
|
||||
|
||||
const mockVisionConfig: FileUpload = {
|
||||
// Required because of '& EnabledOrDisabled' at the end of your type
|
||||
enabled: true,
|
||||
|
||||
// The nested config object
|
||||
fileUploadConfig: {
|
||||
image_file_size_limit: 10,
|
||||
file_size_limit: 10,
|
||||
audio_file_size_limit: 10,
|
||||
video_file_size_limit: 10,
|
||||
workflow_file_upload_limit: 10,
|
||||
batch_count_limit: 0,
|
||||
image_file_batch_limit: 0,
|
||||
single_chunk_attachment_limit: 0,
|
||||
attachment_image_file_size_limit: 0,
|
||||
file_upload_limit: 0,
|
||||
},
|
||||
|
||||
// These match the keys in your FileUpload type
|
||||
allowed_file_types: [],
|
||||
allowed_file_extensions: [],
|
||||
number_limits: 3,
|
||||
|
||||
// NOTE: Your type defines 'allowed_file_upload_methods',
|
||||
// not 'transfer_methods' at the top level.
|
||||
allowed_file_upload_methods: ['local_file', 'remote_url'] as TransferMethod[],
|
||||
|
||||
// If you wanted to define specific image/video behavior:
|
||||
image: {
|
||||
enabled: true,
|
||||
number_limits: 3,
|
||||
transfer_methods: ['local_file', 'remote_url'] as TransferMethod[],
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal valid FileEntity fixture – avoids undefined `type` crash in FileItem
|
||||
// ---------------------------------------------------------------------------
|
||||
const makeFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: 'file-1',
|
||||
name: 'photo.png',
|
||||
type: 'image/png', // required: FileItem calls type.split('/')[0]
|
||||
size: 1024,
|
||||
progress: 100,
|
||||
transferMethod: 'local_file',
|
||||
uploadedId: 'uploaded-ok',
|
||||
...overrides,
|
||||
} as FileEntity)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const getTextarea = () => screen.getByPlaceholderText(/inputPlaceholder/i)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('ChatInputArea', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockFileStore.files = []
|
||||
mockIsMultipleLine = false
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Rendering', () => {
|
||||
it('should render the textarea with default placeholder', () => {
|
||||
render(<ChatInputArea visionConfig={mockVisionConfig} />)
|
||||
expect(getTextarea()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the readonly placeholder when readonly prop is set', () => {
|
||||
render(<ChatInputArea visionConfig={mockVisionConfig} readonly />)
|
||||
expect(screen.getByPlaceholderText(/inputDisabledPlaceholder/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the send button', () => {
|
||||
render(<ChatInputArea visionConfig={mockVisionConfig} />)
|
||||
expect(screen.getByTestId('send-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply disabled styles when the disabled prop is true', () => {
|
||||
const { container } = render(<ChatInputArea visionConfig={mockVisionConfig} disabled />)
|
||||
const disabledWrapper = container.querySelector('.pointer-events-none')
|
||||
expect(disabledWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the operation section inline when single-line', () => {
|
||||
// mockIsMultipleLine is false by default
|
||||
render(<ChatInputArea visionConfig={mockVisionConfig} />)
|
||||
expect(screen.getByTestId('send-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the operation section below the textarea when multi-line', () => {
|
||||
mockIsMultipleLine = true
|
||||
render(<ChatInputArea visionConfig={mockVisionConfig} />)
|
||||
expect(screen.getByTestId('send-button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Typing', () => {
|
||||
it('should update textarea value as the user types', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.type(getTextarea(), 'Hello world')
|
||||
|
||||
expect(getTextarea()).toHaveValue('Hello world')
|
||||
})
|
||||
|
||||
it('should clear the textarea after a message is successfully sent', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.type(getTextarea(), 'Hello world')
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(getTextarea()).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Sending Messages', () => {
|
||||
it('should call onSend with query and files when clicking the send button', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.type(getTextarea(), 'Hello world')
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
expect(onSend).toHaveBeenCalledWith('Hello world', [])
|
||||
})
|
||||
|
||||
it('should call onSend and reset the input when pressing Enter', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.type(getTextarea(), 'Hello world{Enter}')
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('Hello world', [])
|
||||
expect(getTextarea()).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should NOT call onSend when pressing Shift+Enter (inserts newline instead)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.type(getTextarea(), 'Hello world{Shift>}{Enter}{/Shift}')
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
expect(getTextarea()).toHaveValue('Hello world\n')
|
||||
})
|
||||
|
||||
it('should NOT call onSend in readonly mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} readonly />)
|
||||
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pass already-uploaded files to onSend', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
|
||||
// makeFile ensures `type` is always a proper MIME string
|
||||
const uploadedFile = makeFile({ id: 'file-1', name: 'photo.png', uploadedId: 'uploaded-123' })
|
||||
mockFileStore.files = [uploadedFile]
|
||||
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
await user.type(getTextarea(), 'With attachment')
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('With attachment', [uploadedFile])
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('History Navigation', () => {
|
||||
it('should restore the last sent message when pressing Cmd+ArrowUp once', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()
|
||||
|
||||
await user.type(textarea, 'First{Enter}')
|
||||
await user.type(textarea, 'Second{Enter}')
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}')
|
||||
|
||||
expect(textarea).toHaveValue('Second')
|
||||
})
|
||||
|
||||
it('should go further back in history with repeated Cmd+ArrowUp', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()
|
||||
|
||||
await user.type(textarea, 'First{Enter}')
|
||||
await user.type(textarea, 'Second{Enter}')
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}')
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}')
|
||||
|
||||
expect(textarea).toHaveValue('First')
|
||||
})
|
||||
|
||||
it('should move forward in history when pressing Cmd+ArrowDown', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()
|
||||
|
||||
await user.type(textarea, 'First{Enter}')
|
||||
await user.type(textarea, 'Second{Enter}')
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → Second
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → First
|
||||
await user.type(textarea, '{Meta>}{ArrowDown}{/Meta}') // → Second
|
||||
|
||||
expect(textarea).toHaveValue('Second')
|
||||
})
|
||||
|
||||
it('should clear the input when navigating past the most recent history entry', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()
|
||||
|
||||
await user.type(textarea, 'First{Enter}')
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → First
|
||||
await user.type(textarea, '{Meta>}{ArrowDown}{/Meta}') // → past end → ''
|
||||
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should not go below the start of history when pressing Cmd+ArrowUp at the boundary', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea onSend={vi.fn()} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()
|
||||
|
||||
await user.type(textarea, 'Only{Enter}')
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → Only
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // → '' (seed at index 0)
|
||||
await user.type(textarea, '{Meta>}{ArrowUp}{/Meta}') // boundary – should stay at ''
|
||||
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Voice Input', () => {
|
||||
it('should render the voice input button when speech-to-text is enabled', () => {
|
||||
render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />)
|
||||
expect(screen.getByTestId('voice-input-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT render the voice input button when speech-to-text is disabled', () => {
|
||||
render(<ChatInputArea speechToTextConfig={{ enabled: false }} visionConfig={mockVisionConfig} />)
|
||||
expect(screen.queryByTestId('voice-input-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should request microphone permission when the voice button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.click(screen.getByTestId('voice-input-button'))
|
||||
|
||||
expect(mockGetPermission).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should notify with an error when microphone permission is denied', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGetPermission.mockRejectedValueOnce(new Error('Permission denied'))
|
||||
render(<ChatInputArea speechToTextConfig={{ enabled: true }} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.click(screen.getByTestId('voice-input-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
})
|
||||
|
||||
it('should NOT invoke onSend while voice input is being activated', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(
|
||||
<ChatInputArea
|
||||
onSend={onSend}
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
visionConfig={mockVisionConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('voice-input-button'))
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Validation', () => {
|
||||
it('should notify and NOT call onSend when the query is blank', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
|
||||
})
|
||||
|
||||
it('should notify and NOT call onSend when the query contains only whitespace', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.type(getTextarea(), ' ')
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
|
||||
})
|
||||
|
||||
it('should notify and NOT call onSend while the bot is already responding', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
render(<ChatInputArea onSend={onSend} isResponding visionConfig={mockVisionConfig} />)
|
||||
|
||||
await user.type(getTextarea(), 'Hello')
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
|
||||
})
|
||||
|
||||
it('should notify and NOT call onSend while a file upload is still in progress', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
|
||||
// uploadedId is empty string → upload not yet finished
|
||||
mockFileStore.files = [
|
||||
makeFile({ id: 'file-upload', uploadedId: '', progress: 50 }),
|
||||
]
|
||||
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
await user.type(getTextarea(), 'Hello')
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'info' }))
|
||||
})
|
||||
|
||||
it('should call onSend normally when all uploaded files have completed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
|
||||
// uploadedId is present → upload finished
|
||||
mockFileStore.files = [makeFile({ uploadedId: 'uploaded-ok' })]
|
||||
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
await user.type(getTextarea(), 'With completed file')
|
||||
await user.click(screen.getByTestId('send-button'))
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe('Feature Bar', () => {
|
||||
it('should render the FeatureBar section when showFeatureBar is true', () => {
|
||||
const { container } = render(
|
||||
<ChatInputArea visionConfig={mockVisionConfig} showFeatureBar />,
|
||||
)
|
||||
// FeatureBar renders a rounded-bottom container beneath the input
|
||||
expect(container.querySelector('[class*="rounded-b"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT render the FeatureBar when showFeatureBar is false', () => {
|
||||
const { container } = render(
|
||||
<ChatInputArea visionConfig={mockVisionConfig} showFeatureBar={false} />,
|
||||
)
|
||||
expect(container.querySelector('[class*="rounded-b"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not invoke onFeatureBarClick when the component is in readonly mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onFeatureBarClick = vi.fn()
|
||||
render(
|
||||
<ChatInputArea
|
||||
visionConfig={mockVisionConfig}
|
||||
showFeatureBar
|
||||
readonly
|
||||
onFeatureBarClick={onFeatureBarClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
// In readonly mode the FeatureBar receives `noop` as its click handler.
|
||||
// Click every button that is not a named test-id button to exercise the guard.
|
||||
const buttons = screen.queryAllByRole('button')
|
||||
for (const btn of buttons) {
|
||||
if (!btn.dataset.testid)
|
||||
await user.click(btn)
|
||||
}
|
||||
|
||||
expect(onFeatureBarClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { EnableType } from '../../types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Theme } from '../../embedded-chatbot/theme/theme-context'
|
||||
import Operation from './operation'
|
||||
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInChatInput: ({ readonly }: { readonly?: boolean }) => (
|
||||
<div data-testid="file-uploader" data-readonly={readonly} />
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockTheme = (overrides?: Partial<Theme>): Theme => {
|
||||
const theme = new Theme()
|
||||
theme.primaryColor = 'rgb(255, 0, 0)'
|
||||
return Object.assign(theme, overrides || {})
|
||||
}
|
||||
|
||||
describe('Operation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render send button always', () => {
|
||||
render(<Operation onSend={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file uploader when fileConfig.enabled is true', () => {
|
||||
const fileConfig: FileUpload = { enabled: true } as FileUpload
|
||||
|
||||
render(
|
||||
<Operation
|
||||
onSend={vi.fn()}
|
||||
fileConfig={fileConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('file-uploader')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render file uploader when fileConfig is undefined', () => {
|
||||
render(<Operation onSend={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByTestId('file-uploader')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render voice input button when speechToTextConfig.enabled is true', () => {
|
||||
const speechConfig: EnableType = { enabled: true }
|
||||
|
||||
render(
|
||||
<Operation
|
||||
onSend={vi.fn()}
|
||||
speechToTextConfig={speechConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should not render voice input button when speechToTextConfig.enabled is false', () => {
|
||||
const speechConfig: EnableType = { enabled: false }
|
||||
|
||||
render(
|
||||
<Operation
|
||||
onSend={vi.fn()}
|
||||
speechToTextConfig={speechConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Send Button Behavior', () => {
|
||||
it('should call onSend when clicked and not readonly', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
|
||||
render(<Operation onSend={onSend} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onSend when readonly is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn()
|
||||
|
||||
render(<Operation onSend={onSend} readonly />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply theme primaryColor as background style when theme is provided', () => {
|
||||
render(
|
||||
<Operation
|
||||
onSend={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toHaveStyle({
|
||||
backgroundColor: 'rgb(255, 0, 0)',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not apply background style when theme is null', () => {
|
||||
render(
|
||||
<Operation
|
||||
onSend={vi.fn()}
|
||||
theme={null}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button').style.backgroundColor).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Voice Input Button', () => {
|
||||
it('should call onShowVoiceInput when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowVoiceInput = vi.fn()
|
||||
|
||||
render(
|
||||
<Operation
|
||||
onSend={vi.fn()}
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
onShowVoiceInput={onShowVoiceInput}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons[0]
|
||||
|
||||
await user.click(voiceButton)
|
||||
|
||||
expect(onShowVoiceInput).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable voice button when readonly is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowVoiceInput = vi.fn()
|
||||
|
||||
render(
|
||||
<Operation
|
||||
onSend={vi.fn()}
|
||||
speechToTextConfig={{ enabled: true }}
|
||||
onShowVoiceInput={onShowVoiceInput}
|
||||
readonly
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons[0]
|
||||
|
||||
expect(voiceButton).toBeDisabled()
|
||||
|
||||
await user.click(voiceButton)
|
||||
|
||||
expect(onShowVoiceInput).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -51,6 +51,7 @@ const Operation: FC<OperationProps> = ({
|
||||
size="l"
|
||||
disabled={readonly}
|
||||
onClick={onShowVoiceInput}
|
||||
data-testid="voice-input-button"
|
||||
>
|
||||
<RiMicLine className="h-5 w-5" />
|
||||
</ActionButton>
|
||||
@@ -61,6 +62,7 @@ const Operation: FC<OperationProps> = ({
|
||||
className="ml-3 w-8 px-0"
|
||||
variant="primary"
|
||||
onClick={readonly ? noop : onSend}
|
||||
data-testid="send-button"
|
||||
style={
|
||||
theme
|
||||
? {
|
||||
|
||||
364
web/app/components/base/chat/chat/citation/index.spec.tsx
Normal file
364
web/app/components/base/chat/chat/citation/index.spec.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import type { CitationItem } from '../type'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Citation from './index'
|
||||
|
||||
vi.mock('./popup', () => ({
|
||||
default: ({ data, showHitInfo }: { data: { documentName: string }, showHitInfo?: boolean }) => (
|
||||
<div data-testid="popup" data-show-hit-info={String(!!showHitInfo)}>
|
||||
{data.documentName}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const originalClientWidthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
|
||||
type ClientWidthConfig = {
|
||||
container: number
|
||||
item: number
|
||||
}
|
||||
|
||||
const mockClientWidths = ({ container, item }: ClientWidthConfig) => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
get() {
|
||||
const el = this as HTMLElement
|
||||
if (el.className?.includes?.('chat-answer-container') || el.className?.includes?.('my-custom-container'))
|
||||
return container
|
||||
if (el.dataset?.testid === 'citation-measurement-item')
|
||||
return item
|
||||
return 0
|
||||
},
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
const restoreClientWidth = () => {
|
||||
if (originalClientWidthDescriptor)
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidthDescriptor)
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
restoreClientWidth()
|
||||
})
|
||||
|
||||
const makeCitationItem = (overrides: Partial<CitationItem> = {}): CitationItem => ({
|
||||
document_id: 'doc-1',
|
||||
document_name: 'Document One',
|
||||
data_source_type: 'upload_file',
|
||||
segment_id: 'seg-1',
|
||||
content: 'Some content',
|
||||
dataset_id: 'dataset-1',
|
||||
dataset_name: 'Dataset One',
|
||||
segment_position: 1,
|
||||
word_count: 100,
|
||||
hit_count: 5,
|
||||
index_node_hash: 'abc123',
|
||||
score: 0.95,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const setupContainer = (className = 'chat-answer-container') => {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = className
|
||||
document.body.appendChild(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('Citation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
restoreClientWidth()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the citation title section', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(<Citation data={[makeCitationItem()]} />)
|
||||
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render one measurement ghost item per unique document', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-1', document_name: 'Alpha' }),
|
||||
makeCitationItem({ document_id: 'doc-2', document_name: 'Beta' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should display the document name inside each measurement item', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(<Citation data={[makeCitationItem({ document_name: 'My Report' })]} />)
|
||||
expect(screen.getByTestId('citation-measurement-item')).toHaveTextContent('My Report')
|
||||
})
|
||||
|
||||
it('should render a popup for each resource that fits within the container', () => {
|
||||
mockClientWidths({ container: 840, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-1' }),
|
||||
makeCitationItem({ document_id: 'doc-2' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('popup')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render the citation title i18n key', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(<Citation data={[makeCitationItem()]} />)
|
||||
expect(screen.getByText(/citation\.title/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should use chat-answer-container as the default containerClassName', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(<Citation data={[makeCitationItem()]} />)
|
||||
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use a custom containerClassName to resolve the container element', () => {
|
||||
mockClientWidths({ container: 600, item: 50 })
|
||||
setupContainer('my-custom-container')
|
||||
render(<Citation data={[makeCitationItem()]} containerClassName="my-custom-container" />)
|
||||
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should forward showHitInfo=true to each rendered Popup', () => {
|
||||
mockClientWidths({ container: 840, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation
|
||||
data={[
|
||||
makeCitationItem({ document_id: 'doc-1' }),
|
||||
makeCitationItem({ document_id: 'doc-2' }),
|
||||
]}
|
||||
showHitInfo={true}
|
||||
/>,
|
||||
)
|
||||
screen.getAllByTestId('popup').forEach(p =>
|
||||
expect(p).toHaveAttribute('data-show-hit-info', 'true'),
|
||||
)
|
||||
})
|
||||
|
||||
it('should forward showHitInfo=false when prop is omitted', () => {
|
||||
mockClientWidths({ container: 840, item: 50 })
|
||||
setupContainer()
|
||||
render(<Citation data={[makeCitationItem({ document_id: 'doc-1' })]} />)
|
||||
screen.getAllByTestId('popup').forEach(p =>
|
||||
expect(p).toHaveAttribute('data-show-hit-info', 'false'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Resource Grouping', () => {
|
||||
it('should merge citations with the same document_id into one resource', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'shared', segment_id: 'seg-1' }),
|
||||
makeCitationItem({ document_id: 'shared', segment_id: 'seg-2' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should create a separate resource for each distinct document_id', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-a' }),
|
||||
makeCitationItem({ document_id: 'doc-b' }),
|
||||
makeCitationItem({ document_id: 'doc-c' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should handle mixed shared and unique document_ids correctly', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-x', segment_id: 'seg-1' }),
|
||||
makeCitationItem({ document_id: 'doc-y', segment_id: 'seg-2' }),
|
||||
makeCitationItem({ document_id: 'doc-x', segment_id: 'seg-3' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout Adjustment – all resources fit', () => {
|
||||
it('should show all popups and no more-toggle when every item fits within container', () => {
|
||||
// effective containerWidth = 840 - 40 = 800; each item = 50px → all 3 fit
|
||||
mockClientWidths({ container: 840, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-1' }),
|
||||
makeCitationItem({ document_id: 'doc-2' }),
|
||||
makeCitationItem({ document_id: 'doc-3' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('popup')).toHaveLength(3)
|
||||
expect(screen.queryByTestId('citation-more-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout Adjustment – overflow branch: setLimitNumberInOneLine(i - 1)', () => {
|
||||
it('should show more-toggle when backed-out totalWidth + 34 still exceeds containerWidth', () => {
|
||||
// effective = 140 - 40 = 100
|
||||
// i=0: total=80, 80+0=80 ≤ 100 → limit=1
|
||||
// i=1: total=160, 160+4=164 > 100 → overflow; back-out=80; 80+34=114 > 100 → setLimit(0)
|
||||
// 0 < 2 → toggle shown
|
||||
mockClientWidths({ container: 140, item: 80 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }),
|
||||
makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('citation-more-toggle')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout Adjustment – overflow branch: setLimitNumberInOneLine(i)', () => {
|
||||
it('should show more-toggle and limit=i when backed-out totalWidth + 34 fits within containerWidth', () => {
|
||||
// effective = 240 - 40 = 200
|
||||
// i=0: 80+0=80 ≤ 200 → limit=1
|
||||
// i=1: 160+4=164 ≤ 200 → limit=2
|
||||
// i=2: 240+8=248 > 200 → overflow; back-out=160; 160+34=194 ≤ 200 → setLimit(2)
|
||||
// 2 < 3 → toggle shown; 2 popups visible
|
||||
mockClientWidths({ container: 240, item: 80 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }),
|
||||
makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }),
|
||||
makeCitationItem({ document_id: 'doc-3', document_name: 'Doc C' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('citation-more-toggle')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('popup')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Show More / Show Less Toggle', () => {
|
||||
const renderOverflowScenario = () => {
|
||||
// effective = 140 - 40 = 100; items=80px
|
||||
// i=0: 80 ≤ 100 → limit=1
|
||||
// i=1: 160+4=164 > 100 → overflow; back-out=80; 80+34=114 > 100 → setLimit(0)
|
||||
// 0 < 3 → toggle shown; 0 popups visible (slice(0, 0) = [])
|
||||
mockClientWidths({ container: 140, item: 80 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'doc-1', document_name: 'Doc A' }),
|
||||
makeCitationItem({ document_id: 'doc-2', document_name: 'Doc B' }),
|
||||
makeCitationItem({ document_id: 'doc-3', document_name: 'Doc C' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
return screen.getByTestId('citation-more-toggle')
|
||||
}
|
||||
|
||||
it('should show the overflow count label matching /+\\s*\\d+/ on the more-toggle in collapsed state', () => {
|
||||
renderOverflowScenario()
|
||||
expect(screen.getByTestId('citation-more-toggle').textContent).toMatch(/^\+\s*\d+$/)
|
||||
})
|
||||
|
||||
it('should display the collapse icon div after clicking more-toggle to expand', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOverflowScenario()
|
||||
|
||||
await user.click(screen.getByTestId('citation-more-toggle'))
|
||||
|
||||
expect(document.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return to the count label after clicking the toggle a second time to collapse', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOverflowScenario()
|
||||
|
||||
await user.click(screen.getByTestId('citation-more-toggle'))
|
||||
await user.click(screen.getByTestId('citation-more-toggle'))
|
||||
|
||||
expect(screen.getByTestId('citation-more-toggle').textContent).toMatch(/^\+\s*\d+$/)
|
||||
})
|
||||
|
||||
it('should show all resource popups after expanding via the more-toggle', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderOverflowScenario()
|
||||
|
||||
await user.click(screen.getByTestId('citation-more-toggle'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('popup')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render without crashing when data is an empty array', () => {
|
||||
mockClientWidths({ container: 500, item: 0 })
|
||||
setupContainer()
|
||||
render(<Citation data={[]} />)
|
||||
expect(screen.getByTestId('citation-title')).toBeInTheDocument()
|
||||
expect(screen.queryAllByTestId('citation-measurement-item')).toHaveLength(0)
|
||||
expect(screen.queryByTestId('citation-more-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly with a single citation item that fits', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(<Citation data={[makeCitationItem()]} />)
|
||||
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(1)
|
||||
expect(screen.queryByTestId('citation-more-toggle')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle all citations sharing one document_id as a single resource', () => {
|
||||
mockClientWidths({ container: 500, item: 50 })
|
||||
setupContainer()
|
||||
render(
|
||||
<Citation data={[
|
||||
makeCitationItem({ document_id: 'only', segment_id: 's1' }),
|
||||
makeCitationItem({ document_id: 'only', segment_id: 's2' }),
|
||||
makeCitationItem({ document_id: 'only', segment_id: 's3' }),
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should handle a large number of citation items without throwing', () => {
|
||||
mockClientWidths({ container: 5000, item: 50 })
|
||||
setupContainer()
|
||||
const data = Array.from({ length: 20 }, (_, i) =>
|
||||
makeCitationItem({ document_id: `doc-${i}`, document_name: `Document ${i}` }))
|
||||
expect(() => render(<Citation data={data} />)).not.toThrow()
|
||||
expect(screen.getAllByTestId('citation-measurement-item')).toHaveLength(20)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import type { CitationItem } from '../type'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Popup from './popup'
|
||||
@@ -47,37 +46,36 @@ const Citation: FC<CitationProps> = ({
|
||||
return prev
|
||||
}, []), [data])
|
||||
|
||||
const handleAdjustResourcesLayout = () => {
|
||||
useEffect(() => {
|
||||
const containerWidth = document.querySelector(`.${containerClassName}`)!.clientWidth - 40
|
||||
let totalWidth = 0
|
||||
let limit = 0
|
||||
for (let i = 0; i < resources.length; i++) {
|
||||
totalWidth += elesRef.current[i].clientWidth
|
||||
|
||||
if (totalWidth + i * 4 > containerWidth!) {
|
||||
if (totalWidth + i * 4 > containerWidth) {
|
||||
totalWidth -= elesRef.current[i].clientWidth
|
||||
|
||||
if (totalWidth + 34 > containerWidth!)
|
||||
setLimitNumberInOneLine(i - 1)
|
||||
if (totalWidth + 34 > containerWidth)
|
||||
limit = i - 1
|
||||
else
|
||||
setLimitNumberInOneLine(i)
|
||||
limit = i
|
||||
|
||||
break
|
||||
}
|
||||
else {
|
||||
setLimitNumberInOneLine(i + 1)
|
||||
limit = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
handleAdjustResourcesLayout()
|
||||
setLimitNumberInOneLine(limit)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const resourcesLength = resources.length
|
||||
|
||||
return (
|
||||
<div className="-mb-1 mt-3">
|
||||
<div className="system-xs-medium mb-2 flex items-center text-text-tertiary">
|
||||
<div data-testid="citation-title" className="mb-2 flex items-center text-text-tertiary system-xs-medium">
|
||||
{t('chat.citation.title', { ns: 'common' })}
|
||||
<div className="ml-2 h-px grow bg-divider-regular" />
|
||||
</div>
|
||||
@@ -85,17 +83,18 @@ const Citation: FC<CitationProps> = ({
|
||||
{
|
||||
resources.map((res, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={res.documentId}
|
||||
data-testid="citation-measurement-item"
|
||||
className="absolute left-0 top-0 -z-10 mb-1 mr-1 h-7 w-auto max-w-[240px] whitespace-nowrap pl-7 pr-2 text-xs opacity-0"
|
||||
ref={(ele: any) => (elesRef.current[index] = ele!)}
|
||||
ref={(ele: HTMLDivElement | null) => { elesRef.current[index] = ele! }}
|
||||
>
|
||||
{res.documentName}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{
|
||||
resources.slice(0, showMore ? resourcesLength : limitNumberInOneLine).map((res, index) => (
|
||||
<div key={index} className="mb-1 mr-1 cursor-pointer">
|
||||
resources.slice(0, showMore ? resourcesLength : limitNumberInOneLine).map(res => (
|
||||
<div key={res.documentId} className="mb-1 mr-1 cursor-pointer">
|
||||
<Popup
|
||||
data={res}
|
||||
showHitInfo={showHitInfo}
|
||||
@@ -106,13 +105,14 @@ const Citation: FC<CitationProps> = ({
|
||||
{
|
||||
limitNumberInOneLine < resourcesLength && (
|
||||
<div
|
||||
className="system-xs-medium flex h-7 cursor-pointer items-center rounded-lg bg-components-panel-bg px-2 text-text-tertiary"
|
||||
data-testid="citation-more-toggle"
|
||||
className="flex h-7 cursor-pointer items-center rounded-lg bg-components-panel-bg px-2 text-text-tertiary system-xs-medium"
|
||||
onClick={() => setShowMore(v => !v)}
|
||||
>
|
||||
{
|
||||
!showMore
|
||||
? `+ ${resourcesLength - limitNumberInOneLine}`
|
||||
: <RiArrowDownSLine className="h-4 w-4 rotate-180 text-text-tertiary" />
|
||||
: <div className="i-ri-arrow-down-s-line h-4 w-4 rotate-180 text-text-tertiary" />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
|
||||
609
web/app/components/base/chat/chat/citation/popup.spec.tsx
Normal file
609
web/app/components/base/chat/chat/citation/popup.spec.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
import type { Resources } from './index'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useDocumentDownload } from '@/service/knowledge/use-document'
|
||||
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import Popup from './popup'
|
||||
|
||||
vi.mock('@/service/knowledge/use-document', () => ({
|
||||
useDocumentDownload: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/file-icon', () => ({
|
||||
default: ({ type }: { type: string }) => <div data-testid="file-icon" data-type={type} />,
|
||||
}))
|
||||
|
||||
vi.mock('./progress-tooltip', () => ({
|
||||
default: ({ data }: { data: number }) => <div data-testid="progress-tooltip">{data}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./tooltip', () => ({
|
||||
default: ({ text, data }: { text: string, data: number | string }) => (
|
||||
<div data-testid="citation-tooltip" data-text={text}>{data}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockDownloadDocument = vi.fn()
|
||||
const mockUseDocumentDownload = vi.mocked(useDocumentDownload)
|
||||
const mockDownloadUrl = vi.mocked(downloadUrl)
|
||||
|
||||
const makeSource = (overrides: Partial<Resources['sources'][number]> = {}): Resources['sources'][number] => ({
|
||||
dataset_id: 'ds-1',
|
||||
dataset_name: 'Test Dataset',
|
||||
document_id: 'doc-1',
|
||||
segment_id: 'seg-1',
|
||||
segment_position: 1,
|
||||
content: 'Source content here',
|
||||
word_count: 120,
|
||||
hit_count: 3,
|
||||
index_node_hash: 'abcdef1234567',
|
||||
score: 0.85,
|
||||
data_source_type: 'upload_file',
|
||||
document_name: 'test.pdf',
|
||||
...overrides,
|
||||
} as Resources['sources'][number])
|
||||
|
||||
const makeData = (overrides: Partial<Resources> = {}): Resources => ({
|
||||
documentId: 'doc-1',
|
||||
documentName: 'report.pdf',
|
||||
dataSourceType: 'upload_file',
|
||||
sources: [makeSource()],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const openPopup = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByTestId('popup-trigger'))
|
||||
}
|
||||
|
||||
describe('Popup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseDocumentDownload.mockReturnValue({
|
||||
mutateAsync: mockDownloadDocument,
|
||||
isPending: false,
|
||||
} as unknown as ReturnType<typeof useDocumentDownload>)
|
||||
})
|
||||
|
||||
describe('Rendering – Trigger', () => {
|
||||
it('should render the trigger element', () => {
|
||||
render(<Popup data={makeData()} />)
|
||||
expect(screen.getByTestId('popup-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the document name in the trigger', () => {
|
||||
render(<Popup data={makeData({ documentName: 'My Report.pdf' })} />)
|
||||
expect(screen.getByTestId('popup-trigger')).toHaveTextContent('My Report.pdf')
|
||||
})
|
||||
|
||||
it('should pass the extracted file extension to FileIcon for non-notion sources', () => {
|
||||
render(<Popup data={makeData({ documentName: 'report.pdf', dataSourceType: 'upload_file' })} />)
|
||||
expect(screen.getAllByTestId('file-icon')[0]).toHaveAttribute('data-type', 'pdf')
|
||||
})
|
||||
|
||||
it('should pass notion as fileType to FileIcon for notion sources', () => {
|
||||
render(<Popup data={makeData({ documentName: 'Notion Page', dataSourceType: 'notion' })} />)
|
||||
expect(screen.getAllByTestId('file-icon')[0]).toHaveAttribute('data-type', 'notion')
|
||||
})
|
||||
|
||||
it('should pass empty string as fileType when document has no extension', () => {
|
||||
render(<Popup data={makeData({ documentName: 'nodotfile', dataSourceType: 'upload_file' })} />)
|
||||
expect(screen.getAllByTestId('file-icon')[0]).toHaveAttribute('data-type', '')
|
||||
})
|
||||
|
||||
it('should not render popup content before trigger is clicked', () => {
|
||||
render(<Popup data={makeData()} />)
|
||||
expect(screen.queryByTestId('popup-content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Popup Open / Close', () => {
|
||||
it('should open the popup content on trigger click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close the popup on second trigger click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} />)
|
||||
|
||||
await openPopup(user)
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('popup-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should re-open popup after open → close → open cycle', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} />)
|
||||
|
||||
await openPopup(user)
|
||||
await openPopup(user)
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Popup Header – Download Button', () => {
|
||||
it('should render download button in header for upload_file dataSourceType with dataset_id', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-download-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render download button in header for file dataSourceType with dataset_id', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
dataSourceType: 'file',
|
||||
sources: [makeSource({ data_source_type: 'file', dataset_id: 'ds-1' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-download-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plain document name in header (no button) for notion type', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
documentName: 'Notion Doc',
|
||||
dataSourceType: 'notion',
|
||||
sources: [makeSource({ dataset_id: 'ds-1' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plain document name in header when dataset_id is absent', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
dataSourceType: 'upload_file',
|
||||
sources: [makeSource({ dataset_id: '' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable the download button while isDownloading is true', async () => {
|
||||
mockUseDocumentDownload.mockReturnValue({
|
||||
mutateAsync: mockDownloadDocument,
|
||||
isPending: true,
|
||||
} as unknown as ReturnType<typeof useDocumentDownload>)
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-download-btn')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Source Items', () => {
|
||||
it('should render one source item per source entry', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource(), makeSource({ segment_id: 'seg-2' })] })} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getAllByTestId('popup-source-item')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should render source content text', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ content: 'Unique content text' })] })} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-source-content')).toHaveTextContent('Unique content text')
|
||||
})
|
||||
|
||||
it('should show segment_position when it is truthy', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ segment_position: 7 })] })} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-segment-position')).toHaveTextContent('7')
|
||||
})
|
||||
|
||||
it('should fall back to index + 1 when segment_position is 0', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ segment_position: 0 })] })} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-segment-position')).toHaveTextContent('1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Source Dividers', () => {
|
||||
it('should render a divider between multiple sources', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
sources: [makeSource(), makeSource({ segment_id: 'seg-2' }), makeSource({ segment_id: 'seg-3' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getAllByTestId('popup-source-divider')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should not render any divider for a single source', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource()] })} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('popup-source-divider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render exactly n-1 dividers for n sources', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
sources: [
|
||||
makeSource({ segment_id: 's1' }),
|
||||
makeSource({ segment_id: 's2' }),
|
||||
makeSource({ segment_id: 's3' }),
|
||||
makeSource({ segment_id: 's4' }),
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getAllByTestId('popup-source-divider')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('showHitInfo=false (default)', () => {
|
||||
it('should not render the dataset link when showHitInfo is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} showHitInfo={false} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('popup-dataset-link')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render hit info section when showHitInfo is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} showHitInfo={false} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('popup-hit-info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render Tooltip components when showHitInfo is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} showHitInfo={false} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryAllByTestId('citation-tooltip')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should not render ProgressTooltip when showHitInfo is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData()} showHitInfo={false} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('progress-tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('showHitInfo=true', () => {
|
||||
const dataWithScore = makeData({ sources: [makeSource({ score: 0.85 })] })
|
||||
|
||||
it('should render the dataset link when showHitInfo is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={dataWithScore} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-dataset-link')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the dataset link with correct href', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={dataWithScore} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-dataset-link')).toHaveAttribute(
|
||||
'href',
|
||||
`/datasets/${dataWithScore.sources[0].dataset_id}/documents/${dataWithScore.sources[0].document_id}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('should render the linkToDataset i18n key in the link', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={dataWithScore} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-dataset-link')).toHaveTextContent(/linkToDataset/i)
|
||||
})
|
||||
|
||||
it('should render hit info section when showHitInfo is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={dataWithScore} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('popup-hit-info')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render three Tooltip components (characters, hitCount, vectorHash)', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={dataWithScore} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getAllByTestId('citation-tooltip')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should render ProgressTooltip when source score is greater than 0', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ score: 0.9 })] })} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('progress-tooltip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render ProgressTooltip when source score is 0', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ score: 0 })] })} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('progress-tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass score rounded to 2 decimal places to ProgressTooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ score: 0.856 })] })} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getByTestId('progress-tooltip')).toHaveTextContent('0.86')
|
||||
})
|
||||
|
||||
it('should pass word_count to the characters Tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ word_count: 250 })] })} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
const tooltips = screen.getAllByTestId('citation-tooltip')
|
||||
expect(tooltips[0]).toHaveTextContent('250')
|
||||
})
|
||||
|
||||
it('should pass hit_count to the hitCount Tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ hit_count: 7 })] })} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
const tooltips = screen.getAllByTestId('citation-tooltip')
|
||||
expect(tooltips[1]).toHaveTextContent('7')
|
||||
})
|
||||
|
||||
it('should pass truncated index_node_hash (first 7 chars) to vectorHash Tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ sources: [makeSource({ index_node_hash: 'abcdef1234567' })] })} showHitInfo={true} />)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
const tooltips = screen.getAllByTestId('citation-tooltip')
|
||||
expect(tooltips[2]).toHaveTextContent('abcdef1')
|
||||
})
|
||||
|
||||
it('should render hit info for each source when multiple sources are present', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup
|
||||
data={makeData({
|
||||
sources: [makeSource({ score: 0.9 }), makeSource({ segment_id: 'seg-2', score: 0.7 })],
|
||||
})}
|
||||
showHitInfo={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.getAllByTestId('popup-hit-info')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDownloadUploadFile', () => {
|
||||
it('should call downloadDocument and downloadUrl on successful download', async () => {
|
||||
mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' })
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
|
||||
|
||||
await openPopup(user)
|
||||
await user.click(screen.getByTestId('popup-download-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDownloadDocument).toHaveBeenCalledWith({ datasetId: 'ds-1', documentId: 'doc-1' })
|
||||
expect(mockDownloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/file.pdf', fileName: 'report.pdf' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call downloadUrl when res.url is absent', async () => {
|
||||
mockDownloadDocument.mockResolvedValue({ url: '' })
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
|
||||
|
||||
await openPopup(user)
|
||||
await user.click(screen.getByTestId('popup-download-btn'))
|
||||
|
||||
await waitFor(() => expect(mockDownloadDocument).toHaveBeenCalled())
|
||||
expect(mockDownloadUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call downloadDocument when dataSourceType is not upload_file or file', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
dataSourceType: 'notion',
|
||||
sources: [makeSource({ dataset_id: 'ds-1' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('popup-download-btn')).not.toBeInTheDocument()
|
||||
expect(mockDownloadDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call downloadDocument when isDownloading is true', async () => {
|
||||
mockUseDocumentDownload.mockReturnValue({
|
||||
mutateAsync: mockDownloadDocument,
|
||||
isPending: true,
|
||||
} as unknown as ReturnType<typeof useDocumentDownload>)
|
||||
const user = userEvent.setup()
|
||||
render(<Popup data={makeData({ dataSourceType: 'upload_file' })} />)
|
||||
|
||||
await openPopup(user)
|
||||
await user.click(screen.getByTestId('popup-download-btn'))
|
||||
|
||||
expect(mockDownloadDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use documentId from data.documentId as priority over sources[0].document_id', async () => {
|
||||
mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' })
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
documentId: 'primary-doc-id',
|
||||
dataSourceType: 'upload_file',
|
||||
sources: [makeSource({ document_id: 'fallback-doc-id', dataset_id: 'ds-1' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
await user.click(screen.getByTestId('popup-download-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDownloadDocument).toHaveBeenCalledWith({ datasetId: 'ds-1', documentId: 'primary-doc-id' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should work with file dataSourceType the same as upload_file', async () => {
|
||||
mockDownloadDocument.mockResolvedValue({ url: 'https://example.com/file.pdf' })
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
dataSourceType: 'file',
|
||||
sources: [makeSource({ data_source_type: 'file', dataset_id: 'ds-1' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
await user.click(screen.getByTestId('popup-download-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDownloadDocument).toHaveBeenCalled()
|
||||
expect(mockDownloadUrl).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call downloadDocument when both data.documentId and sources[0].document_id are empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup data={makeData({
|
||||
documentId: '',
|
||||
dataSourceType: 'upload_file',
|
||||
sources: [makeSource({ document_id: '', dataset_id: 'ds-1' })],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
await user.click(screen.getByTestId('popup-download-btn'))
|
||||
|
||||
expect(mockDownloadDocument).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render without crashing with minimum required props', () => {
|
||||
expect(() => render(<Popup data={makeData()} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render without crashing with an empty sources array', () => {
|
||||
expect(() => render(<Popup data={makeData({ sources: [] })} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render correctly when source has no score (undefined)', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup
|
||||
data={makeData({
|
||||
sources: [makeSource({ score: undefined })],
|
||||
})}
|
||||
showHitInfo={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
expect(screen.queryByTestId('progress-tooltip')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render correctly when index_node_hash is undefined', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Popup
|
||||
data={makeData({
|
||||
sources: [makeSource({ index_node_hash: undefined })],
|
||||
})}
|
||||
showHitInfo={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openPopup(user)
|
||||
|
||||
const tooltips = screen.getAllByTestId('citation-tooltip')
|
||||
expect(tooltips[2]).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,15 +4,6 @@ import Link from 'next/link'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import FileIcon from '@/app/components/base/file-icon'
|
||||
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import {
|
||||
BezierCurve03,
|
||||
TypeSquare,
|
||||
} from '@/app/components/base/icons/src/vender/line/editor'
|
||||
import {
|
||||
Hash02,
|
||||
Target04,
|
||||
} from '@/app/components/base/icons/src/vender/line/general'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
@@ -40,23 +31,16 @@ const Popup: FC<PopupProps> = ({
|
||||
|
||||
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
|
||||
|
||||
/**
|
||||
* Download the original uploaded file for citations whose data source is upload-file.
|
||||
* We request a signed URL from the dataset document download endpoint, then trigger browser download.
|
||||
*/
|
||||
const handleDownloadUploadFile = async (e: MouseEvent<HTMLElement>) => {
|
||||
// Prevent toggling the citation popup when user clicks the download link.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Only upload-file citations can be downloaded this way (needs dataset/document ids).
|
||||
const isUploadFile = data.dataSourceType === 'upload_file' || data.dataSourceType === 'file'
|
||||
const datasetId = data.sources?.[0]?.dataset_id
|
||||
const documentId = data.documentId || data.sources?.[0]?.document_id
|
||||
if (!isUploadFile || !datasetId || !documentId || isDownloading)
|
||||
return
|
||||
|
||||
// Fetch signed URL (usually points to `/files/<id>/file-preview?...&as_attachment=true`).
|
||||
const res = await downloadDocument({ datasetId, documentId })
|
||||
if (res?.url)
|
||||
downloadUrl({ url: res.url, fileName: data.documentName })
|
||||
@@ -73,22 +57,21 @@ const Popup: FC<PopupProps> = ({
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
|
||||
<div data-testid="popup-trigger" className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
|
||||
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
||||
{/* Keep the trigger purely for opening the popup (no download link here). */}
|
||||
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
||||
<div className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]">
|
||||
<div data-testid="popup-content" className="max-w-[360px] rounded-xl bg-background-section-burn shadow-lg backdrop-blur-[5px]">
|
||||
<div className="px-4 pb-2 pt-3">
|
||||
<div className="flex h-[18px] items-center">
|
||||
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
|
||||
<div className="system-xs-medium truncate text-text-tertiary">
|
||||
{/* If it's an upload-file reference, the title becomes a download link. */}
|
||||
<div className="truncate text-text-tertiary system-xs-medium">
|
||||
{(data.dataSourceType === 'upload_file' || data.dataSourceType === 'file') && !!data.sources?.[0]?.dataset_id
|
||||
? (
|
||||
<button
|
||||
data-testid="popup-download-btn"
|
||||
type="button"
|
||||
className="cursor-pointer truncate text-text-tertiary hover:underline"
|
||||
onClick={handleDownloadUploadFile}
|
||||
@@ -104,63 +87,71 @@ const Popup: FC<PopupProps> = ({
|
||||
<div className="max-h-[450px] overflow-y-auto rounded-lg bg-components-panel-bg px-4 py-0.5">
|
||||
<div className="w-full">
|
||||
{
|
||||
data.sources.map((source, index) => (
|
||||
<Fragment key={index}>
|
||||
<div className="group py-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex h-5 items-center rounded-md border border-divider-subtle px-1.5">
|
||||
<Hash02 className="mr-0.5 h-3 w-3 text-text-quaternary" />
|
||||
<div className="text-[11px] font-medium text-text-tertiary">
|
||||
{source.segment_position || index + 1}
|
||||
data.sources.map((source, index) => {
|
||||
const itemKey = source.document_id
|
||||
? `${source.document_id}-${source.segment_position ?? index}`
|
||||
: source.index_node_hash ?? `${data.documentId ?? 'doc'}-${index}`
|
||||
|
||||
return (
|
||||
<Fragment key={itemKey}>
|
||||
<div data-testid="popup-source-item" className="group py-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex h-5 items-center rounded-md border border-divider-subtle px-1.5">
|
||||
{/* replaced svg component with tailwind icon class per lint rule */}
|
||||
<i className="i-custom-vender-line-general-hash-02 mr-0.5 h-3 w-3 text-text-quaternary" aria-hidden />
|
||||
<div data-testid="popup-segment-position" className="text-[11px] font-medium text-text-tertiary">
|
||||
{source.segment_position || index + 1}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
showHitInfo && (
|
||||
<Link
|
||||
data-testid="popup-dataset-link"
|
||||
href={`/datasets/${source.dataset_id}/documents/${source.document_id}`}
|
||||
className="hidden h-[18px] items-center text-xs text-text-accent group-hover:flex"
|
||||
>
|
||||
{t('chat.citation.linkToDataset', { ns: 'common' })}
|
||||
<i className="i-custom-vender-line-arrows-arrow-up-right ml-1 h-3 w-3" aria-hidden />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div data-testid="popup-source-content" className="break-words text-[13px] text-text-secondary">{source.content}</div>
|
||||
{
|
||||
showHitInfo && (
|
||||
<Link
|
||||
href={`/datasets/${source.dataset_id}/documents/${source.document_id}`}
|
||||
className="hidden h-[18px] items-center text-xs text-text-accent group-hover:flex"
|
||||
>
|
||||
{t('chat.citation.linkToDataset', { ns: 'common' })}
|
||||
<ArrowUpRight className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
<div data-testid="popup-hit-info" className="mt-2 flex flex-wrap items-center text-text-quaternary system-xs-medium">
|
||||
<Tooltip
|
||||
text={t('chat.citation.characters', { ns: 'common' })}
|
||||
data={source.word_count}
|
||||
icon={<i className="i-custom-vender-line-editor-type-square mr-1 h-3 w-3" aria-hidden />}
|
||||
/>
|
||||
<Tooltip
|
||||
text={t('chat.citation.hitCount', { ns: 'common' })}
|
||||
data={source.hit_count}
|
||||
icon={<i className="i-custom-vender-line-general-target-04 mr-1 h-3 w-3" aria-hidden />}
|
||||
/>
|
||||
<Tooltip
|
||||
text={t('chat.citation.vectorHash', { ns: 'common' })}
|
||||
data={source.index_node_hash?.substring(0, 7)}
|
||||
icon={<i className="i-custom-vender-line-editor-bezier-curve-03 mr-1 h-3 w-3" aria-hidden />}
|
||||
/>
|
||||
{
|
||||
!!source.score && (
|
||||
<ProgressTooltip data={Number(source.score.toFixed(2))} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="break-words text-[13px] text-text-secondary">{source.content}</div>
|
||||
{
|
||||
showHitInfo && (
|
||||
<div className="system-xs-medium mt-2 flex flex-wrap items-center text-text-quaternary">
|
||||
<Tooltip
|
||||
text={t('chat.citation.characters', { ns: 'common' })}
|
||||
data={source.word_count}
|
||||
icon={<TypeSquare className="mr-1 h-3 w-3" />}
|
||||
/>
|
||||
<Tooltip
|
||||
text={t('chat.citation.hitCount', { ns: 'common' })}
|
||||
data={source.hit_count}
|
||||
icon={<Target04 className="mr-1 h-3 w-3" />}
|
||||
/>
|
||||
<Tooltip
|
||||
text={t('chat.citation.vectorHash', { ns: 'common' })}
|
||||
data={source.index_node_hash?.substring(0, 7)}
|
||||
icon={<BezierCurve03 className="mr-1 h-3 w-3" />}
|
||||
/>
|
||||
{
|
||||
!!source.score && (
|
||||
<ProgressTooltip data={Number(source.score.toFixed(2))} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
index !== data.sources.length - 1 && (
|
||||
<div data-testid="popup-source-divider" className="my-1 h-px bg-divider-regular" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
index !== data.sources.length - 1 && (
|
||||
<div className="my-1 h-px bg-divider-regular" />
|
||||
)
|
||||
}
|
||||
</Fragment>
|
||||
))
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ProgressTooltip from './progress-tooltip'
|
||||
|
||||
describe('ProgressTooltip', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render the trigger content', () => {
|
||||
render(<ProgressTooltip data={0.75} />)
|
||||
expect(screen.getByTestId('progress-trigger-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the data value in the trigger', () => {
|
||||
render(<ProgressTooltip data={0.75} />)
|
||||
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.75')
|
||||
})
|
||||
|
||||
it('should render the progress bar fill element', () => {
|
||||
render(<ProgressTooltip data={0.5} />)
|
||||
expect(screen.getByTestId('progress-bar-fill')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render the tooltip popup before hovering', () => {
|
||||
render(<ProgressTooltip data={0.5} />)
|
||||
expect(screen.queryByTestId('progress-tooltip-popup')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Progress Bar Width', () => {
|
||||
it('should set fill width to data * 100 percent', () => {
|
||||
render(<ProgressTooltip data={0.75} />)
|
||||
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '75%' })
|
||||
})
|
||||
|
||||
it('should set fill width to 0% when data is 0', () => {
|
||||
render(<ProgressTooltip data={0} />)
|
||||
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '0%' })
|
||||
})
|
||||
|
||||
it('should set fill width to 100% when data is 1', () => {
|
||||
render(<ProgressTooltip data={1} />)
|
||||
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '100%' })
|
||||
})
|
||||
|
||||
it('should set fill width to 50% when data is 0.5', () => {
|
||||
render(<ProgressTooltip data={0.5} />)
|
||||
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '50%' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip Visibility', () => {
|
||||
it('should show the tooltip popup on mouse enter', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ProgressTooltip data={0.8} />)
|
||||
|
||||
await user.hover(screen.getByTestId('progress-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('progress-tooltip-popup')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the tooltip popup on mouse leave', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ProgressTooltip data={0.8} />)
|
||||
|
||||
await user.hover(screen.getByTestId('progress-trigger-content'))
|
||||
await user.unhover(screen.getByTestId('progress-trigger-content'))
|
||||
|
||||
expect(screen.queryByTestId('progress-tooltip-popup')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the hitScore i18n key in the tooltip', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ProgressTooltip data={0.8} />)
|
||||
|
||||
await user.hover(screen.getByTestId('progress-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('progress-tooltip-popup')).toHaveTextContent(/hitScore/i)
|
||||
})
|
||||
|
||||
it('should show the data value inside the tooltip popup', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ProgressTooltip data={0.8} />)
|
||||
|
||||
await user.hover(screen.getByTestId('progress-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('progress-tooltip-popup')).toHaveTextContent('0.8')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should render correctly with a small fractional value', () => {
|
||||
render(<ProgressTooltip data={0.12} />)
|
||||
expect(screen.getByTestId('progress-bar-fill').getAttribute('style')).toMatch(/width:\s*12/)
|
||||
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.12')
|
||||
})
|
||||
|
||||
it('should render correctly with a value close to 1', () => {
|
||||
render(<ProgressTooltip data={0.99} />)
|
||||
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '99%' })
|
||||
})
|
||||
|
||||
it('should update displayed data when prop changes', () => {
|
||||
const { rerender } = render(<ProgressTooltip data={0.3} />)
|
||||
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.3')
|
||||
|
||||
rerender(<ProgressTooltip data={0.9} />)
|
||||
expect(screen.getByTestId('progress-trigger-content')).toHaveTextContent('0.9')
|
||||
expect(screen.getByTestId('progress-bar-fill')).toHaveStyle({ width: '90%' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render without crashing when data is exactly 0', () => {
|
||||
expect(() => render(<ProgressTooltip data={0} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render without crashing when data is exactly 1', () => {
|
||||
expect(() => render(<ProgressTooltip data={1} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should re-show tooltip after hover → unhover → hover cycle', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ProgressTooltip data={0.5} />)
|
||||
|
||||
await user.hover(screen.getByTestId('progress-trigger-content'))
|
||||
await user.unhover(screen.getByTestId('progress-trigger-content'))
|
||||
await user.hover(screen.getByTestId('progress-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('progress-tooltip-popup')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep tooltip closed without any interaction', () => {
|
||||
render(<ProgressTooltip data={0.42} />)
|
||||
expect(screen.queryByTestId('progress-tooltip-popup')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not call any external handlers by default', () => {
|
||||
const consoleError = vi.spyOn(console, 'error')
|
||||
render(<ProgressTooltip data={0.5} />)
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -27,15 +27,20 @@ const ProgressTooltip: FC<ProgressTooltipProps> = ({
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<div className="flex grow items-center">
|
||||
<div data-testid="progress-trigger-content" className="flex grow items-center">
|
||||
<div className="mr-1 h-1.5 w-16 overflow-hidden rounded-[3px] border border-components-progress-gray-border">
|
||||
<div className="h-full bg-components-progress-gray-progress" style={{ width: `${data * 100}%` }}></div>
|
||||
<div
|
||||
data-testid="progress-bar-fill"
|
||||
className="h-full bg-components-progress-gray-progress"
|
||||
style={{ width: `${data * 100}%` }}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{data}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
|
||||
<div className="system-xs-medium rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg">
|
||||
<div data-testid="progress-tooltip-popup" className="rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg system-xs-medium">
|
||||
{t('chat.citation.hitScore', { ns: 'common' })}
|
||||
{' '}
|
||||
{data}
|
||||
|
||||
155
web/app/components/base/chat/chat/citation/tooltip.spec.tsx
Normal file
155
web/app/components/base/chat/chat/citation/tooltip.spec.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Tooltip from './tooltip'
|
||||
|
||||
const renderTooltip = (data: number | string = 42, text = 'Characters', icon = <span data-testid="mock-icon">icon</span>) =>
|
||||
render(<Tooltip data={data} text={text} icon={icon} />)
|
||||
|
||||
describe('Tooltip', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render the trigger content wrapper', () => {
|
||||
renderTooltip()
|
||||
expect(screen.getByTestId('tooltip-trigger-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the icon inside the trigger', () => {
|
||||
renderTooltip(42, 'Characters', <span data-testid="mock-icon">icon</span>)
|
||||
expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a numeric data value in the trigger', () => {
|
||||
renderTooltip(123)
|
||||
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('123')
|
||||
})
|
||||
|
||||
it('should render a string data value in the trigger', () => {
|
||||
renderTooltip('abc123')
|
||||
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('abc123')
|
||||
})
|
||||
|
||||
it('should not render the tooltip popup before hovering', () => {
|
||||
renderTooltip()
|
||||
expect(screen.queryByTestId('tooltip-popup')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should render the provided text label when tooltip is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTooltip(10, 'Word Count')
|
||||
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Word Count')
|
||||
})
|
||||
|
||||
it('should render the data value inside the tooltip popup', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTooltip(99, 'Hit Count')
|
||||
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('99')
|
||||
})
|
||||
|
||||
it('should render a string data value inside the tooltip popup', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTooltip('abc1234', 'Vector Hash')
|
||||
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('abc1234')
|
||||
})
|
||||
|
||||
it('should render both text and data together inside the tooltip popup', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTooltip(55, 'Characters')
|
||||
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
|
||||
const popup = screen.getByTestId('tooltip-popup')
|
||||
expect(popup).toHaveTextContent('Characters')
|
||||
expect(popup).toHaveTextContent('55')
|
||||
})
|
||||
|
||||
it('should render any arbitrary ReactNode as icon', () => {
|
||||
render(<Tooltip data={1} text="text" icon={<div data-testid="custom-icon">★</div>} />)
|
||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update displayed data when prop changes', () => {
|
||||
const { rerender } = render(<Tooltip data={10} text="Words" icon={<span />} />)
|
||||
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('10')
|
||||
|
||||
rerender(<Tooltip data={20} text="Words" icon={<span />} />)
|
||||
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('20')
|
||||
})
|
||||
|
||||
it('should update displayed text in popup when prop changes and tooltip is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(<Tooltip data={10} text="Original" icon={<span />} />)
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Original')
|
||||
|
||||
rerender(<Tooltip data={10} text="Updated" icon={<span />} />)
|
||||
expect(screen.getByTestId('tooltip-popup')).toHaveTextContent('Updated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip Visibility', () => {
|
||||
it('should show the tooltip popup on mouse enter', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTooltip()
|
||||
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('tooltip-popup')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the tooltip popup on mouse leave', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTooltip()
|
||||
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
await user.unhover(screen.getByTestId('tooltip-trigger-content'))
|
||||
|
||||
expect(screen.queryByTestId('tooltip-popup')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should re-show tooltip after hover → unhover → hover cycle', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTooltip()
|
||||
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
await user.unhover(screen.getByTestId('tooltip-trigger-content'))
|
||||
await user.hover(screen.getByTestId('tooltip-trigger-content'))
|
||||
|
||||
expect(screen.getByTestId('tooltip-popup')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render without crashing when data is 0', () => {
|
||||
expect(() => render(<Tooltip data={0} text="score" icon={<span />} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render without crashing when data is an empty string', () => {
|
||||
expect(() => render(<Tooltip data="" text="label" icon={<span />} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render without crashing when text is an empty string', () => {
|
||||
expect(() => render(<Tooltip data={1} text="" icon={<span />} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should keep tooltip closed without any interaction', () => {
|
||||
renderTooltip(0.5)
|
||||
expect(screen.queryByTestId('tooltip-popup')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render data value 0 in the trigger', () => {
|
||||
render(<Tooltip data={0} text="score" icon={<span />} />)
|
||||
expect(screen.getByTestId('tooltip-trigger-content')).toHaveTextContent('0')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -30,13 +30,13 @@ const Tooltip: FC<TooltipProps> = ({
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<div className="mr-6 flex items-center">
|
||||
<div data-testid="tooltip-trigger-content" className="mr-6 flex items-center">
|
||||
{icon}
|
||||
{data}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
|
||||
<div className="system-xs-medium rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg">
|
||||
<div data-testid="tooltip-popup" className="rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg system-xs-medium">
|
||||
{text}
|
||||
{' '}
|
||||
{data}
|
||||
|
||||
79
web/app/components/base/chat/chat/content-switch.spec.tsx
Normal file
79
web/app/components/base/chat/chat/content-switch.spec.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import ContentSwitch from './content-switch'
|
||||
|
||||
describe('ContentSwitch', () => {
|
||||
const defaultProps = {
|
||||
count: 3,
|
||||
currentIndex: 1,
|
||||
prevDisabled: false,
|
||||
nextDisabled: false,
|
||||
switchSibling: vi.fn(),
|
||||
}
|
||||
|
||||
it('renders nothing when count is 1 or less', () => {
|
||||
const { container } = render(<ContentSwitch {...defaultProps} count={1} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing when currentIndex is undefined', () => {
|
||||
const { container } = render(<ContentSwitch {...defaultProps} currentIndex={undefined} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders correctly with current page and total count', () => {
|
||||
render(<ContentSwitch {...defaultProps} currentIndex={0} count={5} />)
|
||||
expect(screen.getByText(/1[^\n\r/\u2028\u2029]*\/.*5/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls switchSibling with "prev" when left button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const switchSibling = vi.fn()
|
||||
render(<ContentSwitch {...defaultProps} switchSibling={switchSibling} />)
|
||||
|
||||
const prevButton = screen.getByRole('button', { name: /previous/i })
|
||||
await user.click(prevButton)
|
||||
|
||||
expect(switchSibling).toHaveBeenCalledWith('prev')
|
||||
})
|
||||
|
||||
it('calls switchSibling with "next" when right button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const switchSibling = vi.fn()
|
||||
render(<ContentSwitch {...defaultProps} switchSibling={switchSibling} />)
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /next/i })
|
||||
await user.click(nextButton)
|
||||
|
||||
expect(switchSibling).toHaveBeenCalledWith('next')
|
||||
})
|
||||
|
||||
it('applies disabled styles and prevents clicks when prevDisabled is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
const switchSibling = vi.fn()
|
||||
render(<ContentSwitch {...defaultProps} prevDisabled={true} switchSibling={switchSibling} />)
|
||||
|
||||
const prevButton = screen.getByRole('button', { name: /previous/i })
|
||||
|
||||
expect(prevButton).toHaveClass('opacity-30')
|
||||
expect(prevButton).toBeDisabled()
|
||||
|
||||
await user.click(prevButton)
|
||||
expect(switchSibling).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies disabled styles and prevents clicks when nextDisabled is true', async () => {
|
||||
const user = userEvent.setup()
|
||||
const switchSibling = vi.fn()
|
||||
render(<ContentSwitch {...defaultProps} nextDisabled={true} switchSibling={switchSibling} />)
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /next/i })
|
||||
|
||||
expect(nextButton).toHaveClass('opacity-30')
|
||||
expect(nextButton).toBeDisabled()
|
||||
|
||||
await user.click(nextButton)
|
||||
expect(switchSibling).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@ export default function ContentSwitch({
|
||||
<div className="flex items-center justify-center pt-3.5 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous" // Added for accessibility and testing
|
||||
className={`${prevDisabled ? 'opacity-30' : 'opacity-100'}`}
|
||||
disabled={prevDisabled}
|
||||
onClick={() => !prevDisabled && switchSibling('prev')}
|
||||
@@ -32,6 +33,7 @@ export default function ContentSwitch({
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next" // Added for accessibility and testing
|
||||
className={`${nextDisabled ? 'opacity-30' : 'opacity-100'}`}
|
||||
disabled={nextDisabled}
|
||||
onClick={() => !nextDisabled && switchSibling('next')}
|
||||
|
||||
94
web/app/components/base/chat/chat/context.spec.tsx
Normal file
94
web/app/components/base/chat/chat/context.spec.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { ChatItem } from '../types'
|
||||
import type { ChatContextValue } from './context'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi } from 'vitest'
|
||||
import { ChatContextProvider, useChatContext } from './context'
|
||||
|
||||
const TestConsumer = () => {
|
||||
const context = useChatContext()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="isResponding">{String(context.isResponding)}</div>
|
||||
<div data-testid="readonly">{String(context.readonly)}</div>
|
||||
<div data-testid="chatListCount">{context.chatList.length}</div>
|
||||
<div data-testid="questionIcon">{context.questionIcon}</div>
|
||||
<button onClick={() => context.onSend?.('test message', [])}>Send Button</button>
|
||||
<button onClick={() => context.onRegenerate?.({ id: '1' } as ChatItem, { message: 'retry' })}>Regenerate Button</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ChatContextProvider', () => {
|
||||
const mockOnSend = vi.fn()
|
||||
const mockOnRegenerate = vi.fn()
|
||||
|
||||
const defaultProps: ChatContextValue = {
|
||||
config: {} as ChatContextValue['config'],
|
||||
isResponding: false,
|
||||
chatList: [{ id: '1', content: 'hello' } as ChatItem],
|
||||
showPromptLog: false,
|
||||
questionIcon: <span data-testid="custom-icon">Icon</span>,
|
||||
answerIcon: null,
|
||||
onSend: mockOnSend,
|
||||
onRegenerate: mockOnRegenerate,
|
||||
onAnnotationEdited: vi.fn(),
|
||||
onAnnotationAdded: vi.fn(),
|
||||
onAnnotationRemoved: vi.fn(),
|
||||
disableFeedback: false,
|
||||
onFeedback: vi.fn(),
|
||||
getHumanInputNodeData: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should provide context values to children', () => {
|
||||
render(
|
||||
<ChatContextProvider {...defaultProps} readonly={true}>
|
||||
<TestConsumer />
|
||||
</ChatContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('isResponding')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('readonly')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('chatListCount')).toHaveTextContent('1')
|
||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use default values for optional props', () => {
|
||||
const minimalProps = { ...defaultProps, chatList: undefined as unknown as ChatItem[] }
|
||||
|
||||
render(
|
||||
<ChatContextProvider {...minimalProps}>
|
||||
<TestConsumer />
|
||||
</ChatContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('chatListCount')).toHaveTextContent('0')
|
||||
expect(screen.getByTestId('readonly')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should handle callbacks correctly via the hook', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<ChatContextProvider {...defaultProps}>
|
||||
<TestConsumer />
|
||||
</ChatContextProvider>,
|
||||
)
|
||||
|
||||
const sendBtn = screen.getByRole('button', { name: /send button/i })
|
||||
const regenBtn = screen.getByRole('button', { name: /regenerate button/i })
|
||||
|
||||
await user.click(sendBtn)
|
||||
expect(mockOnSend).toHaveBeenCalledWith('test message', [])
|
||||
|
||||
await user.click(regenBtn)
|
||||
expect(mockOnRegenerate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1' }),
|
||||
expect.objectContaining({ message: 'retry' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
606
web/app/components/base/chat/chat/index.spec.tsx
Normal file
606
web/app/components/base/chat/chat/index.spec.tsx
Normal file
@@ -0,0 +1,606 @@
|
||||
import type { ChatConfig, ChatItem, OnSend } from '../types'
|
||||
import type { ChatProps } from './index'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Chat from './index'
|
||||
|
||||
// ─── Why each mock exists ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Answer – transitively pulls Markdown (rehype/remark/katex), AgentContent,
|
||||
// WorkflowProcessItem and Operation; none can resolve in jsdom.
|
||||
// Question – pulls Markdown, copy-to-clipboard, react-textarea-autosize.
|
||||
// ChatInputArea – pulls js-audio-recorder (requires Web Audio API unavailable in
|
||||
// jsdom) and VoiceInput / FileContextProvider chains.
|
||||
// PromptLogModal– pulls CopyFeedbackNew and deep modal dep chain.
|
||||
// AgentLogModal – pulls @remixicon/react (causes lint push error), useClickAway
|
||||
// from ahooks, and AgentLogDetail (workflow graph renderer).
|
||||
// es-toolkit/compat – debounce must return a fn with .cancel() or the cleanup
|
||||
// effect throws on unmount.
|
||||
//
|
||||
// NOT mocked (run real):
|
||||
// ChatContextProvider – plain context wrapper, zero side-effects.
|
||||
// TryToAsk – only uses Button (base), Divider (base), i18n (global mock).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('./answer', () => ({
|
||||
default: ({ item, responding }: { item: ChatItem, responding?: boolean }) => (
|
||||
<div
|
||||
data-testid="answer-item"
|
||||
data-id={item.id}
|
||||
data-responding={String(!!responding)}
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./question', () => ({
|
||||
default: ({ item }: { item: ChatItem }) => (
|
||||
<div data-testid="question-item" data-id={item.id}>{item.content}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./chat-input-area', () => ({
|
||||
default: ({ disabled, readonly }: { disabled?: boolean, readonly?: boolean }) => (
|
||||
<div
|
||||
data-testid="chat-input-area"
|
||||
data-disabled={String(!!disabled)}
|
||||
data-readonly={String(!!readonly)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/prompt-log-modal', () => ({
|
||||
default: ({ onCancel }: { onCancel: () => void }) => (
|
||||
<div data-testid="prompt-log-modal">
|
||||
<button data-testid="prompt-log-cancel" onClick={onCancel}>cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/agent-log-modal', () => ({
|
||||
default: ({ onCancel }: { onCancel: () => void }) => (
|
||||
<div data-testid="agent-log-modal">
|
||||
<button data-testid="agent-log-cancel" onClick={onCancel}>cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('es-toolkit/compat', () => ({
|
||||
debounce: (fn: (...args: unknown[]) => void) => {
|
||||
const debounced = (...args: unknown[]) => fn(...args)
|
||||
debounced.cancel = vi.fn()
|
||||
return debounced
|
||||
},
|
||||
}))
|
||||
|
||||
// ─── ResizeObserver capture ───────────────────────────────────────────────────
|
||||
|
||||
type ResizeCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void
|
||||
let capturedResizeCallbacks: ResizeCallback[] = []
|
||||
|
||||
const makeResizeEntry = (blockSize: number, inlineSize: number): ResizeObserverEntry => ({
|
||||
borderBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize],
|
||||
contentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize],
|
||||
contentRect: new DOMRect(0, 0, inlineSize, blockSize),
|
||||
devicePixelContentBoxSize: [{ blockSize, inlineSize } as ResizeObserverSize],
|
||||
target: document.createElement('div'),
|
||||
})
|
||||
|
||||
// ─── Factories ────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeChatItem = (overrides: Partial<ChatItem> = {}): ChatItem => ({
|
||||
id: `item-${Math.random().toString(36).slice(2)}`,
|
||||
content: 'Test content',
|
||||
isAnswer: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const mockSetCurrentLogItem = vi.fn()
|
||||
const mockSetShowPromptLogModal = vi.fn()
|
||||
const mockSetShowAgentLogModal = vi.fn()
|
||||
|
||||
const baseStoreState = {
|
||||
currentLogItem: undefined,
|
||||
setCurrentLogItem: mockSetCurrentLogItem,
|
||||
showPromptLogModal: false,
|
||||
setShowPromptLogModal: mockSetShowPromptLogModal,
|
||||
showAgentLogModal: false,
|
||||
setShowAgentLogModal: mockSetShowAgentLogModal,
|
||||
}
|
||||
|
||||
const renderChat = (props: Partial<ChatProps> = {}) =>
|
||||
render(<Chat chatList={[]} {...props} />)
|
||||
|
||||
// ─── Suite ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Chat', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedResizeCallbacks = []
|
||||
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
cb(0)
|
||||
return 0
|
||||
})
|
||||
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
private cb: ResizeCallback
|
||||
constructor(cb: ResizeCallback) {
|
||||
this.cb = cb
|
||||
capturedResizeCallbacks.push(cb)
|
||||
}
|
||||
|
||||
observe() { }
|
||||
unobserve() { }
|
||||
disconnect() { }
|
||||
})
|
||||
|
||||
useAppStore.setState(baseStoreState)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing with an empty chatList', () => {
|
||||
renderChat()
|
||||
expect(screen.getByTestId('chat-root')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render chatNode when provided', () => {
|
||||
renderChat({ chatNode: <div data-testid="slot-node">slot</div> })
|
||||
expect(screen.getByTestId('slot-node')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply flex-col to root when isTryApp=true', () => {
|
||||
renderChat({ isTryApp: true })
|
||||
expect(screen.getByTestId('chat-root')).toHaveClass('flex', 'flex-col')
|
||||
})
|
||||
|
||||
it('should not have flex-col when isTryApp is falsy', () => {
|
||||
renderChat({ isTryApp: false })
|
||||
expect(screen.getByTestId('chat-root')).not.toHaveClass('flex-col')
|
||||
})
|
||||
|
||||
it('should apply chatContainerClassName to the scroll container', () => {
|
||||
renderChat({ chatContainerClassName: 'my-custom-class' })
|
||||
expect(screen.getByTestId('chat-container')).toHaveClass('my-custom-class')
|
||||
})
|
||||
|
||||
it('should apply px-8 spacing by default', () => {
|
||||
const { container } = renderChat({ noSpacing: false })
|
||||
expect(container.querySelector('.w-full')).toHaveClass('px-8')
|
||||
})
|
||||
|
||||
it('should omit px-8 when noSpacing=true', () => {
|
||||
const { container } = renderChat({ noSpacing: true })
|
||||
expect(container.querySelector('.w-full')).not.toHaveClass('px-8')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chat List', () => {
|
||||
it('should render a Question for a non-answer item', () => {
|
||||
renderChat({ chatList: [makeChatItem({ id: 'q1', isAnswer: false })] })
|
||||
expect(screen.getByTestId('question-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render an Answer for an answer item', () => {
|
||||
renderChat({ chatList: [makeChatItem({ id: 'a1', isAnswer: true })] })
|
||||
expect(screen.getByTestId('answer-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both Question and Answer from a mixed chatList', () => {
|
||||
renderChat({
|
||||
chatList: [
|
||||
makeChatItem({ id: 'q1', isAnswer: false }),
|
||||
makeChatItem({ id: 'a1', isAnswer: true }),
|
||||
],
|
||||
})
|
||||
expect(screen.getByTestId('question-item')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('answer-item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass responding=true only to the last answer when isResponding=true', () => {
|
||||
renderChat({
|
||||
isResponding: true,
|
||||
chatList: [
|
||||
makeChatItem({ id: 'q1', isAnswer: false }),
|
||||
makeChatItem({ id: 'a1', isAnswer: true }),
|
||||
makeChatItem({ id: 'q2', isAnswer: false }),
|
||||
makeChatItem({ id: 'a2', isAnswer: true }),
|
||||
],
|
||||
})
|
||||
const answers = screen.getAllByTestId('answer-item')
|
||||
expect(answers[0]).toHaveAttribute('data-responding', 'false')
|
||||
expect(answers[1]).toHaveAttribute('data-responding', 'true')
|
||||
})
|
||||
|
||||
it('should pass responding=false to all answers when isResponding=false', () => {
|
||||
renderChat({
|
||||
isResponding: false,
|
||||
chatList: [
|
||||
makeChatItem({ id: 'a1', isAnswer: true }),
|
||||
makeChatItem({ id: 'a2', isAnswer: true }),
|
||||
],
|
||||
})
|
||||
screen.getAllByTestId('answer-item').forEach(el =>
|
||||
expect(el).toHaveAttribute('data-responding', 'false'),
|
||||
)
|
||||
})
|
||||
|
||||
it('should render correct counts for a long mixed chatList', () => {
|
||||
const chatList = Array.from({ length: 6 }, (_, i) =>
|
||||
makeChatItem({ id: `item-${i}`, isAnswer: i % 2 === 1 }))
|
||||
renderChat({ chatList })
|
||||
expect(screen.getAllByTestId('question-item')).toHaveLength(3)
|
||||
expect(screen.getAllByTestId('answer-item')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Stop Responding Button', () => {
|
||||
it('should show the stop button when isResponding=true and noStopResponding is falsy', () => {
|
||||
renderChat({ isResponding: true, noStopResponding: false })
|
||||
expect(screen.getByTestId('stop-responding-container')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the stop button when noStopResponding=true', () => {
|
||||
renderChat({ isResponding: true, noStopResponding: true })
|
||||
expect(screen.queryByTestId('stop-responding-container')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the stop button when isResponding=false', () => {
|
||||
renderChat({ isResponding: false, noStopResponding: false })
|
||||
expect(screen.queryByTestId('stop-responding-container')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onStopResponding when the stop button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onStopResponding = vi.fn()
|
||||
renderChat({ isResponding: true, noStopResponding: false, onStopResponding })
|
||||
|
||||
await user.click(screen.getByText(/stopResponding/i))
|
||||
|
||||
expect(onStopResponding).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render the stopResponding i18n key', () => {
|
||||
renderChat({ isResponding: true, noStopResponding: false })
|
||||
expect(screen.getByText(/stopResponding/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TryToAsk (real component)', () => {
|
||||
const tryToAskConfig: ChatConfig = {
|
||||
suggested_questions_after_answer: { enabled: true },
|
||||
} as ChatConfig
|
||||
|
||||
const mockOnSend = vi.fn() as unknown as OnSend
|
||||
|
||||
it('should render the tryToAsk i18n key when all conditions are met', () => {
|
||||
renderChat({
|
||||
config: tryToAskConfig,
|
||||
suggestedQuestions: ['What is AI?'],
|
||||
onSend: mockOnSend,
|
||||
})
|
||||
expect(screen.getByText(/tryToAsk/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render each suggested question as a button', () => {
|
||||
renderChat({
|
||||
config: tryToAskConfig,
|
||||
suggestedQuestions: ['First question', 'Second question'],
|
||||
onSend: mockOnSend,
|
||||
})
|
||||
expect(screen.getByText('First question')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second question')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSend with the question text when a suggestion button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSend = vi.fn() as unknown as OnSend
|
||||
renderChat({
|
||||
config: tryToAskConfig,
|
||||
suggestedQuestions: ['Ask this'],
|
||||
onSend,
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('Ask this'))
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('Ask this')
|
||||
})
|
||||
|
||||
it('should not render TryToAsk when suggested_questions_after_answer is disabled', () => {
|
||||
renderChat({
|
||||
config: { suggested_questions_after_answer: { enabled: false } } as ChatConfig,
|
||||
suggestedQuestions: ['q1'],
|
||||
onSend: mockOnSend,
|
||||
})
|
||||
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render TryToAsk when suggestedQuestions is an empty array', () => {
|
||||
renderChat({
|
||||
config: tryToAskConfig,
|
||||
suggestedQuestions: [],
|
||||
onSend: mockOnSend,
|
||||
})
|
||||
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render TryToAsk when suggestedQuestions is undefined', () => {
|
||||
renderChat({
|
||||
config: tryToAskConfig,
|
||||
suggestedQuestions: undefined,
|
||||
onSend: mockOnSend,
|
||||
})
|
||||
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render TryToAsk when onSend is undefined', () => {
|
||||
renderChat({
|
||||
config: tryToAskConfig,
|
||||
suggestedQuestions: ['q1'],
|
||||
onSend: undefined,
|
||||
})
|
||||
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render TryToAsk when config is undefined', () => {
|
||||
renderChat({
|
||||
config: undefined,
|
||||
suggestedQuestions: ['q1'],
|
||||
onSend: mockOnSend,
|
||||
})
|
||||
expect(screen.queryByText(/tryToAsk/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChatInputArea', () => {
|
||||
it('should render when noChatInput is falsy', () => {
|
||||
renderChat({ noChatInput: false })
|
||||
expect(screen.getByTestId('chat-input-area')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when noChatInput=true', () => {
|
||||
renderChat({ noChatInput: true })
|
||||
expect(screen.queryByTestId('chat-input-area')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass disabled=true when inputDisabled=true', () => {
|
||||
renderChat({ inputDisabled: true })
|
||||
expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-disabled', 'true')
|
||||
})
|
||||
|
||||
it('should pass disabled=false when inputDisabled is falsy', () => {
|
||||
renderChat({ inputDisabled: false })
|
||||
expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-disabled', 'false')
|
||||
})
|
||||
|
||||
it('should pass readonly=true to ChatInputArea when readonly=true', () => {
|
||||
renderChat({ readonly: true })
|
||||
expect(screen.getByTestId('chat-input-area')).toHaveAttribute('data-readonly', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PromptLogModal', () => {
|
||||
it('should render when showPromptLogModal=true and hideLogModal is falsy', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
|
||||
renderChat({ hideLogModal: false })
|
||||
expect(screen.getByTestId('prompt-log-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when showPromptLogModal=false', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showPromptLogModal: false })
|
||||
renderChat()
|
||||
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when hideLogModal=true even if showPromptLogModal=true', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
|
||||
renderChat({ hideLogModal: true })
|
||||
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setCurrentLogItem and setShowPromptLogModal(false) on cancel', async () => {
|
||||
const user = userEvent.setup()
|
||||
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true })
|
||||
renderChat({ hideLogModal: false })
|
||||
|
||||
await user.click(screen.getByTestId('prompt-log-cancel'))
|
||||
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLogModal', () => {
|
||||
it('should render when showAgentLogModal=true and hideLogModal is falsy', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
|
||||
renderChat({ hideLogModal: false })
|
||||
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when showAgentLogModal=false', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showAgentLogModal: false })
|
||||
renderChat()
|
||||
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when hideLogModal=true even if showAgentLogModal=true', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
|
||||
renderChat({ hideLogModal: true })
|
||||
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setCurrentLogItem and setShowAgentLogModal(false) on cancel', async () => {
|
||||
const user = userEvent.setup()
|
||||
useAppStore.setState({ ...baseStoreState, showAgentLogModal: true })
|
||||
renderChat({ hideLogModal: false })
|
||||
|
||||
await user.click(screen.getByTestId('agent-log-cancel'))
|
||||
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Window Resize', () => {
|
||||
it('should register a resize listener on mount', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
renderChat()
|
||||
expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function))
|
||||
})
|
||||
|
||||
it('should remove the resize listener on unmount', () => {
|
||||
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||
const { unmount } = renderChat()
|
||||
unmount()
|
||||
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function))
|
||||
})
|
||||
|
||||
it('should not throw when the resize event fires', () => {
|
||||
renderChat()
|
||||
expect(() => window.dispatchEvent(new Event('resize'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ResizeObserver Callbacks', () => {
|
||||
it('should set paddingBottom on chatContainer from the footer blockSize', () => {
|
||||
renderChat({
|
||||
chatList: [
|
||||
makeChatItem({ id: 'q1', isAnswer: false }),
|
||||
makeChatItem({ id: 'a1', isAnswer: true }),
|
||||
],
|
||||
})
|
||||
const containerCb = capturedResizeCallbacks[0]
|
||||
if (containerCb) {
|
||||
act(() => containerCb([makeResizeEntry(80, 400)], {} as ResizeObserver))
|
||||
expect(screen.getByTestId('chat-container').style.paddingBottom).toBe('80px')
|
||||
}
|
||||
})
|
||||
|
||||
it('should set footer width from the container inlineSize', () => {
|
||||
renderChat()
|
||||
const footerCb = capturedResizeCallbacks[1]
|
||||
if (footerCb) {
|
||||
act(() => footerCb([makeResizeEntry(50, 600)], {} as ResizeObserver))
|
||||
expect(screen.getByTestId('chat-footer').style.width).toBe('600px')
|
||||
}
|
||||
})
|
||||
|
||||
it('should disconnect both observers on unmount', () => {
|
||||
const disconnectSpy = vi.fn()
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe() { }
|
||||
unobserve() { }
|
||||
disconnect = disconnectSpy
|
||||
})
|
||||
const { unmount } = renderChat()
|
||||
unmount()
|
||||
expect(disconnectSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Scroll Behavior', () => {
|
||||
it('should not throw when chatList has 1 item (scroll guard: length > 1 not met)', () => {
|
||||
expect(() => renderChat({ chatList: [makeChatItem({ id: 'q1' })] })).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw when a scroll event fires on the container', () => {
|
||||
renderChat()
|
||||
expect(() =>
|
||||
screen.getByTestId('chat-container').dispatchEvent(new Event('scroll')),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('should set userScrolled when distanceToBottom exceeds threshold', () => {
|
||||
renderChat()
|
||||
const container = screen.getByTestId('chat-container')
|
||||
Object.defineProperty(container, 'scrollHeight', { value: 1000, configurable: true })
|
||||
Object.defineProperty(container, 'clientHeight', { value: 400, configurable: true })
|
||||
Object.defineProperty(container, 'scrollTop', { value: 0, configurable: true })
|
||||
expect(() => container.dispatchEvent(new Event('scroll'))).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not set userScrolled when distanceToBottom is within threshold', () => {
|
||||
renderChat()
|
||||
const container = screen.getByTestId('chat-container')
|
||||
Object.defineProperty(container, 'scrollHeight', { value: 500, configurable: true })
|
||||
Object.defineProperty(container, 'clientHeight', { value: 400, configurable: true })
|
||||
Object.defineProperty(container, 'scrollTop', { value: 99, configurable: true })
|
||||
expect(() => container.dispatchEvent(new Event('scroll'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChatList Scroll Reset', () => {
|
||||
it('should not throw with empty chatList (length <= 1 branch)', () => {
|
||||
expect(() => renderChat({ chatList: [] })).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not throw with exactly one item (length <= 1 branch)', () => {
|
||||
expect(() => renderChat({ chatList: [makeChatItem({ id: 'msg-1' })] })).not.toThrow()
|
||||
})
|
||||
|
||||
it('should reset scroll state when the first message ID changes on rerender', () => {
|
||||
const { rerender } = renderChat({
|
||||
chatList: [makeChatItem({ id: 'first' }), makeChatItem({ id: 'second' })],
|
||||
})
|
||||
expect(() =>
|
||||
rerender(<Chat chatList={[makeChatItem({ id: 'new-first' }), makeChatItem({ id: 'new-second' })]} />),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not reset scroll when the first message ID is unchanged', () => {
|
||||
const item1 = makeChatItem({ id: 'stable-id' })
|
||||
const { rerender } = renderChat({ chatList: [item1, makeChatItem({ id: 'second' })] })
|
||||
expect(() =>
|
||||
rerender(<Chat chatList={[item1, makeChatItem({ id: 'third' })]} />),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sidebar Collapse State', () => {
|
||||
it('should schedule a resize via setTimeout when sidebarCollapseState becomes false', () => {
|
||||
vi.useFakeTimers()
|
||||
const { rerender } = renderChat({ sidebarCollapseState: true })
|
||||
rerender(<Chat chatList={[]} sidebarCollapseState={false} />)
|
||||
expect(() => vi.runAllTimers()).not.toThrow()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should not schedule a resize when sidebarCollapseState stays true', () => {
|
||||
vi.useFakeTimers()
|
||||
renderChat({ sidebarCollapseState: true })
|
||||
expect(() => vi.runAllTimers()).not.toThrow()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should render without crashing with no optional props', () => {
|
||||
expect(() => render(<Chat chatList={[]} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('should handle readonly=true without crashing', () => {
|
||||
expect(() => renderChat({ readonly: true })).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render no modals when both modal flags are false', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showPromptLogModal: false, showAgentLogModal: false })
|
||||
renderChat()
|
||||
expect(screen.queryByTestId('prompt-log-modal')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('agent-log-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render both modals when both flags are true and hideLogModal is false', () => {
|
||||
useAppStore.setState({ ...baseStoreState, showPromptLogModal: true, showAgentLogModal: true })
|
||||
renderChat({ hideLogModal: false })
|
||||
expect(screen.getByTestId('prompt-log-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -26,7 +26,6 @@ import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import AgentLogModal from '@/app/components/base/agent-log-modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
|
||||
import PromptLogModal from '@/app/components/base/prompt-log-modal'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Answer from './answer'
|
||||
@@ -188,7 +187,6 @@ const Chat: FC<ChatProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (chatFooterRef.current && chatContainerRef.current) {
|
||||
// container padding bottom
|
||||
const resizeContainerObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { blockSize } = entry.borderBoxSize[0]
|
||||
@@ -198,7 +196,6 @@ const Chat: FC<ChatProps> = ({
|
||||
})
|
||||
resizeContainerObserver.observe(chatFooterRef.current)
|
||||
|
||||
// footer width
|
||||
const resizeFooterObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { inlineSize } = entry.borderBoxSize[0]
|
||||
@@ -237,20 +234,19 @@ const Chat: FC<ChatProps> = ({
|
||||
return () => container.removeEventListener('scroll', setUserScrolled)
|
||||
}, [])
|
||||
|
||||
// Reset user scroll state when conversation changes or a new chat starts
|
||||
// Track the first message ID to detect conversation switches (fixes #29820)
|
||||
const prevFirstMessageIdRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const firstMessageId = chatList[0]?.id
|
||||
// Reset when: new chat (length <= 1) OR conversation switched (first message ID changed)
|
||||
if (chatList.length <= 1 || (firstMessageId && prevFirstMessageIdRef.current !== firstMessageId))
|
||||
userScrolledRef.current = false
|
||||
prevFirstMessageIdRef.current = firstMessageId
|
||||
}, [chatList])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sidebarCollapseState)
|
||||
setTimeout(() => handleWindowResize(), 200)
|
||||
if (!sidebarCollapseState) {
|
||||
const timer = setTimeout(() => handleWindowResize(), 200)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [handleWindowResize, sidebarCollapseState])
|
||||
|
||||
const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend
|
||||
@@ -273,8 +269,9 @@ const Chat: FC<ChatProps> = ({
|
||||
onFeedback={onFeedback}
|
||||
getHumanInputNodeData={getHumanInputNodeData}
|
||||
>
|
||||
<div className={cn('relative h-full', isTryApp && 'flex flex-col')}>
|
||||
<div data-testid="chat-root" className={cn('relative h-full', isTryApp && 'flex flex-col')}>
|
||||
<div
|
||||
data-testid="chat-container"
|
||||
ref={chatContainerRef}
|
||||
className={cn('relative h-full overflow-y-auto overflow-x-hidden', isTryApp && 'h-0 grow', chatContainerClassName)}
|
||||
>
|
||||
@@ -323,6 +320,7 @@ const Chat: FC<ChatProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-testid="chat-footer"
|
||||
className={`absolute bottom-0 z-10 flex justify-center bg-chat-input-mask ${(hasTryToAsk || !noChatInput || !noStopResponding) && chatFooterClassName}`}
|
||||
ref={chatFooterRef}
|
||||
>
|
||||
@@ -332,9 +330,10 @@ const Chat: FC<ChatProps> = ({
|
||||
>
|
||||
{
|
||||
!noStopResponding && isResponding && (
|
||||
<div className="mb-2 flex justify-center">
|
||||
<div data-testid="stop-responding-container" className="mb-2 flex justify-center">
|
||||
<Button className="border-components-panel-border bg-components-panel-bg text-components-button-secondary-text" onClick={onStopResponding}>
|
||||
<StopCircle className="mr-[5px] h-3.5 w-3.5" />
|
||||
{/* eslint-disable-next-line tailwindcss/no-unknown-classes */}
|
||||
<div className="i-custom-vender-solid-mediaanddevices-stop-circle mr-[5px] h-3.5 w-3.5" />
|
||||
<span className="text-xs font-normal">{t('operation.stopResponding', { ns: 'appDebug' })}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import LoadingAnim from './index'
|
||||
|
||||
describe('LoadingAnim', () => {
|
||||
it('should render correctly with text type', () => {
|
||||
const { container } = render(<LoadingAnim type="text" />)
|
||||
const element = container.firstChild as HTMLElement
|
||||
|
||||
expect(element).toBeInTheDocument()
|
||||
expect(element.className).toMatch(/dot-flashing/)
|
||||
expect(element.className).toMatch(/text/)
|
||||
})
|
||||
|
||||
it('should render correctly with avatar type', () => {
|
||||
const { container } = render(<LoadingAnim type="avatar" />)
|
||||
const element = container.firstChild as HTMLElement
|
||||
|
||||
expect(element).toBeInTheDocument()
|
||||
expect(element.className).toMatch(/dot-flashing/)
|
||||
expect(element.className).toMatch(/avatar/)
|
||||
})
|
||||
})
|
||||
129
web/app/components/base/chat/chat/log/index.spec.tsx
Normal file
129
web/app/components/base/chat/chat/log/index.spec.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { IChatItem, ThoughtItem } from '@/app/components/base/chat/chat/type'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi } from 'vitest'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Log from './index'
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Log', () => {
|
||||
const mockSetCurrentLogItem = vi.fn()
|
||||
const mockSetShowPromptLogModal = vi.fn()
|
||||
const mockSetShowAgentLogModal = vi.fn()
|
||||
const mockSetShowMessageLogModal = vi.fn()
|
||||
|
||||
const createLogItem = (overrides?: Partial<IChatItem>): IChatItem => ({
|
||||
id: '1',
|
||||
content: 'test',
|
||||
isAnswer: true, // Required per your IChatItem type
|
||||
workflow_run_id: '',
|
||||
agent_thoughts: [],
|
||||
message_files: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useAppStore).mockImplementation(selector => selector({
|
||||
// State properties
|
||||
appSidebarExpand: 'expand',
|
||||
currentLogModalActiveTab: 'question',
|
||||
showPromptLogModal: false,
|
||||
showAgentLogModal: false,
|
||||
showMessageLogModal: false,
|
||||
showAppConfigureFeaturesModal: false, // Fixed: Added missing required property
|
||||
currentLogItem: null,
|
||||
// Action functions
|
||||
setCurrentLogItem: mockSetCurrentLogItem,
|
||||
setShowPromptLogModal: mockSetShowPromptLogModal,
|
||||
setShowAgentLogModal: mockSetShowAgentLogModal,
|
||||
setShowMessageLogModal: mockSetShowMessageLogModal,
|
||||
} as unknown as Parameters<typeof selector>[0])) // Fixed: Double cast to avoid overlap error
|
||||
})
|
||||
|
||||
it('should render correctly', () => {
|
||||
render(<Log logItem={createLogItem()} />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show message log modal when workflow_run_id exists', async () => {
|
||||
const user = userEvent.setup()
|
||||
const logItem = createLogItem({ workflow_run_id: 'run-123' })
|
||||
|
||||
render(<Log logItem={logItem} />)
|
||||
const container = screen.getByRole('button').parentElement
|
||||
if (container)
|
||||
await user.click(container)
|
||||
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem)
|
||||
expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should show agent log modal when agent_thoughts exists and workflow_run_id is missing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought: ThoughtItem = {
|
||||
id: 't1',
|
||||
tool: 'test',
|
||||
thought: 'thinking',
|
||||
tool_input: '',
|
||||
message_id: 'm1',
|
||||
conversation_id: 'c1',
|
||||
observation: '',
|
||||
position: 1,
|
||||
}
|
||||
const logItem = createLogItem({
|
||||
workflow_run_id: '',
|
||||
agent_thoughts: [thought],
|
||||
})
|
||||
|
||||
render(<Log logItem={logItem} />)
|
||||
const container = screen.getByRole('button').parentElement
|
||||
if (container)
|
||||
await user.click(container)
|
||||
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem)
|
||||
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should show prompt log modal when both workflow_run_id and agent_thoughts are missing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const logItem = createLogItem({
|
||||
workflow_run_id: '',
|
||||
agent_thoughts: [],
|
||||
})
|
||||
|
||||
render(<Log logItem={logItem} />)
|
||||
const container = screen.getByRole('button').parentElement
|
||||
if (container)
|
||||
await user.click(container)
|
||||
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledWith(logItem)
|
||||
expect(mockSetShowPromptLogModal).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should prevent event propagation on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
// 1. Spy on both the standard propagation and the immediate propagation
|
||||
const stopPropagationSpy = vi.spyOn(Event.prototype, 'stopPropagation')
|
||||
const stopImmediatePropagationSpy = vi.spyOn(Event.prototype, 'stopImmediatePropagation')
|
||||
|
||||
render(<Log logItem={createLogItem()} />)
|
||||
|
||||
// Find the container div that has the onClick handler
|
||||
const container = screen.getByRole('button').parentElement
|
||||
|
||||
if (container)
|
||||
await user.click(container)
|
||||
|
||||
// 2. Assert that both were called
|
||||
expect(stopPropagationSpy).toHaveBeenCalled()
|
||||
expect(stopImmediatePropagationSpy).toHaveBeenCalled()
|
||||
|
||||
// 3. Clean up spies (Good practice to avoid interfering with other tests)
|
||||
stopPropagationSpy.mockRestore()
|
||||
stopImmediatePropagationSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
267
web/app/components/base/chat/chat/question.spec.tsx
Normal file
267
web/app/components/base/chat/chat/question.spec.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import type { Theme } from '../embedded-chatbot/theme/theme-context'
|
||||
import type { ChatConfig, ChatItem, OnRegenerate } from '../types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
import Toast from '../../toast'
|
||||
import { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
|
||||
import { ChatContextProvider } from './context'
|
||||
import Question from './question'
|
||||
|
||||
// Global Mocks
|
||||
vi.mock('@react-aria/interactions', () => ({
|
||||
useFocusVisible: () => ({ isFocusVisible: false }),
|
||||
}))
|
||||
vi.mock('copy-to-clipboard', () => ({ default: vi.fn() }))
|
||||
|
||||
// Mock ResizeObserver and capture lifecycle for targeted coverage
|
||||
const observeMock = vi.fn()
|
||||
const unobserveMock = vi.fn()
|
||||
const disconnectMock = vi.fn()
|
||||
let resizeCallback: ResizeObserverCallback | null = null
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback
|
||||
}
|
||||
|
||||
observe = observeMock
|
||||
unobserve = unobserveMock
|
||||
disconnect = disconnectMock
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
|
||||
type RenderProps = {
|
||||
theme?: Theme | null
|
||||
questionIcon?: React.ReactNode
|
||||
enableEdit?: boolean
|
||||
switchSibling?: (siblingMessageId: string) => void
|
||||
hideAvatar?: boolean
|
||||
answerIcon?: React.ReactNode
|
||||
}
|
||||
|
||||
const makeItem = (overrides: Partial<ChatItem> = {}): ChatItem => ({
|
||||
id: 'q-1',
|
||||
content: 'This is the question content',
|
||||
message_files: [],
|
||||
siblingCount: 3,
|
||||
siblingIndex: 0,
|
||||
prevSibling: null,
|
||||
nextSibling: 'q-2',
|
||||
...overrides,
|
||||
} as unknown as ChatItem)
|
||||
|
||||
const renderWithProvider = (
|
||||
item: ChatItem,
|
||||
onRegenerate: OnRegenerate = vi.fn() as unknown as OnRegenerate,
|
||||
props: RenderProps = {},
|
||||
) => {
|
||||
return render(
|
||||
<ChatContextProvider
|
||||
config={{} as unknown as (ChatConfig | undefined)}
|
||||
isResponding={false}
|
||||
chatList={[]}
|
||||
showPromptLog={false}
|
||||
questionIcon={props.questionIcon}
|
||||
answerIcon={props.answerIcon}
|
||||
onSend={vi.fn()}
|
||||
onRegenerate={onRegenerate}
|
||||
onAnnotationEdited={vi.fn()}
|
||||
onAnnotationAdded={vi.fn()}
|
||||
onAnnotationRemoved={vi.fn()}
|
||||
disableFeedback={false}
|
||||
onFeedback={vi.fn()}
|
||||
getHumanInputNodeData={vi.fn()}
|
||||
>
|
||||
<Question
|
||||
item={item}
|
||||
theme={props.theme}
|
||||
questionIcon={props.questionIcon}
|
||||
enableEdit={props.enableEdit}
|
||||
switchSibling={props.switchSibling}
|
||||
hideAvatar={props.hideAvatar}
|
||||
/>
|
||||
</ChatContextProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Question component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resizeCallback = null
|
||||
})
|
||||
|
||||
it('should render the question content container and default avatar when hideAvatar is false', () => {
|
||||
const { container } = renderWithProvider(makeItem())
|
||||
|
||||
const markdown = container.querySelector('.markdown-body')
|
||||
expect(markdown).toBeInTheDocument()
|
||||
|
||||
const avatar = container.querySelector('.h-10.w-10') || container.querySelector('.h-10.w-10.shrink-0')
|
||||
expect(avatar).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should hide avatar when hideAvatar is true', () => {
|
||||
const { container } = renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { hideAvatar: true })
|
||||
const avatar = container.querySelector('.h-10.w-10')
|
||||
expect(avatar).toBeNull()
|
||||
})
|
||||
|
||||
it('should observe content width resize and update layout accurately', () => {
|
||||
renderWithProvider(makeItem())
|
||||
|
||||
expect(observeMock).toHaveBeenCalled()
|
||||
expect(resizeCallback).not.toBeNull()
|
||||
|
||||
// Mock HTML element clientWidth to trigger logic mapping line coverage
|
||||
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 500 })
|
||||
|
||||
act(() => {
|
||||
if (resizeCallback) {
|
||||
resizeCallback([], {} as ResizeObserver)
|
||||
}
|
||||
})
|
||||
|
||||
const actionContainer = screen.getByTestId('action-container')
|
||||
// 500 width + 8 offset defined in styles
|
||||
expect(actionContainer).toHaveStyle({ right: '508px' })
|
||||
|
||||
// Restore original
|
||||
if (originalClientWidth) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
|
||||
}
|
||||
})
|
||||
|
||||
it('should disconnect ResizeObserver on component unmount', () => {
|
||||
const { unmount } = renderWithProvider(makeItem())
|
||||
unmount()
|
||||
expect(disconnectMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call copy-to-clipboard and show a toast when copy action is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toastSpy = vi.spyOn(Toast, 'notify')
|
||||
|
||||
renderWithProvider(makeItem())
|
||||
|
||||
const copyBtn = screen.getByTestId('copy-btn')
|
||||
await user.click(copyBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(copy).toHaveBeenCalledWith('This is the question content')
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not show edit action when enableEdit is false', () => {
|
||||
renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { enableEdit: false })
|
||||
|
||||
expect(screen.getByTestId('copy-btn')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('edit-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should enter edit mode when edit action clicked, allow editing and call onRegenerate on resend', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRegenerate = vi.fn() as unknown as OnRegenerate
|
||||
|
||||
renderWithProvider(makeItem(), onRegenerate)
|
||||
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
await user.click(editBtn)
|
||||
|
||||
const textbox = await screen.findByRole('textbox')
|
||||
expect(textbox).toHaveValue('This is the question content')
|
||||
|
||||
await user.clear(textbox)
|
||||
await user.type(textbox, 'Edited question')
|
||||
|
||||
const resendBtn = screen.getByRole('button', { name: /chat.resend/i })
|
||||
await user.click(resendBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRegenerate).toHaveBeenCalledWith(makeItem(), { message: 'Edited question', files: [] })
|
||||
})
|
||||
})
|
||||
|
||||
it('should cancel editing and revert to original markdown when cancel is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = renderWithProvider(makeItem())
|
||||
|
||||
const editBtn = screen.getByTestId('edit-btn')
|
||||
await user.click(editBtn)
|
||||
|
||||
const textbox = await screen.findByRole('textbox')
|
||||
await user.clear(textbox)
|
||||
await user.type(textbox, 'Edited question')
|
||||
|
||||
const cancelBtn = screen.getByRole('button', { name: /operation.cancel/i })
|
||||
await user.click(cancelBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
const md = container.querySelector('.markdown-body')
|
||||
expect(md).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch siblings when prev/next buttons are clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const switchSibling = vi.fn()
|
||||
const item = makeItem({ prevSibling: 'q-prev', nextSibling: 'q-next', siblingIndex: 1 })
|
||||
|
||||
renderWithProvider(item, vi.fn() as unknown as OnRegenerate, { switchSibling })
|
||||
|
||||
const prevBtn = screen.getByRole('button', { name: /previous/i })
|
||||
const nextBtn = screen.getByRole('button', { name: /next/i })
|
||||
|
||||
await user.click(prevBtn)
|
||||
await user.click(nextBtn)
|
||||
|
||||
expect(switchSibling).toHaveBeenCalledTimes(2)
|
||||
expect(switchSibling).toHaveBeenCalledWith('q-prev')
|
||||
expect(switchSibling).toHaveBeenCalledWith('q-next')
|
||||
})
|
||||
|
||||
it('should render prev disabled when no prevSibling is provided', () => {
|
||||
const item = makeItem({ prevSibling: undefined, nextSibling: 'q-next', siblingIndex: 0, siblingCount: 2 })
|
||||
renderWithProvider(item, vi.fn() as unknown as OnRegenerate)
|
||||
|
||||
const prevBtn = screen.getByRole('button', { name: /previous/i })
|
||||
const nextBtn = screen.getByRole('button', { name: /next/i })
|
||||
|
||||
expect(prevBtn).toBeDisabled()
|
||||
expect(nextBtn).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should render message files block when message_files provided (audio file branch covered)', () => {
|
||||
const files = [
|
||||
{
|
||||
name: 'audio1.mp3',
|
||||
url: 'https://example.com/audio1.mp3',
|
||||
type: 'audio/mpeg',
|
||||
previewUrl: 'https://example.com/audio1.mp3',
|
||||
size: 1234,
|
||||
} as unknown as FileEntity,
|
||||
]
|
||||
|
||||
renderWithProvider(makeItem({ message_files: files }))
|
||||
|
||||
expect(screen.getByText(/audio1.mp3/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply theme bubble styles when theme provided', () => {
|
||||
const themeBuilder = new ThemeBuilder()
|
||||
themeBuilder.buildTheme('#ff0000', false)
|
||||
const theme = themeBuilder.theme
|
||||
|
||||
renderWithProvider(makeItem(), vi.fn() as unknown as OnRegenerate, { theme })
|
||||
|
||||
const contentContainer = screen.getByTestId('question-content')
|
||||
expect(contentContainer.getAttribute('style')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
} from 'react'
|
||||
import type { Theme } from '../embedded-chatbot/theme/theme-context'
|
||||
import type { ChatItem } from '../types'
|
||||
import { RiClipboardLine, RiEditLine } from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import {
|
||||
memo,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from 'react-textarea-autosize'
|
||||
import { FileList } from '@/app/components/base/file-uploader'
|
||||
import { User } from '@/app/components/base/icons/src/public/avatar'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ActionButton from '../../action-button'
|
||||
@@ -107,25 +105,29 @@ const Question: FC<QuestionProps> = ({
|
||||
<div className={cn('group relative mr-4 flex max-w-full items-start overflow-x-hidden pl-14', isEditing && 'flex-1')}>
|
||||
<div className={cn('mr-2 gap-1', isEditing ? 'hidden' : 'flex')}>
|
||||
<div
|
||||
data-testid="action-container"
|
||||
className="absolute hidden gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex"
|
||||
style={{ right: contentWidth + 8 }}
|
||||
>
|
||||
<ActionButton onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
<ActionButton
|
||||
data-testid="copy-btn"
|
||||
onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
>
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
<div className="i-ri-clipboard-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
{enableEdit && (
|
||||
<ActionButton onClick={handleEdit}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
<ActionButton data-testid="edit-btn" onClick={handleEdit}>
|
||||
<div className="i-ri-edit-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={contentRef}
|
||||
data-testid="question-content"
|
||||
className="w-full rounded-2xl bg-background-gradient-bg-fill-chat-bubble-bg-3 px-4 py-3 text-sm text-text-primary"
|
||||
style={theme?.chatBubbleColorStyle ? CssTransform(theme.chatBubbleColorStyle) : {}}
|
||||
>
|
||||
@@ -150,7 +152,7 @@ const Question: FC<QuestionProps> = ({
|
||||
<div className="max-h-[158px] overflow-y-auto overflow-x-hidden">
|
||||
<Textarea
|
||||
className={cn(
|
||||
'body-lg-regular w-full p-1 leading-6 text-text-tertiary outline-none',
|
||||
'w-full p-1 leading-6 text-text-tertiary outline-none body-lg-regular',
|
||||
)}
|
||||
autoFocus
|
||||
minRows={1}
|
||||
@@ -181,7 +183,7 @@ const Question: FC<QuestionProps> = ({
|
||||
{
|
||||
questionIcon || (
|
||||
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
|
||||
<User className="h-full w-full" />
|
||||
<div className="i-custom-public-avatar-user h-full w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
345
web/app/components/base/chat/chat/thought/index.spec.tsx
Normal file
345
web/app/components/base/chat/chat/thought/index.spec.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import type { ThoughtItem } from '../type'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Thought from './index'
|
||||
|
||||
describe('Thought', () => {
|
||||
const createThought = (overrides?: Partial<ThoughtItem>): ThoughtItem => ({
|
||||
id: 'test-id',
|
||||
tool: 'test-tool',
|
||||
tool_input: 'test input',
|
||||
observation: 'test output',
|
||||
...overrides,
|
||||
} as ThoughtItem)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render single tool thought in collapsed state', () => {
|
||||
const thought = createThought()
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText(/used/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('test-tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple tool thoughts from JSON array', () => {
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['tool1', 'tool2']),
|
||||
tool_input: JSON.stringify(['input1', 'input2']),
|
||||
observation: JSON.stringify(['output1', 'output2']),
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={false} />)
|
||||
|
||||
expect(screen.getAllByText(/using/i)).toHaveLength(2)
|
||||
expect(screen.getByText('tool1')).toBeInTheDocument()
|
||||
expect(screen.getByText('tool2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show input and output when expanded', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool_input: 'test input data',
|
||||
observation: 'test output data',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.queryByText('test input data')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('test output data')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
|
||||
expect(screen.getByText('test input data')).toBeInTheDocument()
|
||||
expect(screen.getByText('test output data')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should show finished state with correct text', () => {
|
||||
const thought = createThought()
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText(/used/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show in-progress state with correct text', () => {
|
||||
const thought = createThought()
|
||||
|
||||
render(<Thought thought={thought} isFinished={false} />)
|
||||
|
||||
expect(screen.getByText(/using/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tool labels', () => {
|
||||
it('should use tool name when no labels provided', () => {
|
||||
const thought = createThought({
|
||||
tool: 'custom-tool',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText('custom-tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to tool name when tool_labels is undefined', () => {
|
||||
const thought = createThought({
|
||||
tool: 'fallback-tool',
|
||||
tool_labels: undefined,
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText('fallback-tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to tool name when toolName property is missing', () => {
|
||||
const thought = createThought({
|
||||
tool: 'another-tool',
|
||||
tool_labels: {},
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText('another-tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to tool name when language property is missing', () => {
|
||||
const thought = createThought({
|
||||
tool: 'test-tool',
|
||||
tool_labels: {
|
||||
toolName: {
|
||||
en_US: 'English Label',
|
||||
zh_Hans: '中文标签',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText('test-tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show knowledge label for dataset tools', () => {
|
||||
const thought = createThought({
|
||||
tool: 'dataset_123',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText(/knowledge/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Value parsing', () => {
|
||||
it('should handle invalid JSON in tool field', () => {
|
||||
const thought = createThought({
|
||||
tool: 'invalid-json-{',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText('invalid-json-{')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle non-array JSON in tool field', () => {
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify({ name: 'object-tool' }),
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText('{"name":"object-tool"}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle invalid JSON in tool_input when parsing array', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['tool1']),
|
||||
tool_input: 'invalid-json-{',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
|
||||
expect(screen.getByText('invalid-json-{')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle invalid JSON in observation when parsing array', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['tool1']),
|
||||
observation: 'invalid-json-[',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
|
||||
expect(screen.getByText('invalid-json-[')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should extract correct values from JSON arrays by index', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['tool1', 'tool2', 'tool3']),
|
||||
tool_input: JSON.stringify(['input1', 'input2', 'input3']),
|
||||
observation: JSON.stringify(['output1', 'output2', 'output3']),
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
const toolSections = screen.getAllByText(/used/i)
|
||||
expect(toolSections).toHaveLength(3)
|
||||
|
||||
await user.click(toolSections[0])
|
||||
expect(screen.getByText('input1')).toBeInTheDocument()
|
||||
expect(screen.getByText('output1')).toBeInTheDocument()
|
||||
|
||||
await user.click(toolSections[1])
|
||||
expect(screen.getByText('input2')).toBeInTheDocument()
|
||||
expect(screen.getByText('output2')).toBeInTheDocument()
|
||||
|
||||
await user.click(toolSections[2])
|
||||
expect(screen.getByText('input3')).toBeInTheDocument()
|
||||
expect(screen.getByText('output3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use original value when isValueArray is false', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool: 'single-tool',
|
||||
tool_input: 'regular input',
|
||||
observation: 'regular output',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
|
||||
expect(screen.getByText('regular input')).toBeInTheDocument()
|
||||
expect(screen.getByText('regular output')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User interactions', () => {
|
||||
it('should toggle expand state on click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool_input: 'test input',
|
||||
observation: 'test output',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.queryByText('test input')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
expect(screen.getByText('test input')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
expect(screen.queryByText('test input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expand multiple tools independently', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['tool1', 'tool2']),
|
||||
tool_input: JSON.stringify(['input1', 'input2']),
|
||||
observation: JSON.stringify(['output1', 'output2']),
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
const toolHeaders = screen.getAllByText(/used/i)
|
||||
|
||||
await user.click(toolHeaders[0])
|
||||
expect(screen.getByText('input1')).toBeInTheDocument()
|
||||
expect(screen.queryByText('input2')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(toolHeaders[1])
|
||||
expect(screen.getByText('input1')).toBeInTheDocument()
|
||||
expect(screen.getByText('input2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple tools with labels', () => {
|
||||
it('should render multiple tools with dataset prefix', () => {
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['dataset_123', 'dataset_456']),
|
||||
tool_input: JSON.stringify(['input1', 'input2']),
|
||||
observation: JSON.stringify(['output1', 'output2']),
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getAllByText(/knowledge/i)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle mixed dataset and regular tools', () => {
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['dataset_123', 'regular-tool']),
|
||||
tool_input: JSON.stringify(['input1', 'input2']),
|
||||
observation: JSON.stringify(['output1', 'output2']),
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
expect(screen.getByText(/knowledge/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('regular-tool')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty tool_input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool_input: '',
|
||||
observation: 'output',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
|
||||
expect(screen.getByText(/requestTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty observation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool_input: 'input',
|
||||
observation: '',
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
await user.click(screen.getByText(/used/i))
|
||||
|
||||
expect(screen.getByText(/responseTitle/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle JSON array with undefined elements', async () => {
|
||||
const user = userEvent.setup()
|
||||
const thought = createThought({
|
||||
tool: JSON.stringify(['tool1', 'tool2']),
|
||||
tool_input: JSON.stringify(['input1']),
|
||||
observation: JSON.stringify(['output1']),
|
||||
})
|
||||
|
||||
render(<Thought thought={thought} isFinished={true} />)
|
||||
|
||||
const toolHeaders = screen.getAllByText(/used/i)
|
||||
await user.click(toolHeaders[1])
|
||||
|
||||
expect(screen.getByText('tool2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
102
web/app/components/base/chat/chat/try-to-ask.spec.tsx
Normal file
102
web/app/components/base/chat/chat/try-to-ask.spec.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { OnSend } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import TryToAsk from './try-to-ask'
|
||||
|
||||
describe('TryToAsk', () => {
|
||||
const mockOnSend: OnSend = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the component with header text', () => {
|
||||
render(
|
||||
<TryToAsk
|
||||
suggestedQuestions={['Question 1']}
|
||||
onSend={mockOnSend}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/tryToAsk/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all suggested questions as buttons', () => {
|
||||
const questions = ['What is AI?', 'How does it work?', 'Tell me more']
|
||||
|
||||
render(
|
||||
<TryToAsk
|
||||
suggestedQuestions={questions}
|
||||
onSend={mockOnSend}
|
||||
/>,
|
||||
)
|
||||
|
||||
questions.forEach((question) => {
|
||||
expect(screen.getByRole('button', { name: question })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onSend with the correct question when button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const questions = ['Question 1', 'Question 2', 'Question 3']
|
||||
|
||||
render(
|
||||
<TryToAsk
|
||||
suggestedQuestions={questions}
|
||||
onSend={mockOnSend}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Question 2' }))
|
||||
|
||||
expect(mockOnSend).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnSend).toHaveBeenCalledWith('Question 2')
|
||||
})
|
||||
|
||||
it('calls onSend for each button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const questions = ['First', 'Second', 'Third']
|
||||
|
||||
render(
|
||||
<TryToAsk
|
||||
suggestedQuestions={questions}
|
||||
onSend={mockOnSend}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'First' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Third' }))
|
||||
|
||||
expect(mockOnSend).toHaveBeenCalledTimes(2)
|
||||
expect(mockOnSend).toHaveBeenNthCalledWith(1, 'First')
|
||||
expect(mockOnSend).toHaveBeenNthCalledWith(2, 'Third')
|
||||
})
|
||||
|
||||
it('renders no buttons when suggestedQuestions is empty', () => {
|
||||
render(
|
||||
<TryToAsk
|
||||
suggestedQuestions={[]}
|
||||
onSend={mockOnSend}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders single question correctly', async () => {
|
||||
const user = userEvent.setup()
|
||||
const question = 'Single question'
|
||||
|
||||
render(
|
||||
<TryToAsk
|
||||
suggestedQuestions={[question]}
|
||||
onSend={mockOnSend}
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: question })
|
||||
expect(button).toBeInTheDocument()
|
||||
|
||||
await user.click(button)
|
||||
expect(mockOnSend).toHaveBeenCalledWith(question)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,400 @@
|
||||
import type { ChatConfig, ChatItem, ChatItemInTree } from '../types'
|
||||
import type { EmbeddedChatbotContextValue } from './context'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import {
|
||||
AppSourceType,
|
||||
fetchSuggestedQuestions,
|
||||
submitHumanInputForm,
|
||||
} from '@/service/share'
|
||||
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
|
||||
import { useChat } from '../chat/hooks'
|
||||
import ChatWrapper from './chat-wrapper'
|
||||
import { useEmbeddedChatbotContext } from './context'
|
||||
|
||||
vi.mock('./context', () => ({
|
||||
useEmbeddedChatbotContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../chat/hooks', () => ({
|
||||
useChat: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./inputs-form', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>inputs form</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../chat', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
chatNode,
|
||||
chatList,
|
||||
inputDisabled,
|
||||
questionIcon,
|
||||
answerIcon,
|
||||
onSend,
|
||||
onRegenerate,
|
||||
switchSibling,
|
||||
onHumanInputFormSubmit,
|
||||
onStopResponding,
|
||||
}: {
|
||||
chatNode: React.ReactNode
|
||||
chatList: ChatItem[]
|
||||
inputDisabled: boolean
|
||||
questionIcon?: React.ReactNode
|
||||
answerIcon?: React.ReactNode
|
||||
onSend: (message: string) => void
|
||||
onRegenerate: (chatItem: ChatItem, editedQuestion?: { message: string, files?: never[] }) => void
|
||||
switchSibling: (siblingMessageId: string) => void
|
||||
onHumanInputFormSubmit: (formToken: string, formData: Record<string, string>) => Promise<void>
|
||||
onStopResponding: () => void
|
||||
}) => (
|
||||
<div>
|
||||
<div>{chatNode}</div>
|
||||
{answerIcon}
|
||||
{chatList.map(item => <div key={item.id}>{item.content}</div>)}
|
||||
<div>
|
||||
chat count:
|
||||
{' '}
|
||||
{chatList.length}
|
||||
</div>
|
||||
{questionIcon}
|
||||
<button onClick={() => onSend('hello world')}>send through chat</button>
|
||||
<button onClick={() => onRegenerate({ id: 'answer-1', isAnswer: true, content: 'answer', parentMessageId: 'question-1' })}>regenerate answer</button>
|
||||
<button onClick={() => switchSibling('sibling-2')}>switch sibling</button>
|
||||
<button disabled={inputDisabled}>send message</button>
|
||||
<button onClick={onStopResponding}>stop responding</button>
|
||||
<button onClick={() => onHumanInputFormSubmit('form-token', { answer: 'ok' })}>submit human input</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/share', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/share')>()
|
||||
return {
|
||||
...actual,
|
||||
fetchSuggestedQuestions: vi.fn(),
|
||||
getUrl: vi.fn(() => '/chat-messages'),
|
||||
stopChatMessageResponding: vi.fn(),
|
||||
submitHumanInputForm: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
submitHumanInputForm: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockIsDify = vi.fn(() => false)
|
||||
vi.mock('./utils', () => ({
|
||||
isDify: () => mockIsDify(),
|
||||
}))
|
||||
|
||||
type UseChatReturn = ReturnType<typeof useChat>
|
||||
|
||||
const createContextValue = (overrides: Partial<EmbeddedChatbotContextValue> = {}): EmbeddedChatbotContextValue => ({
|
||||
appMeta: { tool_icons: {} },
|
||||
appData: {
|
||||
app_id: 'app-1',
|
||||
can_replace_logo: true,
|
||||
custom_config: {
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: '',
|
||||
},
|
||||
enable_site: true,
|
||||
end_user_id: 'user-1',
|
||||
site: {
|
||||
title: 'Embedded App',
|
||||
icon_type: 'emoji',
|
||||
icon: 'bot',
|
||||
icon_background: '#000000',
|
||||
icon_url: '',
|
||||
use_icon_as_answer_icon: false,
|
||||
},
|
||||
},
|
||||
appParams: {} as ChatConfig,
|
||||
appChatListDataLoading: false,
|
||||
currentConversationId: '',
|
||||
currentConversationItem: undefined,
|
||||
appPrevChatList: [],
|
||||
pinnedConversationList: [],
|
||||
conversationList: [],
|
||||
newConversationInputs: {},
|
||||
newConversationInputsRef: { current: {} },
|
||||
handleNewConversationInputsChange: vi.fn(),
|
||||
inputsForms: [],
|
||||
handleNewConversation: vi.fn(),
|
||||
handleStartChat: vi.fn(),
|
||||
handleChangeConversation: vi.fn(),
|
||||
handleNewConversationCompleted: vi.fn(),
|
||||
chatShouldReloadKey: 'reload-key',
|
||||
isMobile: false,
|
||||
isInstalledApp: false,
|
||||
appSourceType: AppSourceType.webApp,
|
||||
allowResetChat: true,
|
||||
appId: 'app-1',
|
||||
disableFeedback: false,
|
||||
handleFeedback: vi.fn(),
|
||||
currentChatInstanceRef: { current: { handleStop: vi.fn() } },
|
||||
themeBuilder: undefined,
|
||||
clearChatList: false,
|
||||
setClearChatList: vi.fn(),
|
||||
isResponding: false,
|
||||
setIsResponding: vi.fn(),
|
||||
currentConversationInputs: {},
|
||||
setCurrentConversationInputs: vi.fn(),
|
||||
allInputsHidden: false,
|
||||
initUserVariables: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createUseChatReturn = (overrides: Partial<UseChatReturn> = {}): UseChatReturn => ({
|
||||
chatList: [],
|
||||
setTargetMessageId: vi.fn() as UseChatReturn['setTargetMessageId'],
|
||||
handleSend: vi.fn(),
|
||||
handleResume: vi.fn(),
|
||||
setIsResponding: vi.fn() as UseChatReturn['setIsResponding'],
|
||||
handleStop: vi.fn(),
|
||||
handleSwitchSibling: vi.fn(),
|
||||
isResponding: false,
|
||||
suggestedQuestions: [],
|
||||
handleRestart: vi.fn(),
|
||||
handleAnnotationEdited: vi.fn(),
|
||||
handleAnnotationAdded: vi.fn(),
|
||||
handleAnnotationRemoved: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('EmbeddedChatbot chat-wrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue())
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn())
|
||||
})
|
||||
|
||||
describe('Welcome behavior', () => {
|
||||
it('should show opening message and suggested question for a new chat', () => {
|
||||
const handleSwitchSibling = vi.fn()
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
handleSwitchSibling,
|
||||
chatList: [{ id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome to the app', suggestedQuestions: ['How does it work?'] }],
|
||||
}))
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
appPrevChatList: [
|
||||
{
|
||||
id: 'parent-node',
|
||||
content: 'parent',
|
||||
isAnswer: true,
|
||||
children: [
|
||||
{
|
||||
id: 'paused-workflow',
|
||||
content: 'paused',
|
||||
isAnswer: true,
|
||||
workflow_run_id: 'run-1',
|
||||
humanInputFormDataList: [{ label: 'Need info' }],
|
||||
} as unknown as ChatItem,
|
||||
],
|
||||
} as unknown as ChatItem,
|
||||
],
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
|
||||
expect(screen.getByText('How does it work?')).toBeInTheDocument()
|
||||
expect(handleSwitchSibling).toHaveBeenCalledWith('paused-workflow', expect.objectContaining({
|
||||
isPublicAPI: true,
|
||||
}))
|
||||
const resumeOptions = handleSwitchSibling.mock.calls[0]?.[1] as { onGetSuggestedQuestions: (responseItemId: string) => void }
|
||||
resumeOptions.onGetSuggestedQuestions('resume-1')
|
||||
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resume-1', AppSourceType.webApp, 'app-1')
|
||||
})
|
||||
|
||||
it('should hide or show welcome content based on chat state', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
inputsForms: [{ variable: 'name', label: 'Name', required: true, type: InputVarType.textInput }],
|
||||
currentConversationId: '',
|
||||
allInputsHidden: false,
|
||||
}))
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
chatList: [{ id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome to the app' }],
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
|
||||
expect(screen.queryByText('Welcome to the app')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('inputs form')).toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
inputsForms: [],
|
||||
currentConversationId: '',
|
||||
allInputsHidden: true,
|
||||
}))
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
chatList: [{ id: 'opening-2', isAnswer: true, isOpeningStatement: true, content: 'Fallback welcome' }],
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
expect(screen.queryByText('inputs form')).not.toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
appData: null,
|
||||
}))
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
isResponding: false,
|
||||
chatList: [{ id: 'opening-3', isAnswer: true, isOpeningStatement: true, content: 'Should be hidden' }],
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
expect(screen.queryByText('Should be hidden')).not.toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue())
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
isResponding: true,
|
||||
chatList: [{ id: 'opening-4', isAnswer: true, isOpeningStatement: true, content: 'Should be hidden while responding' }],
|
||||
}))
|
||||
render(<ChatWrapper />)
|
||||
expect(screen.queryByText('Should be hidden while responding')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Input and avatar behavior', () => {
|
||||
it('should disable sending when required fields are incomplete or uploading', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
inputsForms: [{ variable: 'email', label: 'Email', required: true, type: InputVarType.textInput }],
|
||||
newConversationInputsRef: { current: {} },
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled()
|
||||
|
||||
cleanup()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
inputsForms: [{ variable: 'file', label: 'File', required: true, type: InputVarType.multiFiles }],
|
||||
newConversationInputsRef: {
|
||||
current: {
|
||||
file: [
|
||||
{
|
||||
transferMethod: 'local_file',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled()
|
||||
|
||||
cleanup()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
inputsForms: [{ variable: 'singleFile', label: 'Single file', required: true, type: InputVarType.singleFile }],
|
||||
newConversationInputsRef: {
|
||||
current: {
|
||||
singleFile: {
|
||||
transferMethod: 'local_file',
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
render(<ChatWrapper />)
|
||||
expect(screen.getByRole('button', { name: 'send message' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should show the user name when avatar data is provided', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
initUserVariables: {
|
||||
avatar_url: 'https://example.com/avatar.png',
|
||||
name: 'Alice',
|
||||
},
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
|
||||
expect(screen.getByRole('img', { name: 'Alice' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Human input submit behavior', () => {
|
||||
it('should submit via installed app service when the app is installed', async () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
isInstalledApp: true,
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit human input' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitHumanInputFormService).toHaveBeenCalledWith('form-token', { answer: 'ok' })
|
||||
})
|
||||
expect(submitHumanInputForm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should submit via share service and support chat actions in web app mode', async () => {
|
||||
const handleSend = vi.fn()
|
||||
const handleSwitchSibling = vi.fn()
|
||||
const handleStop = vi.fn()
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
handleSend,
|
||||
handleSwitchSibling,
|
||||
handleStop,
|
||||
chatList: [
|
||||
{ id: 'opening-1', isAnswer: true, isOpeningStatement: true, content: 'Welcome' },
|
||||
{ id: 'question-1', isAnswer: false, content: 'Question' },
|
||||
{ id: 'answer-1', isAnswer: true, content: 'Answer', parentMessageId: 'question-1' },
|
||||
] as ChatItemInTree[],
|
||||
}))
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
isInstalledApp: false,
|
||||
appSourceType: AppSourceType.tryApp,
|
||||
isMobile: true,
|
||||
inputsForms: [{ variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }],
|
||||
currentConversationId: 'conversation-1',
|
||||
}))
|
||||
mockIsDify.mockReturnValue(true)
|
||||
|
||||
render(<ChatWrapper />)
|
||||
|
||||
expect(screen.getByText('chat count: 3')).toBeInTheDocument()
|
||||
expect(screen.queryByText('inputs form')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send through chat' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'regenerate answer' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'switch sibling' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'stop responding' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit human input' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitHumanInputForm).toHaveBeenCalledWith('form-token', { answer: 'ok' })
|
||||
})
|
||||
expect(handleSend).toHaveBeenCalledTimes(2)
|
||||
const sendOptions = handleSend.mock.calls[0]?.[2] as { onGetSuggestedQuestions: (responseItemId: string) => void }
|
||||
sendOptions.onGetSuggestedQuestions('resp-1')
|
||||
expect(handleSwitchSibling).toHaveBeenCalledWith('sibling-2', expect.objectContaining({
|
||||
isPublicAPI: false,
|
||||
}))
|
||||
const switchOptions = handleSwitchSibling.mock.calls.find(call => call[0] === 'sibling-2')?.[1] as { onGetSuggestedQuestions: (responseItemId: string) => void }
|
||||
switchOptions.onGetSuggestedQuestions('resp-2')
|
||||
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resp-1', AppSourceType.tryApp, 'app-1')
|
||||
expect(fetchSuggestedQuestions).toHaveBeenCalledWith('resp-2', AppSourceType.tryApp, 'app-1')
|
||||
expect(handleStop).toHaveBeenCalled()
|
||||
expect(screen.queryByRole('img', { name: 'Alice' })).not.toBeInTheDocument()
|
||||
|
||||
cleanup()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(createContextValue({
|
||||
isMobile: true,
|
||||
currentConversationId: '',
|
||||
inputsForms: [{ variable: 'topic', label: 'Topic', required: false, type: InputVarType.textInput }],
|
||||
}))
|
||||
vi.mocked(useChat).mockReturnValue(createUseChatReturn({
|
||||
chatList: [{ id: 'opening-mobile', isAnswer: true, isOpeningStatement: true, content: 'Mobile welcome' }],
|
||||
}))
|
||||
|
||||
render(<ChatWrapper />)
|
||||
expect(screen.getByText('inputs form')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,362 @@
|
||||
/* eslint-disable next/no-img-element */
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import type { EmbeddedChatbotContextValue } from '../context'
|
||||
import type { AppData } from '@/models/share'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi } from 'vitest'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { InstallationScope, LicenseStatus } from '@/types/feature'
|
||||
import { useEmbeddedChatbotContext } from '../context'
|
||||
import Header from './index'
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
useEmbeddedChatbotContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown', () => ({
|
||||
default: () => <div data-testid="view-form-dropdown" />,
|
||||
}))
|
||||
|
||||
// Mock next/image to render a normal img tag for testing
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => {
|
||||
const { unoptimized: _, ...rest } = props
|
||||
return <img {...rest} />
|
||||
},
|
||||
}))
|
||||
|
||||
type GlobalPublicStoreMock = {
|
||||
systemFeatures: SystemFeatures
|
||||
setSystemFeatures: (systemFeatures: SystemFeatures) => void
|
||||
}
|
||||
|
||||
describe('EmbeddedChatbot Header', () => {
|
||||
const defaultAppData: AppData = {
|
||||
app_id: 'test-app-id',
|
||||
can_replace_logo: true,
|
||||
custom_config: {
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: '',
|
||||
},
|
||||
enable_site: true,
|
||||
end_user_id: 'test-user-id',
|
||||
site: {
|
||||
title: 'Test Site',
|
||||
},
|
||||
}
|
||||
|
||||
const defaultContext: Partial<EmbeddedChatbotContextValue> = {
|
||||
appData: defaultAppData,
|
||||
currentConversationId: 'test-conv-id',
|
||||
inputsForms: [],
|
||||
allInputsHidden: false,
|
||||
}
|
||||
|
||||
const defaultSystemFeatures: SystemFeatures = {
|
||||
trial_models: [],
|
||||
plugin_installation_permission: {
|
||||
plugin_installation_scope: InstallationScope.ALL,
|
||||
restrict_to_marketplace_only: false,
|
||||
},
|
||||
sso_enforced_for_signin: false,
|
||||
sso_enforced_for_signin_protocol: '',
|
||||
sso_enforced_for_web: false,
|
||||
sso_enforced_for_web_protocol: '',
|
||||
enable_marketplace: false,
|
||||
enable_change_email: false,
|
||||
enable_email_code_login: false,
|
||||
enable_email_password_login: false,
|
||||
enable_social_oauth_login: false,
|
||||
is_allow_create_workspace: false,
|
||||
is_allow_register: false,
|
||||
is_email_setup: false,
|
||||
license: {
|
||||
status: LicenseStatus.NONE,
|
||||
expired_at: '',
|
||||
},
|
||||
branding: {
|
||||
enabled: true,
|
||||
workspace_logo: '',
|
||||
login_page_logo: '',
|
||||
favicon: '',
|
||||
application_title: '',
|
||||
},
|
||||
webapp_auth: {
|
||||
enabled: false,
|
||||
allow_sso: false,
|
||||
sso_config: { protocol: '' },
|
||||
allow_email_code_login: false,
|
||||
allow_email_password_login: false,
|
||||
},
|
||||
enable_trial_app: false,
|
||||
enable_explore_banner: false,
|
||||
}
|
||||
|
||||
const setupIframe = () => {
|
||||
const mockPostMessage = vi.fn()
|
||||
const mockTop = { postMessage: mockPostMessage }
|
||||
Object.defineProperty(window, 'self', { value: {}, configurable: true })
|
||||
Object.defineProperty(window, 'top', { value: mockTop, configurable: true })
|
||||
Object.defineProperty(window, 'parent', { value: mockTop, configurable: true })
|
||||
return mockPostMessage
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(defaultContext as EmbeddedChatbotContextValue)
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
|
||||
systemFeatures: defaultSystemFeatures,
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
|
||||
Object.defineProperty(window, 'self', { value: window, configurable: true })
|
||||
Object.defineProperty(window, 'top', { value: window, configurable: true })
|
||||
})
|
||||
|
||||
describe('Desktop Rendering', () => {
|
||||
it('should render desktop header with branding by default', async () => {
|
||||
render(<Header title="Test Chatbot" />)
|
||||
|
||||
expect(screen.getByTestId('webapp-brand')).toBeInTheDocument()
|
||||
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom logo when provided in appData', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...defaultContext,
|
||||
appData: {
|
||||
...defaultAppData,
|
||||
custom_config: {
|
||||
...defaultAppData.custom_config,
|
||||
replace_webapp_logo: 'https://example.com/logo.png',
|
||||
},
|
||||
},
|
||||
} as EmbeddedChatbotContextValue)
|
||||
|
||||
render(<Header title="Test Chatbot" />)
|
||||
|
||||
const img = screen.getByAltText('logo')
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/logo.png')
|
||||
})
|
||||
|
||||
it('should render workspace logo when branding is enabled and logo exists', () => {
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
workspace_logo: 'https://example.com/workspace.png',
|
||||
},
|
||||
},
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
|
||||
render(<Header title="Test Chatbot" />)
|
||||
|
||||
const img = screen.getByAltText('logo')
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/workspace.png')
|
||||
})
|
||||
|
||||
it('should render Dify logo by default when no branding or custom logo is provided', () => {
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
render(<Header title="Test Chatbot" />)
|
||||
expect(screen.getByAltText('Dify logo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT render branding when remove_webapp_brand is true', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...defaultContext,
|
||||
appData: {
|
||||
...defaultAppData,
|
||||
custom_config: {
|
||||
...defaultAppData.custom_config,
|
||||
remove_webapp_brand: true,
|
||||
},
|
||||
},
|
||||
} as EmbeddedChatbotContextValue)
|
||||
|
||||
render(<Header title="Test Chatbot" />)
|
||||
|
||||
expect(screen.queryByTestId('webapp-brand')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render reset button when allowResetChat is true and conversation exists', () => {
|
||||
render(<Header title="Test Chatbot" allowResetChat={true} />)
|
||||
|
||||
expect(screen.getByTestId('reset-chat-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCreateNewChat when reset button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCreateNewChat = vi.fn()
|
||||
render(<Header title="Test Chatbot" allowResetChat={true} onCreateNewChat={onCreateNewChat} />)
|
||||
|
||||
await user.click(screen.getByTestId('reset-chat-button'))
|
||||
expect(onCreateNewChat).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render ViewFormDropdown when conditions are met', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...defaultContext,
|
||||
inputsForms: [{ id: '1' }],
|
||||
allInputsHidden: false,
|
||||
} as EmbeddedChatbotContextValue)
|
||||
|
||||
render(<Header title="Test Chatbot" />)
|
||||
|
||||
expect(screen.getByTestId('view-form-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT render ViewFormDropdown when inputs are hidden', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...defaultContext,
|
||||
inputsForms: [{ id: '1' }],
|
||||
allInputsHidden: true,
|
||||
} as EmbeddedChatbotContextValue)
|
||||
|
||||
render(<Header title="Test Chatbot" />)
|
||||
|
||||
expect(screen.queryByTestId('view-form-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT render ViewFormDropdown when currentConversationId is missing', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...defaultContext,
|
||||
currentConversationId: '',
|
||||
inputsForms: [{ id: '1' }],
|
||||
} as EmbeddedChatbotContextValue)
|
||||
|
||||
render(<Header title="Test Chatbot" />)
|
||||
|
||||
expect(screen.queryByTestId('view-form-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile Rendering', () => {
|
||||
it('should render mobile header with title', () => {
|
||||
render(<Header title="Mobile Chatbot" isMobile />)
|
||||
|
||||
expect(screen.getByText('Mobile Chatbot')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render customer icon in mobile header', () => {
|
||||
render(<Header title="Mobile Chatbot" isMobile customerIcon={<div data-testid="custom-icon" />} />)
|
||||
|
||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render mobile reset button when allowed', () => {
|
||||
render(<Header title="Mobile Chatbot" isMobile allowResetChat />)
|
||||
|
||||
expect(screen.getByTestId('mobile-reset-chat-button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Iframe Communication', () => {
|
||||
it('should send dify-chatbot-iframe-ready on mount', () => {
|
||||
const mockPostMessage = setupIframe()
|
||||
render(<Header title="Iframe" />)
|
||||
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
{ type: 'dify-chatbot-iframe-ready' },
|
||||
'*',
|
||||
)
|
||||
})
|
||||
|
||||
it('should update expand button visibility and handle click', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockPostMessage = setupIframe()
|
||||
render(<Header title="Iframe" />)
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
origin: 'https://parent.com',
|
||||
data: {
|
||||
type: 'dify-chatbot-config',
|
||||
payload: { isToggledByButton: true, isDraggable: false },
|
||||
},
|
||||
}))
|
||||
|
||||
const expandBtn = await screen.findByTestId('expand-button')
|
||||
expect(expandBtn).toBeInTheDocument()
|
||||
|
||||
await user.click(expandBtn)
|
||||
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
{ type: 'dify-chatbot-expand-change' },
|
||||
'https://parent.com',
|
||||
)
|
||||
expect(expandBtn.querySelector('.i-ri-collapse-diagonal-2-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should NOT show expand button if isDraggable is true', async () => {
|
||||
setupIframe()
|
||||
render(<Header title="Iframe" />)
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
origin: 'https://parent.com',
|
||||
data: {
|
||||
type: 'dify-chatbot-config',
|
||||
payload: { isToggledByButton: true, isDraggable: true },
|
||||
},
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('expand-button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore messages from different origins after security lock', async () => {
|
||||
setupIframe()
|
||||
render(<Header title="Iframe" />)
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
origin: 'https://secure.com',
|
||||
data: { type: 'dify-chatbot-config', payload: { isToggledByButton: true, isDraggable: false } },
|
||||
}))
|
||||
|
||||
await screen.findByTestId('expand-button')
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
origin: 'https://malicious.com',
|
||||
data: { type: 'dify-chatbot-config', payload: { isToggledByButton: false, isDraggable: false } },
|
||||
}))
|
||||
|
||||
expect(screen.getByTestId('expand-button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle document.referrer for targetOrigin', () => {
|
||||
const mockPostMessage = setupIframe()
|
||||
Object.defineProperty(document, 'referrer', { value: 'https://referrer.com', configurable: true })
|
||||
render(<Header title="Referrer" />)
|
||||
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'https://referrer.com',
|
||||
)
|
||||
})
|
||||
|
||||
it('should NOT add message listener if not in iframe', () => {
|
||||
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||
render(<Header title="Direct" />)
|
||||
expect(addSpy).not.toHaveBeenCalledWith('message', expect.any(Function))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import type { Theme } from '../theme/theme-context'
|
||||
import { RiCollapseDiagonal2Line, RiExpandDiagonal2Line, RiResetLeftLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -89,11 +88,13 @@ const Header: FC<IHeaderProps> = ({
|
||||
{/* powered by */}
|
||||
<div className="shrink-0">
|
||||
{!appData?.custom_config?.remove_webapp_brand && (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-2',
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-2',
|
||||
)}
|
||||
data-testid="webapp-brand"
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
|
||||
@@ -112,11 +113,11 @@ const Header: FC<IHeaderProps> = ({
|
||||
<Tooltip
|
||||
popupContent={expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })}
|
||||
>
|
||||
<ActionButton size="l" onClick={handleToggleExpand}>
|
||||
<ActionButton size="l" onClick={handleToggleExpand} data-testid="expand-button">
|
||||
{
|
||||
expanded
|
||||
? <RiCollapseDiagonal2Line className="h-[18px] w-[18px]" />
|
||||
: <RiExpandDiagonal2Line className="h-[18px] w-[18px]" />
|
||||
? <div className="i-ri-collapse-diagonal-2-line h-[18px] w-[18px]" />
|
||||
: <div className="i-ri-expand-diagonal-2-line h-[18px] w-[18px]" />
|
||||
}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
@@ -126,8 +127,8 @@ const Header: FC<IHeaderProps> = ({
|
||||
<Tooltip
|
||||
popupContent={t('chat.resetChat', { ns: 'share' })}
|
||||
>
|
||||
<ActionButton size="l" onClick={onCreateNewChat}>
|
||||
<RiResetLeftLine className="h-[18px] w-[18px]" />
|
||||
<ActionButton size="l" onClick={onCreateNewChat} data-testid="reset-chat-button">
|
||||
<div className="i-ri-reset-left-line h-[18px] w-[18px]" />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -147,7 +148,7 @@ const Header: FC<IHeaderProps> = ({
|
||||
<div className="flex grow items-center space-x-3">
|
||||
{customerIcon}
|
||||
<div
|
||||
className="system-md-semibold truncate"
|
||||
className="truncate system-md-semibold"
|
||||
style={CssTransform(theme?.colorFontOnHeaderStyle ?? '')}
|
||||
>
|
||||
{title}
|
||||
@@ -159,11 +160,11 @@ const Header: FC<IHeaderProps> = ({
|
||||
<Tooltip
|
||||
popupContent={expanded ? t('chat.collapse', { ns: 'share' }) : t('chat.expand', { ns: 'share' })}
|
||||
>
|
||||
<ActionButton size="l" onClick={handleToggleExpand}>
|
||||
<ActionButton size="l" onClick={handleToggleExpand} data-testid="mobile-expand-button">
|
||||
{
|
||||
expanded
|
||||
? <RiCollapseDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
: <RiExpandDiagonal2Line className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
? <div className={cn('i-ri-collapse-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
: <div className={cn('i-ri-expand-diagonal-2-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
@@ -173,8 +174,8 @@ const Header: FC<IHeaderProps> = ({
|
||||
<Tooltip
|
||||
popupContent={t('chat.resetChat', { ns: 'share' })}
|
||||
>
|
||||
<ActionButton size="l" onClick={onCreateNewChat}>
|
||||
<RiResetLeftLine className={cn('h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
<ActionButton size="l" onClick={onCreateNewChat} data-testid="mobile-reset-chat-button">
|
||||
<div className={cn('i-ri-reset-left-line h-[18px] w-[18px]', theme?.colorPathOnHeader)} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
240
web/app/components/base/chat/embedded-chatbot/index.spec.tsx
Normal file
240
web/app/components/base/chat/embedded-chatbot/index.spec.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { ChatConfig } from '../types'
|
||||
import type { AppData, AppMeta, ConversationItem } from '@/models/share'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import { useEmbeddedChatbot } from './hooks'
|
||||
import EmbeddedChatbot from './index'
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
useEmbeddedChatbot: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: vi.fn(),
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
tablet: 'tablet',
|
||||
pc: 'pc',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./chat-wrapper', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>chat area</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./header', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>chat header</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./theme/theme-context', () => ({
|
||||
useThemeContext: vi.fn(() => ({
|
||||
buildTheme: vi.fn(),
|
||||
theme: {
|
||||
backgroundHeaderColorStyle: '',
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
const mockIsDify = vi.fn(() => false)
|
||||
vi.mock('./utils', () => ({
|
||||
isDify: () => mockIsDify(),
|
||||
}))
|
||||
|
||||
type EmbeddedChatbotHookReturn = ReturnType<typeof useEmbeddedChatbot>
|
||||
|
||||
const createHookReturn = (overrides: Partial<EmbeddedChatbotHookReturn> = {}): EmbeddedChatbotHookReturn => {
|
||||
const appData: AppData = {
|
||||
app_id: 'app-1',
|
||||
can_replace_logo: true,
|
||||
custom_config: {
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: '',
|
||||
},
|
||||
enable_site: true,
|
||||
end_user_id: 'user-1',
|
||||
site: {
|
||||
title: 'Embedded App',
|
||||
chat_color_theme: 'blue',
|
||||
chat_color_theme_inverted: false,
|
||||
},
|
||||
}
|
||||
|
||||
const base: EmbeddedChatbotHookReturn = {
|
||||
appSourceType: 'webApp' as EmbeddedChatbotHookReturn['appSourceType'],
|
||||
isInstalledApp: false,
|
||||
appId: 'app-1',
|
||||
currentConversationId: '',
|
||||
currentConversationItem: undefined,
|
||||
removeConversationIdInfo: vi.fn(),
|
||||
handleConversationIdInfoChange: vi.fn(),
|
||||
appData,
|
||||
appParams: {} as ChatConfig,
|
||||
appMeta: { tool_icons: {} } as AppMeta,
|
||||
appPinnedConversationData: { data: [], has_more: false, limit: 20 },
|
||||
appConversationData: { data: [], has_more: false, limit: 20 },
|
||||
appConversationDataLoading: false,
|
||||
appChatListData: { data: [], has_more: false, limit: 20 },
|
||||
appChatListDataLoading: false,
|
||||
appPrevChatList: [],
|
||||
pinnedConversationList: [] as ConversationItem[],
|
||||
conversationList: [] as ConversationItem[],
|
||||
setShowNewConversationItemInList: vi.fn(),
|
||||
newConversationInputs: {},
|
||||
newConversationInputsRef: { current: {} } as unknown as RefObject<Record<string, unknown>>,
|
||||
handleNewConversationInputsChange: vi.fn(),
|
||||
inputsForms: [],
|
||||
handleNewConversation: vi.fn(),
|
||||
handleStartChat: vi.fn(),
|
||||
handleChangeConversation: vi.fn(),
|
||||
handleNewConversationCompleted: vi.fn(),
|
||||
newConversationId: '',
|
||||
chatShouldReloadKey: 'reload-key',
|
||||
allowResetChat: true,
|
||||
handleFeedback: vi.fn(),
|
||||
currentChatInstanceRef: { current: { handleStop: vi.fn() } },
|
||||
clearChatList: false,
|
||||
setClearChatList: vi.fn(),
|
||||
isResponding: false,
|
||||
setIsResponding: vi.fn(),
|
||||
currentConversationInputs: {},
|
||||
setCurrentConversationInputs: vi.fn(),
|
||||
allInputsHidden: false,
|
||||
initUserVariables: {},
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('EmbeddedChatbot index', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
|
||||
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn())
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
enabled: true,
|
||||
workspace_logo: '',
|
||||
},
|
||||
},
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
})
|
||||
|
||||
describe('Loading and chat content', () => {
|
||||
it('should show loading state before chat content', () => {
|
||||
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({ appChatListDataLoading: true }))
|
||||
|
||||
render(<EmbeddedChatbot />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
expect(screen.queryByText('chat area')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render chat content when loading finishes', () => {
|
||||
render(<EmbeddedChatbot />)
|
||||
|
||||
expect(screen.getByText('chat area')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Powered by branding', () => {
|
||||
it('should show workspace logo on mobile when branding is enabled', () => {
|
||||
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
|
||||
systemFeatures: {
|
||||
...defaultSystemFeatures,
|
||||
branding: {
|
||||
...defaultSystemFeatures.branding,
|
||||
enabled: true,
|
||||
workspace_logo: 'https://example.com/workspace-logo.png',
|
||||
},
|
||||
},
|
||||
setSystemFeatures: vi.fn(),
|
||||
}))
|
||||
|
||||
render(<EmbeddedChatbot />)
|
||||
|
||||
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/workspace-logo.png')
|
||||
})
|
||||
|
||||
it('should show custom logo when workspace branding logo is unavailable', () => {
|
||||
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({
|
||||
appData: {
|
||||
app_id: 'app-1',
|
||||
can_replace_logo: true,
|
||||
custom_config: {
|
||||
remove_webapp_brand: false,
|
||||
replace_webapp_logo: 'https://example.com/custom-logo.png',
|
||||
},
|
||||
enable_site: true,
|
||||
end_user_id: 'user-1',
|
||||
site: {
|
||||
title: 'Embedded App',
|
||||
chat_color_theme: 'blue',
|
||||
chat_color_theme_inverted: false,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
render(<EmbeddedChatbot />)
|
||||
|
||||
expect(screen.getByText('share.chat.poweredBy')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('logo')).toHaveAttribute('src', 'https://example.com/custom-logo.png')
|
||||
})
|
||||
|
||||
it('should hide powered by section when branding is removed', () => {
|
||||
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({
|
||||
appData: {
|
||||
app_id: 'app-1',
|
||||
can_replace_logo: true,
|
||||
custom_config: {
|
||||
remove_webapp_brand: true,
|
||||
replace_webapp_logo: '',
|
||||
},
|
||||
enable_site: true,
|
||||
end_user_id: 'user-1',
|
||||
site: {
|
||||
title: 'Embedded App',
|
||||
chat_color_theme: 'blue',
|
||||
chat_color_theme_inverted: false,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
render(<EmbeddedChatbot />)
|
||||
|
||||
expect(screen.queryByText('share.chat.poweredBy')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show powered by section on desktop', () => {
|
||||
vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
|
||||
vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn({ appData: null }))
|
||||
mockIsDify.mockReturnValue(true)
|
||||
|
||||
render(<EmbeddedChatbot />)
|
||||
|
||||
expect(screen.queryByText('share.chat.poweredBy')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('chat header')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,263 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { useEmbeddedChatbotContext } from '../context'
|
||||
import InputsFormContent from './content'
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
useEmbeddedChatbotContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ token: 'test-token' }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({ notify: vi.fn() }),
|
||||
}))
|
||||
|
||||
// Mock CodeEditor to trigger onChange easily
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({ value, onChange, placeholder }: { value: string, onChange: (v: string) => void, placeholder: string | React.ReactNode }) => (
|
||||
<textarea
|
||||
data-testid="mock-code-editor"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={typeof placeholder === 'string' ? placeholder : 'json-placeholder'}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock FileUploaderInAttachmentWrapper to trigger onChange easily
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
|
||||
FileUploaderInAttachmentWrapper: ({ value, onChange }: { value: any[], onChange: (v: any[]) => void }) => (
|
||||
<div data-testid="mock-file-uploader">
|
||||
<button onClick={() => onChange([new File([''], 'test.png', { type: 'image/png' })])}>Upload</button>
|
||||
<span>{value.length > 0 ? value[0].name : 'no file'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockContextValue = {
|
||||
appParams: {
|
||||
system_parameters: {
|
||||
file_size_limit: 10,
|
||||
},
|
||||
},
|
||||
inputsForms: [
|
||||
{
|
||||
variable: 'text_var',
|
||||
label: 'Text Label',
|
||||
type: InputVarType.textInput,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
variable: 'num_var',
|
||||
label: 'Number Label',
|
||||
type: InputVarType.number,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
variable: 'para_var',
|
||||
label: 'Paragraph Label',
|
||||
type: InputVarType.paragraph,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
variable: 'bool_var',
|
||||
label: 'Bool Label',
|
||||
type: InputVarType.checkbox,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
variable: 'select_var',
|
||||
label: 'Select Label',
|
||||
type: InputVarType.select,
|
||||
options: ['Option 1', 'Option 2'],
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
variable: 'file_var',
|
||||
label: 'File Label',
|
||||
type: InputVarType.singleFile,
|
||||
required: true,
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_upload_methods: ['local_upload'],
|
||||
},
|
||||
{
|
||||
variable: 'multi_file_var',
|
||||
label: 'Multi File Label',
|
||||
type: InputVarType.multiFiles,
|
||||
required: true,
|
||||
max_length: 5,
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_upload_methods: ['local_upload'],
|
||||
},
|
||||
{
|
||||
variable: 'json_var',
|
||||
label: 'JSON Label',
|
||||
type: InputVarType.jsonObject,
|
||||
required: true,
|
||||
json_schema: '{ "type": "object" }',
|
||||
},
|
||||
{
|
||||
variable: 'hidden_var',
|
||||
label: 'Hidden Label',
|
||||
type: InputVarType.textInput,
|
||||
hide: true,
|
||||
},
|
||||
],
|
||||
currentConversationId: null,
|
||||
currentConversationInputs: {},
|
||||
setCurrentConversationInputs: vi.fn(),
|
||||
newConversationInputs: {},
|
||||
newConversationInputsRef: { current: {} },
|
||||
handleNewConversationInputsChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('InputsFormContent', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(mockContextValue as unknown as any)
|
||||
})
|
||||
|
||||
it('should render visible input forms', () => {
|
||||
render(<InputsFormContent />)
|
||||
|
||||
expect(screen.getAllByText(/Text Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/Number Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/Paragraph Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/Bool Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/Select Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/File Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/Multi File Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText(/JSON Label/i).length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('Hidden Label')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render optional label for non-required fields', () => {
|
||||
render(<InputsFormContent />)
|
||||
expect(screen.queryAllByText(/panel.optional/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should handle text input changes', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const inputs = screen.getAllByPlaceholderText('Text Label')
|
||||
await user.type(inputs[0], 'hello')
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle number input changes', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const inputs = screen.getAllByPlaceholderText('Number Label')
|
||||
await user.type(inputs[0], '123')
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle paragraph input changes', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const inputs = screen.getAllByPlaceholderText('Paragraph Label')
|
||||
await user.type(inputs[0], 'long text')
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle bool input changes', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const checkbox = screen.getByTestId(/checkbox-/i)
|
||||
await user.click(checkbox)
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle select input changes', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const selectTrigger = screen.getAllByText(/Select Label/i).find(el => el.tagName === 'SPAN')
|
||||
if (!selectTrigger)
|
||||
throw new Error('Select trigger not found')
|
||||
|
||||
await user.click(selectTrigger)
|
||||
const option = screen.getByText('Option 1')
|
||||
await user.click(option)
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle single file upload change', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const uploadButtons = screen.getAllByText('Upload')
|
||||
await user.click(uploadButtons[0]) // First one is single file
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle multi files upload change', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const uploadButtons = screen.getAllByText('Upload')
|
||||
await user.click(uploadButtons[1]) // Second one is multi files
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle JSON object change', async () => {
|
||||
render(<InputsFormContent />)
|
||||
const jsonEditor = screen.getByTestId('mock-code-editor')
|
||||
fireEvent.change(jsonEditor, { target: { value: '{ "a": 1 }' } })
|
||||
|
||||
expect(mockContextValue.setCurrentConversationInputs).toHaveBeenCalled()
|
||||
expect(mockContextValue.handleNewConversationInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show tip when showTip is true', () => {
|
||||
render(<InputsFormContent showTip />)
|
||||
expect(screen.getByText(/chat.chatFormTip/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set initial values from context', () => {
|
||||
const contextWithValues = {
|
||||
...mockContextValue,
|
||||
newConversationInputs: {
|
||||
text_var: 'initial value',
|
||||
},
|
||||
}
|
||||
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(contextWithValues as unknown as any)
|
||||
|
||||
render(<InputsFormContent />)
|
||||
expect(screen.getByDisplayValue('initial value')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use currentConversationInputs when currentConversationId exists', () => {
|
||||
const contextWithConv = {
|
||||
...mockContextValue,
|
||||
currentConversationId: 'conv-id',
|
||||
currentConversationInputs: {
|
||||
text_var: 'conv value',
|
||||
},
|
||||
}
|
||||
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(contextWithConv as unknown as any)
|
||||
|
||||
render(<InputsFormContent />)
|
||||
expect(screen.getByDisplayValue('conv value')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -45,12 +45,12 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{visibleInputsForms.map(form => (
|
||||
<div key={form.variable} className="space-y-1">
|
||||
<div key={form.variable} className="space-y-1" data-testid={`inputs-form-item-${form.variable}`}>
|
||||
{form.type !== InputVarType.checkbox && (
|
||||
<div className="flex h-6 items-center gap-1">
|
||||
<div className="system-md-semibold text-text-secondary">{form.label}</div>
|
||||
<div className="text-text-secondary system-md-semibold">{form.label}</div>
|
||||
{!form.required && (
|
||||
<div className="system-xs-regular text-text-tertiary">{t('panel.optional', { ns: 'workflow' })}</div>
|
||||
<div className="text-text-tertiary system-xs-regular">{t('panel.optional', { ns: 'workflow' })}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -125,7 +125,7 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
value={inputsFormValue?.[form.variable] || ''}
|
||||
onChange={v => handleFormChange(form.variable, v)}
|
||||
noWrapper
|
||||
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||
className="h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
|
||||
placeholder={
|
||||
<div className="whitespace-pre">{form.json_schema}</div>
|
||||
}
|
||||
@@ -134,7 +134,7 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
</div>
|
||||
))}
|
||||
{showTip && (
|
||||
<div className="system-xs-regular text-text-tertiary">{t('chat.chatFormTip', { ns: 'share' })}</div>
|
||||
<div className="text-text-tertiary system-xs-regular">{t('chat.chatFormTip', { ns: 'share' })}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import { useEmbeddedChatbotContext } from '../context'
|
||||
import InputsFormNode from './index'
|
||||
|
||||
vi.mock('../context', () => ({
|
||||
useEmbeddedChatbotContext: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock InputsFormContent to avoid complex integration in this test
|
||||
vi.mock('./content', () => ({
|
||||
default: () => <div data-testid="mock-inputs-form-content" />,
|
||||
}))
|
||||
|
||||
const mockContextValue = {
|
||||
appSourceType: AppSourceType.webApp,
|
||||
isMobile: false,
|
||||
currentConversationId: null,
|
||||
themeBuilder: null,
|
||||
handleStartChat: vi.fn(),
|
||||
allInputsHidden: false,
|
||||
inputsForms: [{ variable: 'test' }],
|
||||
}
|
||||
|
||||
describe('InputsFormNode', () => {
|
||||
const user = userEvent.setup()
|
||||
const setCollapsed = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue(mockContextValue as unknown as any)
|
||||
})
|
||||
|
||||
it('should return null if allInputsHidden is true', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...mockContextValue,
|
||||
allInputsHidden: true,
|
||||
} as unknown as any)
|
||||
const { container } = render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null if inputsForms is empty', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...mockContextValue,
|
||||
inputsForms: [],
|
||||
} as unknown as any)
|
||||
const { container } = render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render expanded state correctly', () => {
|
||||
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
expect(screen.getByText(/chat.chatSettingsTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('mock-inputs-form-content')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('inputs-form-start-chat-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render collapsed state correctly', () => {
|
||||
render(<InputsFormNode collapsed={true} setCollapsed={setCollapsed} />)
|
||||
expect(screen.getByText(/chat.chatSettingsTitle/i)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('mock-inputs-form-content')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('inputs-form-edit-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle edit button click', async () => {
|
||||
render(<InputsFormNode collapsed={true} setCollapsed={setCollapsed} />)
|
||||
await user.click(screen.getByTestId('inputs-form-edit-button'))
|
||||
expect(setCollapsed).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should handle close button click', async () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...mockContextValue,
|
||||
currentConversationId: 'conv-123',
|
||||
} as unknown as any)
|
||||
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
await user.click(screen.getByTestId('inputs-form-close-button'))
|
||||
expect(setCollapsed).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should handle start chat button click', async () => {
|
||||
const handleStartChat = vi.fn(cb => cb())
|
||||
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...mockContextValue,
|
||||
handleStartChat,
|
||||
} as unknown as any)
|
||||
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
await user.click(screen.getByTestId('inputs-form-start-chat-button'))
|
||||
expect(handleStartChat).toHaveBeenCalled()
|
||||
expect(setCollapsed).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should apply theme primary color to start chat button', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...mockContextValue,
|
||||
themeBuilder: {
|
||||
theme: {
|
||||
primaryColor: '#ff0000',
|
||||
},
|
||||
},
|
||||
} as unknown as any)
|
||||
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
const button = screen.getByTestId('inputs-form-start-chat-button')
|
||||
expect(button).toHaveStyle({ backgroundColor: '#ff0000' })
|
||||
})
|
||||
|
||||
it('should apply tryApp styles when appSourceType is tryApp', () => {
|
||||
vi.mocked(useEmbeddedChatbotContext).mockReturnValue({
|
||||
...mockContextValue,
|
||||
appSourceType: AppSourceType.tryApp,
|
||||
} as unknown as any)
|
||||
render(<InputsFormNode collapsed={false} setCollapsed={setCollapsed} />)
|
||||
const mainDiv = screen.getByTestId('inputs-form-node')
|
||||
expect(mainDiv).toHaveClass('mb-0 px-0')
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useEmbeddedChatbotContext } from '../context'
|
||||
@@ -33,7 +32,10 @@ const InputsFormNode = ({
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className={cn('mb-6 flex flex-col items-center px-4 pt-6', isMobile && 'mb-4 pt-4', isTryApp && 'mb-0 px-0')}>
|
||||
<div
|
||||
data-testid="inputs-form-node"
|
||||
className={cn('mb-6 flex flex-col items-center px-4 pt-6', isMobile && 'mb-4 pt-4', isTryApp && 'mb-0 px-0')}
|
||||
>
|
||||
<div className={cn(
|
||||
'w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md',
|
||||
collapsed && 'border border-components-card-border bg-components-card-bg shadow-none',
|
||||
@@ -46,13 +48,29 @@ const InputsFormNode = ({
|
||||
isMobile && 'px-4 py-3',
|
||||
)}
|
||||
>
|
||||
<Message3Fill className="h-6 w-6 shrink-0" />
|
||||
<div className="system-xl-semibold grow text-text-secondary">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
|
||||
<div className="i-custom-public-other-message-3-fill h-6 w-6 shrink-0" />
|
||||
<div className="grow text-text-secondary system-xl-semibold">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
|
||||
{collapsed && (
|
||||
<Button className="uppercase text-text-tertiary" size="small" variant="ghost" onClick={() => setCollapsed(false)}>{t('operation.edit', { ns: 'common' })}</Button>
|
||||
<Button
|
||||
className="uppercase text-text-tertiary"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => setCollapsed(false)}
|
||||
data-testid="inputs-form-edit-button"
|
||||
>
|
||||
{t('operation.edit', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
{!collapsed && currentConversationId && (
|
||||
<Button className="uppercase text-text-tertiary" size="small" variant="ghost" onClick={() => setCollapsed(true)}>{t('operation.close', { ns: 'common' })}</Button>
|
||||
<Button
|
||||
className="uppercase text-text-tertiary"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
onClick={() => setCollapsed(true)}
|
||||
data-testid="inputs-form-close-button"
|
||||
>
|
||||
{t('operation.close', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
@@ -66,6 +84,7 @@ const InputsFormNode = ({
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => handleStartChat(() => setCollapsed(true))}
|
||||
data-testid="inputs-form-start-chat-button"
|
||||
style={
|
||||
themeBuilder?.theme
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ViewFormDropdown from './view-form-dropdown'
|
||||
|
||||
// Mock InputsFormContent to avoid complex integration in this test
|
||||
vi.mock('./content', () => ({
|
||||
default: () => <div data-testid="mock-inputs-form-content" />,
|
||||
}))
|
||||
|
||||
// Note: PortalToFollowElem is mocked globally in vitest.setup.ts
|
||||
// to render children in the normal DOM flow when open is true.
|
||||
|
||||
describe('ViewFormDropdown', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
it('should render the trigger button', () => {
|
||||
render(<ViewFormDropdown />)
|
||||
expect(screen.getByTestId('view-form-dropdown-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show content initially', () => {
|
||||
render(<ViewFormDropdown />)
|
||||
expect(screen.queryByTestId('view-form-dropdown-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show content when trigger is clicked', async () => {
|
||||
render(<ViewFormDropdown />)
|
||||
await user.click(screen.getByTestId('view-form-dropdown-trigger'))
|
||||
|
||||
expect(screen.getByTestId('view-form-dropdown-content')).toBeInTheDocument()
|
||||
expect(screen.getByText(/chat.chatSettingsTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('mock-inputs-form-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close content when trigger is clicked again', async () => {
|
||||
render(<ViewFormDropdown />)
|
||||
const trigger = screen.getByTestId('view-form-dropdown-trigger')
|
||||
|
||||
await user.click(trigger) // Open
|
||||
expect(screen.getByTestId('view-form-dropdown-content')).toBeInTheDocument()
|
||||
|
||||
await user.click(trigger) // Close
|
||||
expect(screen.queryByTestId('view-form-dropdown-content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply iconColor class to the icon', async () => {
|
||||
render(<ViewFormDropdown iconColor="text-red-500" />)
|
||||
await user.click(screen.getByTestId('view-form-dropdown-trigger'))
|
||||
|
||||
const icon = screen.getByTestId('view-form-dropdown-trigger').querySelector('.i-ri-chat-settings-line')
|
||||
expect(icon).toHaveClass('text-red-500')
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,18 @@
|
||||
import {
|
||||
RiChatSettingsLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
|
||||
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
iconColor?: string
|
||||
}
|
||||
const ViewFormDropdown = ({ iconColor }: Props) => {
|
||||
|
||||
const ViewFormDropdown = ({
|
||||
iconColor,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -26,18 +26,23 @@ const ViewFormDropdown = ({ iconColor }: Props) => {
|
||||
crossAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
||||
<RiChatSettingsLine className={cn('h-[18px] w-[18px]', iconColor)} />
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<ActionButton
|
||||
size="l"
|
||||
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
|
||||
data-testid="view-form-dropdown-trigger"
|
||||
>
|
||||
<div className={cn('i-ri-chat-settings-line h-[18px] w-[18px] shrink-0', iconColor)} />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[99]">
|
||||
<div className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm">
|
||||
<div
|
||||
data-testid="view-form-dropdown-content"
|
||||
className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4">
|
||||
<Message3Fill className="h-6 w-6 shrink-0" />
|
||||
<div className="system-xl-semibold grow text-text-secondary">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
|
||||
<div className="i-custom-public-other-message-3-fill h-6 w-6 shrink-0" />
|
||||
<div className="grow text-text-secondary system-xl-semibold">{t('chat.chatSettingsTitle', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<InputsFormContent />
|
||||
@@ -45,7 +50,6 @@ const ViewFormDropdown = ({ iconColor }: Props) => {
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import IndeterminateIcon from './indeterminate-icon'
|
||||
|
||||
describe('IndeterminateIcon', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<IndeterminateIcon />)
|
||||
expect(screen.getByTestId('indeterminate-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render an svg element', () => {
|
||||
const { container } = render(<IndeterminateIcon />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -32,10 +32,10 @@ describe('YearAndMonthPicker Options', () => {
|
||||
it('should render year options', () => {
|
||||
const props = createOptionsProps()
|
||||
|
||||
render(<Options {...props} />)
|
||||
const { container } = render(<Options {...props} />)
|
||||
|
||||
const allItems = screen.getAllByRole('listitem')
|
||||
expect(allItems).toHaveLength(212)
|
||||
const yearList = container.querySelectorAll('ul')[1]
|
||||
expect(yearList?.children).toHaveLength(200)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
import { Dialog, DialogBackdrop, DialogTitle } from '@headlessui/react'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Button from '../button'
|
||||
@@ -81,7 +80,18 @@ export default function Drawer({
|
||||
)}
|
||||
{showClose && (
|
||||
<DialogTitle className="mb-4 flex cursor-pointer items-center" as="div">
|
||||
<XMarkIcon className="h-4 w-4 text-text-tertiary" onClick={onClose} />
|
||||
<span
|
||||
className="i-heroicons-x-mark h-4 w-4 text-text-tertiary"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ')
|
||||
onClose()
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
data-testid="close-icon"
|
||||
/>
|
||||
</DialogTitle>
|
||||
)}
|
||||
</div>
|
||||
|
||||
383
web/app/components/base/error-boundary/index.spec.tsx
Normal file
383
web/app/components/base/error-boundary/index.spec.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ErrorBoundary, { ErrorFallback, useAsyncError, useErrorHandler, withErrorBoundary } from './index'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
isDev: false,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_DEV() {
|
||||
return mockConfig.isDev
|
||||
},
|
||||
}))
|
||||
|
||||
type ThrowOnRenderProps = {
|
||||
message?: string
|
||||
shouldThrow: boolean
|
||||
}
|
||||
|
||||
const ThrowOnRender = ({ shouldThrow, message = 'render boom' }: ThrowOnRenderProps) => {
|
||||
if (shouldThrow)
|
||||
throw new Error(message)
|
||||
|
||||
return <div>Child content rendered</div>
|
||||
}
|
||||
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConfig.isDev = false
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
// Verify default render and default fallback behavior.
|
||||
describe('Rendering', () => {
|
||||
it('should render children when no error occurs', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender shouldThrow={false} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Child content rendered')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render default fallback with title and message when child throws', async () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Something went wrong')).toBeInTheDocument()
|
||||
expect(screen.getByText('An unexpected error occurred while rendering this component.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom title, message, and className in fallback', async () => {
|
||||
render(
|
||||
<ErrorBoundary
|
||||
className="custom-boundary"
|
||||
customMessage="Custom recovery message"
|
||||
customTitle="Custom crash title"
|
||||
isolate={false}
|
||||
>
|
||||
<ThrowOnRender shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Custom crash title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom recovery message')).toBeInTheDocument()
|
||||
|
||||
const fallbackRoot = document.querySelector('.custom-boundary')
|
||||
expect(fallbackRoot).toBeInTheDocument()
|
||||
expect(fallbackRoot).not.toHaveClass('min-h-[200px]')
|
||||
})
|
||||
})
|
||||
|
||||
// Validate explicit fallback prop variants.
|
||||
describe('Fallback props', () => {
|
||||
it('should render node fallback when fallback prop is a React node', async () => {
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Node fallback content</div>}>
|
||||
<ThrowOnRender shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Node fallback content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render function fallback with error message when fallback prop is a function', async () => {
|
||||
render(
|
||||
<ErrorBoundary
|
||||
fallback={error => (
|
||||
<div>
|
||||
Function fallback:
|
||||
{' '}
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<ThrowOnRender message="function fallback boom" shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Function fallback: function fallback boom')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Validate error reporting and details panel behavior.
|
||||
describe('Error reporting', () => {
|
||||
it('should call onError with error and errorInfo when child throws', async () => {
|
||||
const onError = vi.fn()
|
||||
|
||||
render(
|
||||
<ErrorBoundary onError={onError}>
|
||||
<ThrowOnRender shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
await screen.findByText('Something went wrong')
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'render boom' }),
|
||||
expect.objectContaining({ componentStack: expect.any(String) }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should render details block when showDetails is true', async () => {
|
||||
render(
|
||||
<ErrorBoundary showDetails={true}>
|
||||
<ThrowOnRender message="details boom" shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Error Details (Development Only)')).toBeInTheDocument()
|
||||
expect(screen.getByText('Error:')).toBeInTheDocument()
|
||||
expect(screen.getByText(/details boom/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should log boundary errors in development mode', async () => {
|
||||
mockConfig.isDev = true
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowOnRender message="dev boom" shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
await screen.findByText('Something went wrong')
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'ErrorBoundary caught an error:',
|
||||
expect.objectContaining({ message: 'dev boom' }),
|
||||
)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error Info:',
|
||||
expect.objectContaining({ componentStack: expect.any(String) }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Validate recovery controls and automatic reset triggers.
|
||||
describe('Recovery', () => {
|
||||
it('should hide recovery actions when enableRecovery is false', async () => {
|
||||
render(
|
||||
<ErrorBoundary enableRecovery={false}>
|
||||
<ThrowOnRender shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
await screen.findByText('Something went wrong')
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Try Again' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Reload Page' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset and render children when Try Again is clicked', async () => {
|
||||
const onReset = vi.fn()
|
||||
|
||||
const RecoveryHarness = () => {
|
||||
const [shouldThrow, setShouldThrow] = React.useState(true)
|
||||
return (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
onReset()
|
||||
setShouldThrow(false)
|
||||
}}
|
||||
>
|
||||
<ThrowOnRender shouldThrow={shouldThrow} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
render(<RecoveryHarness />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Try Again' }))
|
||||
|
||||
await screen.findByText('Child content rendered')
|
||||
expect(onReset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should reset after resetKeys change when boundary is in error state', async () => {
|
||||
const ResetKeysHarness = () => {
|
||||
const [shouldThrow, setShouldThrow] = React.useState(true)
|
||||
const [boundaryKey, setBoundaryKey] = React.useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShouldThrow(false)
|
||||
setBoundaryKey(1)
|
||||
}}
|
||||
>
|
||||
Recover with keys
|
||||
</button>
|
||||
<ErrorBoundary resetKeys={[boundaryKey]}>
|
||||
<ThrowOnRender shouldThrow={shouldThrow} />
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ResetKeysHarness />)
|
||||
await screen.findByText('Something went wrong')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Recover with keys' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Child content rendered')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should reset after children change when resetOnPropsChange is true', async () => {
|
||||
const ResetOnPropsHarness = () => {
|
||||
const [shouldThrow, setShouldThrow] = React.useState(true)
|
||||
const [childLabel, setChildLabel] = React.useState('first child')
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShouldThrow(false)
|
||||
setChildLabel('second child')
|
||||
}}
|
||||
>
|
||||
Replace children
|
||||
</button>
|
||||
<ErrorBoundary resetOnPropsChange={true}>
|
||||
{shouldThrow ? <ThrowOnRender shouldThrow={true} /> : <div>{childLabel}</div>}
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<ResetOnPropsHarness />)
|
||||
await screen.findByText('Something went wrong')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Replace children' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('second child')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ErrorBoundary utility exports', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
// Validate imperative error hook behavior.
|
||||
describe('useErrorHandler', () => {
|
||||
it('should trigger error boundary fallback when setError is called', async () => {
|
||||
const HookConsumer = () => {
|
||||
const setError = useErrorHandler()
|
||||
return (
|
||||
<button onClick={() => setError(new Error('handler boom'))}>
|
||||
Trigger hook error
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Hook fallback shown</div>}>
|
||||
<HookConsumer />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Trigger hook error' }))
|
||||
|
||||
expect(await screen.findByText('Hook fallback shown')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Validate async error bridge hook behavior.
|
||||
describe('useAsyncError', () => {
|
||||
it('should trigger error boundary fallback when async error callback is called', async () => {
|
||||
const AsyncHookConsumer = () => {
|
||||
const throwAsyncError = useAsyncError()
|
||||
return (
|
||||
<button onClick={() => throwAsyncError(new Error('async hook boom'))}>
|
||||
Trigger async hook error
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<ErrorBoundary fallback={<div>Async fallback shown</div>}>
|
||||
<AsyncHookConsumer />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Trigger async hook error' }))
|
||||
|
||||
expect(await screen.findByText('Async fallback shown')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Validate HOC wrapper behavior and metadata.
|
||||
describe('withErrorBoundary', () => {
|
||||
it('should wrap component and render custom title when wrapped component throws', async () => {
|
||||
type WrappedProps = {
|
||||
shouldThrow: boolean
|
||||
}
|
||||
|
||||
const WrappedTarget = ({ shouldThrow }: WrappedProps) => {
|
||||
if (shouldThrow)
|
||||
throw new Error('wrapped boom')
|
||||
return <div>Wrapped content</div>
|
||||
}
|
||||
|
||||
const Wrapped = withErrorBoundary(WrappedTarget, {
|
||||
customTitle: 'Wrapped boundary title',
|
||||
})
|
||||
|
||||
render(<Wrapped shouldThrow={true} />)
|
||||
|
||||
expect(await screen.findByText('Wrapped boundary title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set displayName using wrapped component name', () => {
|
||||
const NamedComponent = () => <div>named content</div>
|
||||
const Wrapped = withErrorBoundary(NamedComponent)
|
||||
|
||||
expect(Wrapped.displayName).toBe('withErrorBoundary(NamedComponent)')
|
||||
})
|
||||
})
|
||||
|
||||
// Validate simple fallback helper component.
|
||||
describe('ErrorFallback', () => {
|
||||
it('should render message and call reset action when button is clicked', () => {
|
||||
const resetErrorBoundaryAction = vi.fn()
|
||||
|
||||
render(
|
||||
<ErrorFallback
|
||||
error={new Error('fallback helper message')}
|
||||
resetErrorBoundaryAction={resetErrorBoundaryAction}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Oops! Something went wrong')).toBeInTheDocument()
|
||||
expect(screen.getByText('fallback helper message')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
|
||||
|
||||
expect(resetErrorBoundaryAction).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
69
web/app/components/base/features/context.spec.tsx
Normal file
69
web/app/components/base/features/context.spec.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useContext } from 'react'
|
||||
import { FeaturesContext, FeaturesProvider } from './context'
|
||||
|
||||
const TestConsumer = () => {
|
||||
const store = useContext(FeaturesContext)
|
||||
if (!store)
|
||||
return <div>no store</div>
|
||||
|
||||
const { features } = store.getState()
|
||||
return <div role="status">{features.moreLikeThis?.enabled ? 'enabled' : 'disabled'}</div>
|
||||
}
|
||||
|
||||
describe('FeaturesProvider', () => {
|
||||
it('should provide store to children when FeaturesProvider wraps them', () => {
|
||||
render(
|
||||
<FeaturesProvider>
|
||||
<TestConsumer />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('disabled')
|
||||
})
|
||||
|
||||
it('should provide initial features state when features prop is provided', () => {
|
||||
render(
|
||||
<FeaturesProvider features={{ moreLikeThis: { enabled: true } }}>
|
||||
<TestConsumer />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('enabled')
|
||||
})
|
||||
|
||||
it('should maintain the same store reference across re-renders', () => {
|
||||
const storeRefs: Array<ReturnType<typeof useContext>> = []
|
||||
|
||||
const StoreRefCollector = () => {
|
||||
const store = useContext(FeaturesContext)
|
||||
storeRefs.push(store)
|
||||
return null
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<FeaturesProvider>
|
||||
<StoreRefCollector />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<FeaturesProvider>
|
||||
<StoreRefCollector />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
|
||||
expect(storeRefs[0]).toBe(storeRefs[1])
|
||||
})
|
||||
|
||||
it('should handle empty features object', () => {
|
||||
render(
|
||||
<FeaturesProvider features={{}}>
|
||||
<TestConsumer />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('disabled')
|
||||
})
|
||||
})
|
||||
63
web/app/components/base/features/hooks.spec.ts
Normal file
63
web/app/components/base/features/hooks.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { FeaturesContext } from './context'
|
||||
import { useFeatures, useFeaturesStore } from './hooks'
|
||||
import { createFeaturesStore } from './store'
|
||||
|
||||
describe('useFeatures', () => {
|
||||
it('should return selected state from the store when useFeatures is called with selector', () => {
|
||||
const store = createFeaturesStore({
|
||||
features: { moreLikeThis: { enabled: true } },
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(FeaturesContext.Provider, { value: store }, children)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useFeatures(s => s.features.moreLikeThis?.enabled),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should throw error when used outside FeaturesContext.Provider', () => {
|
||||
// Act & Assert
|
||||
expect(() => {
|
||||
renderHook(() => useFeatures(s => s.features))
|
||||
}).toThrow('Missing FeaturesContext.Provider in the tree')
|
||||
})
|
||||
|
||||
it('should return undefined when feature does not exist', () => {
|
||||
const store = createFeaturesStore({ features: {} })
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(FeaturesContext.Provider, { value: store }, children)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useFeatures(s => (s.features as Record<string, unknown>).nonexistent as boolean | undefined),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
expect(result.current).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFeaturesStore', () => {
|
||||
it('should return the store from context when used within provider', () => {
|
||||
const store = createFeaturesStore()
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(FeaturesContext.Provider, { value: store }, children)
|
||||
|
||||
const { result } = renderHook(() => useFeaturesStore(), { wrapper })
|
||||
|
||||
expect(result.current).toBe(store)
|
||||
})
|
||||
|
||||
it('should return null when used outside provider', () => {
|
||||
const { result } = renderHook(() => useFeaturesStore())
|
||||
|
||||
expect(result.current).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import AnnotationCtrlButton from './annotation-ctrl-button'
|
||||
|
||||
const mockSetShowAnnotationFullModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowAnnotationFullModal: mockSetShowAnnotationFullModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockAnnotatedResponseUsage = 5
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: {
|
||||
usage: { get annotatedResponse() { return mockAnnotatedResponseUsage } },
|
||||
total: { annotatedResponse: 100 },
|
||||
},
|
||||
enableBilling: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockAddAnnotation = vi.fn().mockResolvedValue({
|
||||
id: 'annotation-1',
|
||||
account: { name: 'Test User' },
|
||||
})
|
||||
|
||||
vi.mock('@/service/annotation', () => ({
|
||||
addAnnotation: (...args: unknown[]) => mockAddAnnotation(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
describe('AnnotationCtrlButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAnnotatedResponseUsage = 5
|
||||
})
|
||||
|
||||
it('should render edit button when cached', () => {
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
cached={true}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onEdit when edit button is clicked', () => {
|
||||
const onEdit = vi.fn()
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
cached={true}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={vi.fn()}
|
||||
onEdit={onEdit}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onEdit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render add button when not cached and has answer', () => {
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
cached={false}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render any button when not cached and no answer', () => {
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
cached={false}
|
||||
query="test query"
|
||||
answer=""
|
||||
onAdded={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call addAnnotation and onAdded when add button is clicked', async () => {
|
||||
const onAdded = vi.fn()
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
messageId="msg-1"
|
||||
cached={false}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={onAdded}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddAnnotation).toHaveBeenCalledWith('test-app', {
|
||||
message_id: 'msg-1',
|
||||
question: 'test query',
|
||||
answer: 'test answer',
|
||||
})
|
||||
expect(onAdded).toHaveBeenCalledWith('annotation-1', 'Test User')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show annotation full modal when annotation limit is reached', () => {
|
||||
mockAnnotatedResponseUsage = 100
|
||||
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
cached={false}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(mockSetShowAnnotationFullModal).toHaveBeenCalled()
|
||||
expect(mockAddAnnotation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,415 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import ConfigParamModal from './config-param-modal'
|
||||
|
||||
let mockHooksReturn: {
|
||||
modelList: { provider: { provider: string }, models: { model: string }[] }[]
|
||||
defaultModel: { provider: { provider: string }, model: string } | undefined
|
||||
currentModel: boolean | undefined
|
||||
} = {
|
||||
modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
|
||||
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
|
||||
currentModel: true,
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: () => mockHooksReturn,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
ModelTypeEnum: {
|
||||
textEmbedding: 'text-embedding',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: ({ defaultModel, onSelect }: { defaultModel?: { provider: string, model: string }, onSelect: (val: { provider: string, model: string }) => void }) => (
|
||||
<div data-testid="model-selector" data-provider={defaultModel?.provider} data-model={defaultModel?.model}>
|
||||
Model Selector
|
||||
<button data-testid="select-model" onClick={() => onSelect({ provider: 'cohere', model: 'embed-english' })}>Select</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
ANNOTATION_DEFAULT: { score_threshold: 0.9 },
|
||||
}))
|
||||
|
||||
const defaultAnnotationConfig = {
|
||||
id: 'test-id',
|
||||
enabled: false,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
}
|
||||
|
||||
describe('ConfigParamModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHooksReturn = {
|
||||
modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
|
||||
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
|
||||
currentModel: true,
|
||||
}
|
||||
})
|
||||
|
||||
it('should not render when isShow is false', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={false}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/initSetup/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render init title when isInit is true', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
isInit={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/initSetup\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render config title when isInit is false', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
isInit={false}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/initSetup\.configTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render score slider', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render model selector', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel and confirm buttons', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
isInit={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/initSetup\.confirmBtn/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display score threshold value', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('0.90')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render configConfirmBtn when isInit is false', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
isInit={false}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/initSetup\.configConfirmBtn/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSave with embedding model and score when save is clicked', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click the confirm/save button
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
{ embedding_provider_name: 'openai', embedding_model_name: 'text-embedding-ada-002' },
|
||||
0.9,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast when embedding model is not set', () => {
|
||||
const configWithoutModel = {
|
||||
...defaultAnnotationConfig,
|
||||
embedding_model: undefined as unknown as typeof defaultAnnotationConfig.embedding_model,
|
||||
}
|
||||
|
||||
// Override hooks to return no default model and no valid current model
|
||||
mockHooksReturn = {
|
||||
modelList: [],
|
||||
defaultModel: undefined,
|
||||
currentModel: undefined,
|
||||
}
|
||||
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={configWithoutModel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
expect(Toast.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onHide when cancel is clicked and not loading', () => {
|
||||
const onHide = vi.fn()
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={onHide}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render slider with expected bounds and current value', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '80')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '100')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '90')
|
||||
})
|
||||
|
||||
it('should update embedding model when model selector is used', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click the select model button in mock
|
||||
fireEvent.click(screen.getByTestId('select-model'))
|
||||
|
||||
// Model selector should now show the new provider/model
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-provider', 'cohere')
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'embed-english')
|
||||
})
|
||||
|
||||
it('should call onSave with updated score from annotation config', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
annotationConfig={{
|
||||
...defaultAnnotationConfig,
|
||||
score_threshold: 0.95,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Save
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ embedding_provider_name: 'openai' }),
|
||||
0.95,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onSave with updated model after model selector change', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Change model
|
||||
fireEvent.click(screen.getByTestId('select-model'))
|
||||
|
||||
// Save
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
{ embedding_provider_name: 'cohere', embedding_model_name: 'embed-english' },
|
||||
0.9,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should use default model when annotation config has no embedding model', () => {
|
||||
const configWithoutModel = {
|
||||
...defaultAnnotationConfig,
|
||||
embedding_model: undefined as unknown as typeof defaultAnnotationConfig.embedding_model,
|
||||
}
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={configWithoutModel}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Model selector should be initialized with the default model
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-provider', 'openai')
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'text-embedding-ada-002')
|
||||
})
|
||||
|
||||
it('should use ANNOTATION_DEFAULT score_threshold when config has no score_threshold', () => {
|
||||
const configWithoutThreshold = {
|
||||
...defaultAnnotationConfig,
|
||||
score_threshold: 0,
|
||||
}
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={configWithoutThreshold}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '90')
|
||||
})
|
||||
|
||||
it('should set loading state while saving', async () => {
|
||||
let resolveOnSave: () => void
|
||||
const onSave = vi.fn().mockImplementation(() => new Promise<void>((resolve) => {
|
||||
resolveOnSave = resolve
|
||||
}))
|
||||
const onHide = vi.fn()
|
||||
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={onHide}
|
||||
onSave={onSave}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click save
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
// While loading, clicking cancel should not call onHide
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
|
||||
// Resolve the save
|
||||
resolveOnSave!()
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user