mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 01:45:13 +00:00
Compare commits
6 Commits
4c48e3b997
...
7c60ad01d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c60ad01d3 | ||
|
|
57890eed25 | ||
|
|
737575d637 | ||
|
|
f76ee7cfa4 | ||
|
|
a0244d1390 | ||
|
|
42af9d5438 |
@@ -39,7 +39,7 @@ class Moderation(Extensible, ABC):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@abstractmethod
|
@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.
|
Validate the incoming form config data.
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ except ImportError:
|
|||||||
from importlib_metadata import version # type: ignore[import-not-found]
|
from importlib_metadata import version # type: ignore[import-not-found]
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from opentelemetry.metrics import Meter
|
from opentelemetry.metrics import Histogram, Meter
|
||||||
from opentelemetry.metrics._internal.instrument import Histogram
|
|
||||||
from opentelemetry.sdk.metrics.export import MetricReader
|
from opentelemetry.sdk.metrics.export import MetricReader
|
||||||
|
|
||||||
from opentelemetry import trace as trace_api
|
from opentelemetry import trace as trace_api
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class BaseVector(ABC):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@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
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@@ -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 json
|
||||||
import pickle
|
import pickle
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import patch
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from models.dataset import (
|
from models.dataset import (
|
||||||
@@ -954,156 +954,6 @@ class TestChildChunk:
|
|||||||
assert child_chunk.index_node_hash == index_node_hash
|
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:
|
class TestDocumentSegmentNavigation:
|
||||||
"""Test suite for DocumentSegment navigation properties."""
|
"""Test suite for DocumentSegment navigation properties."""
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ describe('YearAndMonthPicker Options', () => {
|
|||||||
it('should render year options', () => {
|
it('should render year options', () => {
|
||||||
const props = createOptionsProps()
|
const props = createOptionsProps()
|
||||||
|
|
||||||
render(<Options {...props} />)
|
const { container } = render(<Options {...props} />)
|
||||||
|
|
||||||
const allItems = screen.getAllByRole('listitem')
|
const yearList = container.querySelectorAll('ul')[1]
|
||||||
expect(allItems).toHaveLength(212)
|
expect(yearList?.children).toHaveLength(200)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { AppContextValue } from '@/context/app-context'
|
||||||
|
import type { ICurrentWorkspace } from '@/models/common'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import { ToastContext } from '@/app/components/base/toast'
|
||||||
|
import { useAppContext } from '@/context/app-context'
|
||||||
|
import { updateWorkspaceInfo } from '@/service/common'
|
||||||
|
import EditWorkspaceModal from './index'
|
||||||
|
|
||||||
|
vi.mock('@/context/app-context')
|
||||||
|
vi.mock('@/service/common')
|
||||||
|
|
||||||
|
describe('EditWorkspaceModal', () => {
|
||||||
|
const mockOnCancel = vi.fn()
|
||||||
|
const mockNotify = vi.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
vi.mocked(useAppContext).mockReturnValue({
|
||||||
|
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
|
||||||
|
isCurrentWorkspaceOwner: true,
|
||||||
|
} as unknown as AppContextValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderModal = () => render(
|
||||||
|
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
|
||||||
|
<EditWorkspaceModal onCancel={mockOnCancel} />
|
||||||
|
</ToastContext.Provider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
it('should show current workspace name in the input', async () => {
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue('Test Workspace')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should let user edit workspace name', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
|
||||||
|
await user.clear(input)
|
||||||
|
await user.type(input, 'New Workspace Name')
|
||||||
|
|
||||||
|
expect(input).toHaveValue('New Workspace Name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should submit update when confirming as owner', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const mockAssign = vi.fn()
|
||||||
|
vi.stubGlobal('location', { ...window.location, assign: mockAssign })
|
||||||
|
vi.mocked(updateWorkspaceInfo).mockResolvedValue({} as ICurrentWorkspace)
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
|
||||||
|
await user.clear(input)
|
||||||
|
await user.type(input, 'Renamed Workspace')
|
||||||
|
await user.click(screen.getByRole('button', { name: /operation\.confirm/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(updateWorkspaceInfo).toHaveBeenCalledWith({
|
||||||
|
url: '/workspaces/info',
|
||||||
|
body: { name: 'Renamed Workspace' },
|
||||||
|
})
|
||||||
|
expect(mockAssign).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show error toast when update fails', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
vi.mocked(updateWorkspaceInfo).mockRejectedValue(new Error('update failed'))
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /operation\.confirm/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'error',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should disable confirm button for non-owners', async () => {
|
||||||
|
vi.mocked(useAppContext).mockReturnValue({
|
||||||
|
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
|
||||||
|
isCurrentWorkspaceOwner: false,
|
||||||
|
} as unknown as AppContextValue)
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: /operation\.confirm/i })).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import type { AppContextValue } from '@/context/app-context'
|
||||||
|
import type { ICurrentWorkspace, Member } from '@/models/common'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||||
|
import { useAppContext } from '@/context/app-context'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
import { useProviderContext } from '@/context/provider-context'
|
||||||
|
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||||
|
import { useMembers } from '@/service/use-common'
|
||||||
|
import MembersPage from './index'
|
||||||
|
|
||||||
|
vi.mock('@/context/app-context')
|
||||||
|
vi.mock('@/context/global-public-context')
|
||||||
|
vi.mock('@/context/provider-context')
|
||||||
|
vi.mock('@/hooks/use-format-time-from-now')
|
||||||
|
vi.mock('@/service/use-common')
|
||||||
|
|
||||||
|
vi.mock('./edit-workspace-modal', () => ({
|
||||||
|
default: ({ onCancel }: { onCancel: () => void }) => (
|
||||||
|
<div>
|
||||||
|
<div>Edit Workspace Modal</div>
|
||||||
|
<button onClick={onCancel}>Close Edit Workspace</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
vi.mock('./invite-button', () => ({
|
||||||
|
default: ({ onClick, disabled }: { onClick: () => void, disabled: boolean }) => (
|
||||||
|
<button onClick={onClick} disabled={disabled}>Invite</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
vi.mock('./invite-modal', () => ({
|
||||||
|
default: ({ onCancel, onSend }: { onCancel: () => void, onSend: (results: Array<{ email: string, status: 'success', url: string }>) => void }) => (
|
||||||
|
<div>
|
||||||
|
<div>Invite Modal</div>
|
||||||
|
<button onClick={onCancel}>Close Invite Modal</button>
|
||||||
|
<button onClick={() => onSend([{ email: 'sent@example.com', status: 'success', url: 'http://invite/link' }])}>Send Invite Results</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
vi.mock('./invited-modal', () => ({
|
||||||
|
default: ({ onCancel }: { onCancel: () => void }) => (
|
||||||
|
<div>
|
||||||
|
<div>Invited Modal</div>
|
||||||
|
<button onClick={onCancel}>Close Invited Modal</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
vi.mock('./operation', () => ({
|
||||||
|
default: () => <div>Member Operation</div>,
|
||||||
|
}))
|
||||||
|
vi.mock('./operation/transfer-ownership', () => ({
|
||||||
|
default: ({ onOperate }: { onOperate: () => void }) => <button onClick={onOperate}>Transfer ownership</button>,
|
||||||
|
}))
|
||||||
|
vi.mock('./transfer-ownership-modal', () => ({
|
||||||
|
default: ({ onClose }: { onClose: () => void }) => (
|
||||||
|
<div>
|
||||||
|
<div>Transfer Ownership Modal</div>
|
||||||
|
<button onClick={onClose}>Close Transfer Modal</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('MembersPage', () => {
|
||||||
|
const mockRefetch = vi.fn()
|
||||||
|
const mockFormatTimeFromNow = vi.fn(() => 'just now')
|
||||||
|
|
||||||
|
const mockAccounts: Member[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Owner User',
|
||||||
|
email: 'owner@example.com',
|
||||||
|
avatar: '',
|
||||||
|
avatar_url: '',
|
||||||
|
role: 'owner',
|
||||||
|
last_active_at: '1731000000',
|
||||||
|
last_login_at: '1731000000',
|
||||||
|
created_at: '1731000000',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Admin User',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
avatar: '',
|
||||||
|
avatar_url: '',
|
||||||
|
role: 'admin',
|
||||||
|
last_active_at: '1731000000',
|
||||||
|
last_login_at: '1731000000',
|
||||||
|
created_at: '1731000000',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
vi.mocked(useAppContext).mockReturnValue({
|
||||||
|
userProfile: { email: 'owner@example.com' },
|
||||||
|
currentWorkspace: { name: 'Test Workspace', role: 'owner' } as ICurrentWorkspace,
|
||||||
|
isCurrentWorkspaceOwner: true,
|
||||||
|
isCurrentWorkspaceManager: true,
|
||||||
|
} as unknown as AppContextValue)
|
||||||
|
|
||||||
|
vi.mocked(useMembers).mockReturnValue({
|
||||||
|
data: { accounts: mockAccounts },
|
||||||
|
refetch: mockRefetch,
|
||||||
|
} as unknown as ReturnType<typeof useMembers>)
|
||||||
|
|
||||||
|
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
|
||||||
|
systemFeatures: { is_email_setup: true },
|
||||||
|
} as unknown as Parameters<typeof selector>[0]))
|
||||||
|
|
||||||
|
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
|
||||||
|
enableBilling: false,
|
||||||
|
isAllowTransferWorkspace: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mocked(useFormatTimeFromNow).mockReturnValue({
|
||||||
|
formatTimeFromNow: mockFormatTimeFromNow,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render workspace and member information', () => {
|
||||||
|
render(<MembersPage />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Workspace')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Owner User')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Admin User')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should open and close invite modal', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<MembersPage />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /invite/i }))
|
||||||
|
expect(screen.getByText('Invite Modal')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Close Invite Modal' }))
|
||||||
|
expect(screen.queryByText('Invite Modal')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should open invited modal after invite results are sent', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<MembersPage />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /invite/i }))
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Send Invite Results' }))
|
||||||
|
|
||||||
|
expect(screen.getByText('Invited Modal')).toBeInTheDocument()
|
||||||
|
expect(mockRefetch).toHaveBeenCalled()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Close Invited Modal' }))
|
||||||
|
expect(screen.queryByText('Invited Modal')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should open transfer ownership modal when transfer action is used', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<MembersPage />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /transfer ownership/i }))
|
||||||
|
expect(screen.getByText('Transfer Ownership Modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show non-interactive owner role when transfer ownership is not allowed', () => {
|
||||||
|
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
|
||||||
|
enableBilling: false,
|
||||||
|
isAllowTransferWorkspace: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
render(<MembersPage />)
|
||||||
|
|
||||||
|
expect(screen.getByText('common.members.owner')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide manager controls for non-owner non-manager users', () => {
|
||||||
|
vi.mocked(useAppContext).mockReturnValue({
|
||||||
|
userProfile: { email: 'admin@example.com' },
|
||||||
|
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
|
||||||
|
isCurrentWorkspaceOwner: false,
|
||||||
|
isCurrentWorkspaceManager: false,
|
||||||
|
} as unknown as AppContextValue)
|
||||||
|
|
||||||
|
render(<MembersPage />)
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: /invite/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Transfer ownership')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { AppContextValue } from '@/context/app-context'
|
||||||
|
import type { ICurrentWorkspace } from '@/models/common'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import { useAppContext } from '@/context/app-context'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
import { useWorkspacePermissions } from '@/service/use-workspace'
|
||||||
|
import InviteButton from './invite-button'
|
||||||
|
|
||||||
|
vi.mock('@/context/app-context')
|
||||||
|
vi.mock('@/context/global-public-context')
|
||||||
|
vi.mock('@/service/use-workspace')
|
||||||
|
|
||||||
|
describe('InviteButton', () => {
|
||||||
|
const setupMocks = ({
|
||||||
|
brandingEnabled,
|
||||||
|
isFetching,
|
||||||
|
allowInvite,
|
||||||
|
}: {
|
||||||
|
brandingEnabled: boolean
|
||||||
|
isFetching: boolean
|
||||||
|
allowInvite?: boolean
|
||||||
|
}) => {
|
||||||
|
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
|
||||||
|
systemFeatures: { branding: { enabled: brandingEnabled } },
|
||||||
|
} as unknown as Parameters<typeof selector>[0]))
|
||||||
|
vi.mocked(useWorkspacePermissions).mockReturnValue({
|
||||||
|
data: allowInvite === undefined ? null : { allow_member_invite: allowInvite },
|
||||||
|
isFetching,
|
||||||
|
} as unknown as ReturnType<typeof useWorkspacePermissions>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(useAppContext).mockReturnValue({
|
||||||
|
currentWorkspace: { id: 'workspace-id' } as ICurrentWorkspace,
|
||||||
|
} as unknown as AppContextValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show invite button when branding is disabled', () => {
|
||||||
|
setupMocks({ brandingEnabled: false, isFetching: false })
|
||||||
|
|
||||||
|
render(<InviteButton />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show loading status while permissions are loading', () => {
|
||||||
|
setupMocks({ brandingEnabled: true, isFetching: true })
|
||||||
|
|
||||||
|
render(<InviteButton />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide invite button when permission is denied', () => {
|
||||||
|
setupMocks({ brandingEnabled: true, isFetching: false, allowInvite: false })
|
||||||
|
|
||||||
|
render(<InviteButton />)
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: /members\.invite/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show invite button when permission is granted', () => {
|
||||||
|
setupMocks({ brandingEnabled: true, isFetching: false, allowInvite: true })
|
||||||
|
|
||||||
|
render(<InviteButton />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { InvitationResponse } from '@/models/common'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import { ToastContext } from '@/app/components/base/toast'
|
||||||
|
import { useProviderContextSelector } from '@/context/provider-context'
|
||||||
|
import { inviteMember } from '@/service/common'
|
||||||
|
import InviteModal from './index'
|
||||||
|
|
||||||
|
vi.mock('@/context/provider-context', () => ({
|
||||||
|
useProviderContextSelector: vi.fn(),
|
||||||
|
useProviderContext: vi.fn(() => ({
|
||||||
|
datasetOperatorEnabled: true,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
vi.mock('@/service/common')
|
||||||
|
vi.mock('@/context/i18n', () => ({
|
||||||
|
useLocale: () => 'en-US',
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('InviteModal', () => {
|
||||||
|
const mockOnCancel = vi.fn()
|
||||||
|
const mockOnSend = vi.fn()
|
||||||
|
const mockRefreshLicenseLimit = vi.fn()
|
||||||
|
const mockNotify = vi.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||||
|
licenseLimit: { workspace_members: { size: 5, limit: 10 } },
|
||||||
|
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||||
|
} as unknown as Parameters<typeof selector>[0]))
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderModal = (isEmailSetup = true) => render(
|
||||||
|
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
|
||||||
|
<InviteModal isEmailSetup={isEmailSetup} onCancel={mockOnCancel} onSend={mockOnSend} />
|
||||||
|
</ToastContext.Provider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
it('should render invite modal content', async () => {
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
expect(await screen.findByText(/members\.inviteTeamMember$/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show warning when email service is not configured', async () => {
|
||||||
|
renderModal(false)
|
||||||
|
|
||||||
|
expect(await screen.findByText(/members\.emailNotSetup$/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should enable send button after entering an email', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.type(input, 'user@example.com{enter}')
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not close modal when invite request fails', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.mocked(inviteMember).mockRejectedValue(new Error('request failed'))
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
await user.type(screen.getByRole('textbox'), 'user@example.com{enter}')
|
||||||
|
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(inviteMember).toHaveBeenCalled()
|
||||||
|
expect(mockOnCancel).not.toHaveBeenCalled()
|
||||||
|
expect(mockOnSend).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should send invites and close modal on successful submission', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
vi.mocked(inviteMember).mockResolvedValue({
|
||||||
|
result: 'success',
|
||||||
|
invitation_results: [],
|
||||||
|
} as InvitationResponse)
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.type(input, 'user@example.com{enter}')
|
||||||
|
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(inviteMember).toHaveBeenCalled()
|
||||||
|
expect(mockRefreshLicenseLimit).toHaveBeenCalled()
|
||||||
|
expect(mockOnCancel).toHaveBeenCalled()
|
||||||
|
expect(mockOnSend).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep send button disabled when license limit is exceeded', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
|
||||||
|
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
|
||||||
|
refreshLicenseLimit: mockRefreshLicenseLimit,
|
||||||
|
} as unknown as Parameters<typeof selector>[0]))
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await user.type(input, 'user@example.com{enter}')
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import { useProviderContext } from '@/context/provider-context'
|
||||||
|
import RoleSelector from './role-selector'
|
||||||
|
|
||||||
|
vi.mock('@/context/provider-context')
|
||||||
|
|
||||||
|
type WrapperProps = {
|
||||||
|
initialRole?: 'normal' | 'editor' | 'admin' | 'dataset_operator'
|
||||||
|
}
|
||||||
|
|
||||||
|
const RoleSelectorWrapper = ({ initialRole = 'normal' }: WrapperProps) => {
|
||||||
|
const [role, setRole] = useState<'normal' | 'editor' | 'admin' | 'dataset_operator'>(initialRole)
|
||||||
|
return <RoleSelector value={role} onChange={setRole} />
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RoleSelector', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(useProviderContext).mockReturnValue({
|
||||||
|
datasetOperatorEnabled: true,
|
||||||
|
} as unknown as ReturnType<typeof useProviderContext>)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show current role in trigger text', () => {
|
||||||
|
render(<RoleSelectorWrapper initialRole="admin" />)
|
||||||
|
|
||||||
|
expect(screen.getByText(/members\.invitedAsRole/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'common.members.admin',
|
||||||
|
'common.members.editor',
|
||||||
|
'common.members.datasetOperator',
|
||||||
|
])('should update selected role after user chooses %s', async (nextRoleLabel) => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<RoleSelectorWrapper initialRole="normal" />)
|
||||||
|
|
||||||
|
await user.click(screen.getByText(/members\.invitedAsRole/i))
|
||||||
|
await user.click(screen.getByText(nextRoleLabel))
|
||||||
|
|
||||||
|
expect(screen.getByText(new RegExp(nextRoleLabel.replace('.', '\\.'), 'i'))).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide dataset operator option when feature is disabled', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
vi.mocked(useProviderContext).mockReturnValue({
|
||||||
|
datasetOperatorEnabled: false,
|
||||||
|
} as unknown as ReturnType<typeof useProviderContext>)
|
||||||
|
|
||||||
|
render(<RoleSelectorWrapper />)
|
||||||
|
|
||||||
|
await user.click(screen.getByText(/members\.invitedAsRole/i))
|
||||||
|
|
||||||
|
expect(screen.queryByText('common.members.datasetOperator')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { InvitationResult } from '@/models/common'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import InvitedModal from './index'
|
||||||
|
|
||||||
|
vi.mock('@/config', () => ({
|
||||||
|
IS_CE_EDITION: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('InvitedModal', () => {
|
||||||
|
const mockOnCancel = vi.fn()
|
||||||
|
const results: InvitationResult[] = [
|
||||||
|
{ email: 'success@example.com', status: 'success', url: 'http://invite.com/1' },
|
||||||
|
{ email: 'failed@example.com', status: 'failed', message: 'Error msg' },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('should show success and failed invitation sections', async () => {
|
||||||
|
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
|
||||||
|
|
||||||
|
expect(await screen.findByText(/members\.invitationSent$/i)).toBeInTheDocument()
|
||||||
|
expect(await screen.findByText(/members\.invitationLink/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('http://invite.com/1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('failed@example.com')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import InvitationLink from './invitation-link'
|
||||||
|
|
||||||
|
describe('InvitationLink', () => {
|
||||||
|
const value = { email: 'test@example.com', status: 'success' as const, url: '/invite/123' }
|
||||||
|
|
||||||
|
it('should render invitation url and keep it visible after click', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<InvitationLink value={value} />)
|
||||||
|
|
||||||
|
const url = screen.getByText('/invite/123')
|
||||||
|
await user.click(url)
|
||||||
|
|
||||||
|
expect(url).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep link visible after copy feedback timeout passes', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<InvitationLink value={value} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByText('/invite/123'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('/invite/123')).toBeInTheDocument()
|
||||||
|
}, { timeout: 1500 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Member } from '@/models/common'
|
import type { Member } from '@/models/common'
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
import { vi } from 'vitest'
|
import { vi } from 'vitest'
|
||||||
import { ToastContext } from '@/app/components/base/toast'
|
import { ToastContext } from '@/app/components/base/toast'
|
||||||
import Operation from './index'
|
import Operation from './index'
|
||||||
@@ -55,20 +56,45 @@ describe('Operation', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows dataset operator option when the feature flag is enabled', async () => {
|
it('shows dataset operator option when the feature flag is enabled', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
|
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
|
||||||
renderOperation()
|
renderOperation()
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('common.members.editor'))
|
await user.click(screen.getByText('common.members.editor'))
|
||||||
|
|
||||||
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
|
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows owner-allowed role options for admin operators', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
renderOperation({}, 'admin')
|
||||||
|
|
||||||
|
await user.click(screen.getByText('common.members.editor'))
|
||||||
|
|
||||||
|
expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show role options for unsupported operators', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
renderOperation({}, 'normal')
|
||||||
|
|
||||||
|
await user.click(screen.getByText('common.members.editor'))
|
||||||
|
|
||||||
|
expect(screen.queryByText('common.members.normal')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('calls updateMemberRole and onOperate when selecting another role', async () => {
|
it('calls updateMemberRole and onOperate when selecting another role', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
const onOperate = vi.fn()
|
const onOperate = vi.fn()
|
||||||
renderOperation({}, 'owner', onOperate)
|
renderOperation({}, 'owner', onOperate)
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('common.members.editor'))
|
await user.click(screen.getByText('common.members.editor'))
|
||||||
fireEvent.click(await screen.findByText('common.members.normal'))
|
await user.click(await screen.findByText('common.members.normal'))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUpdateMemberRole).toHaveBeenCalled()
|
expect(mockUpdateMemberRole).toHaveBeenCalled()
|
||||||
@@ -77,11 +103,12 @@ describe('Operation', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('calls deleteMemberOrCancelInvitation when removing the member', async () => {
|
it('calls deleteMemberOrCancelInvitation when removing the member', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
const onOperate = vi.fn()
|
const onOperate = vi.fn()
|
||||||
renderOperation({}, 'owner', onOperate)
|
renderOperation({}, 'owner', onOperate)
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('common.members.editor'))
|
await user.click(screen.getByText('common.members.editor'))
|
||||||
fireEvent.click(await screen.findByText('common.members.removeFromTeam'))
|
await user.click(await screen.findByText('common.members.removeFromTeam'))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled()
|
expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled()
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { AppContextValue } from '@/context/app-context'
|
||||||
|
import type { ICurrentWorkspace } from '@/models/common'
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import { useAppContext } from '@/context/app-context'
|
||||||
|
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||||
|
import { useWorkspacePermissions } from '@/service/use-workspace'
|
||||||
|
import TransferOwnership from './transfer-ownership'
|
||||||
|
|
||||||
|
vi.mock('@/context/app-context')
|
||||||
|
vi.mock('@/context/global-public-context')
|
||||||
|
vi.mock('@/service/use-workspace')
|
||||||
|
|
||||||
|
describe('TransferOwnership', () => {
|
||||||
|
const setupMocks = ({
|
||||||
|
brandingEnabled,
|
||||||
|
isFetching,
|
||||||
|
allowOwnerTransfer,
|
||||||
|
}: {
|
||||||
|
brandingEnabled: boolean
|
||||||
|
isFetching: boolean
|
||||||
|
allowOwnerTransfer?: boolean
|
||||||
|
}) => {
|
||||||
|
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
|
||||||
|
systemFeatures: { branding: { enabled: brandingEnabled } },
|
||||||
|
} as unknown as Parameters<typeof selector>[0]))
|
||||||
|
vi.mocked(useWorkspacePermissions).mockReturnValue({
|
||||||
|
data: allowOwnerTransfer === undefined ? null : { allow_owner_transfer: allowOwnerTransfer },
|
||||||
|
isFetching,
|
||||||
|
} as unknown as ReturnType<typeof useWorkspacePermissions>)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(useAppContext).mockReturnValue({
|
||||||
|
currentWorkspace: { id: 'workspace-id' } as ICurrentWorkspace,
|
||||||
|
} as unknown as AppContextValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show loading status while permissions are loading', () => {
|
||||||
|
setupMocks({ brandingEnabled: true, isFetching: true })
|
||||||
|
|
||||||
|
render(<TransferOwnership onOperate={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show owner text without transfer menu when transfer is forbidden', () => {
|
||||||
|
setupMocks({ brandingEnabled: true, isFetching: false, allowOwnerTransfer: false })
|
||||||
|
|
||||||
|
render(<TransferOwnership onOperate={vi.fn()} />)
|
||||||
|
|
||||||
|
expect(screen.getByText(/members\.owner/i)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/members\.transferOwnership/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should open transfer dialog when transfer option is selected', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onOperate = vi.fn()
|
||||||
|
|
||||||
|
setupMocks({ brandingEnabled: true, isFetching: false, allowOwnerTransfer: true })
|
||||||
|
|
||||||
|
render(<TransferOwnership onOperate={onOperate} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /members\.owner/i }))
|
||||||
|
const transferOptionText = await screen.findByText(/members\.transferOwnership/i)
|
||||||
|
const transferOption = transferOptionText.closest('div.cursor-pointer')
|
||||||
|
if (!transferOption)
|
||||||
|
throw new Error('Transfer option container not found')
|
||||||
|
fireEvent.click(transferOption)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onOperate).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow transfer menu when branding is disabled', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
setupMocks({ brandingEnabled: false, isFetching: false })
|
||||||
|
|
||||||
|
render(<TransferOwnership onOperate={vi.fn()} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /members\.owner/i }))
|
||||||
|
|
||||||
|
expect(screen.getByText(/members\.transferOwnership/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { AppContextValue } from '@/context/app-context'
|
||||||
|
import type { ICurrentWorkspace } from '@/models/common'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import { ToastContext } from '@/app/components/base/toast'
|
||||||
|
import { useAppContext } from '@/context/app-context'
|
||||||
|
import { ownershipTransfer, sendOwnerEmail, verifyOwnerEmail } from '@/service/common'
|
||||||
|
import TransferOwnershipModal from './index'
|
||||||
|
|
||||||
|
vi.mock('@/context/app-context')
|
||||||
|
vi.mock('@/service/common')
|
||||||
|
|
||||||
|
vi.mock('./member-selector', () => ({
|
||||||
|
default: ({ onSelect }: { onSelect: (id: string) => void }) => (
|
||||||
|
<button onClick={() => onSelect('new-owner-id')}>Select member</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('TransferOwnershipModal', () => {
|
||||||
|
const mockOnClose = vi.fn()
|
||||||
|
const mockNotify = vi.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.spyOn(globalThis, 'setInterval').mockImplementation(() => 0 as unknown as ReturnType<typeof setInterval>)
|
||||||
|
vi.spyOn(globalThis, 'clearInterval').mockImplementation(() => {})
|
||||||
|
|
||||||
|
vi.mocked(useAppContext).mockReturnValue({
|
||||||
|
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
|
||||||
|
userProfile: { email: 'owner@example.com', id: 'owner-id' },
|
||||||
|
} as unknown as AppContextValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderModal = () => render(
|
||||||
|
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
|
||||||
|
<TransferOwnershipModal show onClose={mockOnClose} />
|
||||||
|
</ToastContext.Provider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const mockEmailVerification = ({
|
||||||
|
isValid = true,
|
||||||
|
token = 'final-token',
|
||||||
|
}: {
|
||||||
|
isValid?: boolean
|
||||||
|
token?: string
|
||||||
|
} = {}) => {
|
||||||
|
vi.mocked(sendOwnerEmail).mockResolvedValue({
|
||||||
|
data: 'step-token',
|
||||||
|
result: 'success',
|
||||||
|
} as Awaited<ReturnType<typeof sendOwnerEmail>>)
|
||||||
|
vi.mocked(verifyOwnerEmail).mockResolvedValue({
|
||||||
|
is_valid: isValid,
|
||||||
|
token,
|
||||||
|
result: 'success',
|
||||||
|
} as Awaited<ReturnType<typeof verifyOwnerEmail>>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToTransferStep = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||||
|
await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }))
|
||||||
|
await user.type(screen.getByPlaceholderText(/members\.transferModal\.codePlaceholder/i), '123456')
|
||||||
|
await user.click(screen.getByRole('button', { name: /members\.transferModal\.continue/i }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectNewOwnerAndSubmit = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||||
|
await user.click(screen.getByRole('button', { name: /select member/i }))
|
||||||
|
await user.click(screen.getByRole('button', { name: /members\.transferModal\.transfer$/i }))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should complete ownership transfer flow through all steps', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
mockEmailVerification()
|
||||||
|
vi.mocked(ownershipTransfer).mockResolvedValue({
|
||||||
|
result: 'success',
|
||||||
|
} as Awaited<ReturnType<typeof ownershipTransfer>>)
|
||||||
|
|
||||||
|
const mockReload = vi.fn()
|
||||||
|
vi.stubGlobal('location', { ...window.location, reload: mockReload })
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
await goToTransferStep(user)
|
||||||
|
|
||||||
|
expect(await screen.findByText(/members\.transferModal\.transferLabel/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
await selectNewOwnerAndSubmit(user)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' })
|
||||||
|
expect(mockReload).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
it('should show error when email verification returns invalid code', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
mockEmailVerification({ isValid: false, token: 'step-token' })
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
await goToTransferStep(user)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'error',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show error when sending verification email fails', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
vi.mocked(sendOwnerEmail).mockRejectedValue(new Error('network error'))
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'error',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show error when ownership transfer fails', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
mockEmailVerification()
|
||||||
|
vi.mocked(ownershipTransfer).mockRejectedValue(new Error('transfer failed'))
|
||||||
|
|
||||||
|
renderModal()
|
||||||
|
|
||||||
|
await goToTransferStep(user)
|
||||||
|
await selectNewOwnerAndSubmit(user)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'error',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { Member } from '@/models/common'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import { useMembers } from '@/service/use-common'
|
||||||
|
import MemberSelector from './member-selector'
|
||||||
|
|
||||||
|
vi.mock('@/service/use-common')
|
||||||
|
|
||||||
|
const MemberSelectorHarness = ({ initialValue = '', exclude = [] as string[] }: { initialValue?: string, exclude?: string[] }) => {
|
||||||
|
const [selected, setSelected] = useState<string>(initialValue)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MemberSelector value={selected} onSelect={setSelected} exclude={exclude} />
|
||||||
|
{selected && (
|
||||||
|
<div>
|
||||||
|
Selected:
|
||||||
|
{' '}
|
||||||
|
{selected}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MemberSelector', () => {
|
||||||
|
const mockMembers = [
|
||||||
|
{ id: '1', name: 'User 1', email: 'user1@example.com', role: 'admin' },
|
||||||
|
{ id: '2', name: 'User 2', email: 'user2@example.com', role: 'normal' },
|
||||||
|
] as Member[]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(useMembers).mockReturnValue({
|
||||||
|
data: { accounts: mockMembers },
|
||||||
|
} as unknown as ReturnType<typeof useMembers>)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show member options when selector is opened', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<MemberSelectorHarness />)
|
||||||
|
|
||||||
|
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText(/common\.operation\.search/i)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('User 1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('User 2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter displayed members by search term', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<MemberSelectorHarness />)
|
||||||
|
|
||||||
|
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
|
||||||
|
await user.type(screen.getByPlaceholderText(/common\.operation\.search/i), 'User 2')
|
||||||
|
|
||||||
|
expect(screen.queryByText('User 1')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('User 2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show selected member after clicking an option', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<MemberSelectorHarness />)
|
||||||
|
|
||||||
|
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
|
||||||
|
await user.click(screen.getByText('User 1'))
|
||||||
|
|
||||||
|
expect(screen.getByText('Selected: 1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show selected value details when an initial value is provided', () => {
|
||||||
|
render(<MemberSelectorHarness initialValue="2" />)
|
||||||
|
|
||||||
|
expect(screen.getByText('User 2')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('user2@example.com')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide excluded members from options', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<MemberSelectorHarness exclude={['1']} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
|
||||||
|
|
||||||
|
expect(screen.queryByText('User 1')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('User 2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render empty options when member data is unavailable', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
vi.mocked(useMembers).mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
} as unknown as ReturnType<typeof useMembers>)
|
||||||
|
|
||||||
|
render(<MemberSelectorHarness />)
|
||||||
|
|
||||||
|
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
|
||||||
|
|
||||||
|
expect(screen.queryByText('User 1')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('User 2')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import ModelBadge from './index'
|
||||||
|
|
||||||
|
describe('ModelBadge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rendering behavior for user-visible content.
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render provided text', () => {
|
||||||
|
render(<ModelBadge>Provider</ModelBadge>)
|
||||||
|
|
||||||
|
expect(screen.getByText(/provider/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render without text when children is null', () => {
|
||||||
|
const { container } = render(<ModelBadge>{null}</ModelBadge>)
|
||||||
|
|
||||||
|
expect(container.textContent).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render nested content', () => {
|
||||||
|
render(
|
||||||
|
<ModelBadge>
|
||||||
|
<span>Badge Label</span>
|
||||||
|
</ModelBadge>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText(/badge label/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { Model } from '../declarations'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { Theme } from '@/types/app'
|
||||||
|
import {
|
||||||
|
ConfigurationMethodEnum,
|
||||||
|
ModelStatusEnum,
|
||||||
|
ModelTypeEnum,
|
||||||
|
} from '../declarations'
|
||||||
|
import ModelIcon from './index'
|
||||||
|
|
||||||
|
type I18nText = {
|
||||||
|
en_US: string
|
||||||
|
zh_Hans: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let mockTheme: Theme = Theme.light
|
||||||
|
let mockLanguage = 'en_US'
|
||||||
|
|
||||||
|
vi.mock('@/hooks/use-theme', () => ({
|
||||||
|
default: () => ({ theme: mockTheme }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../hooks', () => ({
|
||||||
|
useLanguage: () => mockLanguage,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/icons/src/public/llm', () => ({
|
||||||
|
OpenaiYellow: () => <svg data-testid="openai-yellow-icon" />,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const createI18nText = (value: string): I18nText => ({
|
||||||
|
en_US: value,
|
||||||
|
zh_Hans: value,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createModel = (overrides?: Partial<Model>): Model => ({
|
||||||
|
provider: 'test-provider',
|
||||||
|
icon_small: createI18nText('light.png'),
|
||||||
|
icon_small_dark: createI18nText('dark.png'),
|
||||||
|
label: createI18nText('Test Provider'),
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
model: 'test-model',
|
||||||
|
label: createI18nText('Test Model'),
|
||||||
|
model_type: ModelTypeEnum.textGeneration,
|
||||||
|
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||||
|
status: ModelStatusEnum.active,
|
||||||
|
model_properties: {},
|
||||||
|
load_balancing_enabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: ModelStatusEnum.active,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ModelIcon', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockTheme = Theme.light
|
||||||
|
mockLanguage = 'en_US'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rendering
|
||||||
|
it('should render the light icon when icon_small is provided', () => {
|
||||||
|
const provider = createModel({
|
||||||
|
icon_small: createI18nText('light-only.png'),
|
||||||
|
icon_small_dark: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelIcon provider={provider} />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('img', { name: /model-icon/i })).toHaveAttribute('src', 'light-only.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Theme selection
|
||||||
|
it('should render the dark icon when theme is dark and icon_small_dark exists', () => {
|
||||||
|
mockTheme = Theme.dark
|
||||||
|
const provider = createModel({
|
||||||
|
icon_small: createI18nText('light.png'),
|
||||||
|
icon_small_dark: createI18nText('dark.png'),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelIcon provider={provider} />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('img', { name: /model-icon/i })).toHaveAttribute('src', 'dark.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Provider override
|
||||||
|
it('should ignore icon_small for OpenAI models starting with "o"', () => {
|
||||||
|
const provider = createModel({
|
||||||
|
provider: 'openai',
|
||||||
|
icon_small: createI18nText('openai.png'),
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelIcon provider={provider} modelName="o1" />)
|
||||||
|
|
||||||
|
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('openai-yellow-icon')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Edge case
|
||||||
|
it('should render without an icon when provider is undefined', () => {
|
||||||
|
const { container } = render(<ModelIcon />)
|
||||||
|
|
||||||
|
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
|
||||||
|
expect(container.firstChild).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
import type {
|
||||||
|
CredentialFormSchema,
|
||||||
|
CredentialFormSchemaBase,
|
||||||
|
CredentialFormSchemaNumberInput,
|
||||||
|
CredentialFormSchemaRadio,
|
||||||
|
CredentialFormSchemaSelect,
|
||||||
|
CredentialFormSchemaTextInput,
|
||||||
|
FormValue,
|
||||||
|
} from '../declarations'
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { FormTypeEnum } from '../declarations'
|
||||||
|
import Form from './Form'
|
||||||
|
|
||||||
|
type CustomSchema = Omit<CredentialFormSchemaBase, 'type'> & { type: 'custom-type' }
|
||||||
|
|
||||||
|
type MockVarPayload = { type: string }
|
||||||
|
|
||||||
|
type AnyFormSchema = CredentialFormSchema | (CredentialFormSchemaBase & { type: FormTypeEnum })
|
||||||
|
|
||||||
|
vi.mock('../hooks', () => ({
|
||||||
|
useLanguage: () => 'en_US',
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({
|
||||||
|
default: ({ onSelect }: { onSelect: (item: { id: string }) => void }) => (
|
||||||
|
<button type="button" onClick={() => onSelect({ id: 'app-1' })}>Select App</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({
|
||||||
|
default: ({ setModel }: { setModel: (model: { model: string, model_type: string }) => void }) => (
|
||||||
|
<button type="button" onClick={() => setModel({ model: 'gpt-1', model_type: 'llm' })}>Select Model</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/plugins/plugin-detail-panel/multiple-tool-selector', () => ({
|
||||||
|
default: ({ onChange }: { onChange: (items: Array<{ id: string }>) => void }) => (
|
||||||
|
<button type="button" onClick={() => onChange([{ id: 'tool-1' }])}>Select Tools</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({
|
||||||
|
default: ({ onSelect, onDelete }: { onSelect: (item: { id: string }) => void, onDelete: () => void }) => (
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={() => onSelect({ id: 'tool-1' })}>Select Tool</button>
|
||||||
|
<button type="button" onClick={onDelete}>Remove Tool</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||||
|
default: ({ filterVar, onChange }: { filterVar?: (payload: MockVarPayload) => boolean, onChange: (items: Array<{ name: string }>) => void }) => {
|
||||||
|
const allowed = filterVar ? filterVar({ type: 'text' }) : true
|
||||||
|
const blocked = filterVar ? filterVar({ type: 'image' }) : false
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>{allowed ? 'allowed' : 'blocked'}</div>
|
||||||
|
<div>{blocked ? 'allowed' : 'blocked'}</div>
|
||||||
|
<button type="button" onClick={() => onChange([{ name: 'var-1' }])}>Pick Variable</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../key-validator/ValidateStatus', () => ({
|
||||||
|
ValidatingTip: () => <div>Validating...</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
|
||||||
|
|
||||||
|
const createBaseSchema = (
|
||||||
|
type: FormTypeEnum,
|
||||||
|
overrides: Partial<CredentialFormSchemaBase> = {},
|
||||||
|
): CredentialFormSchemaBase => ({
|
||||||
|
name: overrides.variable ?? 'field',
|
||||||
|
variable: overrides.variable ?? 'field',
|
||||||
|
label: createI18n('Field'),
|
||||||
|
type,
|
||||||
|
required: false,
|
||||||
|
show_on: [],
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createTextSchema = (overrides: Partial<CredentialFormSchemaTextInput> & { type?: FormTypeEnum }) => ({
|
||||||
|
...createBaseSchema(overrides.type ?? FormTypeEnum.textInput, { variable: overrides.variable ?? 'text' }),
|
||||||
|
placeholder: createI18n('Input'),
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createNumberSchema = (overrides: Partial<CredentialFormSchemaNumberInput>) => ({
|
||||||
|
...createBaseSchema(FormTypeEnum.textNumber, { variable: overrides.variable ?? 'number' }),
|
||||||
|
placeholder: createI18n('Number'),
|
||||||
|
min: 1,
|
||||||
|
max: 9,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createRadioSchema = (overrides: Partial<CredentialFormSchemaRadio>) => ({
|
||||||
|
...createBaseSchema(FormTypeEnum.radio, { variable: overrides.variable ?? 'radio' }),
|
||||||
|
options: [
|
||||||
|
{ label: createI18n('Option A'), value: 'a', show_on: [] },
|
||||||
|
{ label: createI18n('Option B'), value: 'b', show_on: [] },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createSelectSchema = (overrides: Partial<CredentialFormSchemaSelect>) => ({
|
||||||
|
...createBaseSchema(FormTypeEnum.select, { variable: overrides.variable ?? 'select' }),
|
||||||
|
placeholder: createI18n('Select one'),
|
||||||
|
options: [
|
||||||
|
{ label: createI18n('Select A'), value: 'a', show_on: [] },
|
||||||
|
{ label: createI18n('Select B'), value: 'b', show_on: [] },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Form', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rendering basics
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render visible fields and apply default values', () => {
|
||||||
|
const formSchemas: AnyFormSchema[] = [
|
||||||
|
createTextSchema({
|
||||||
|
variable: 'api_key',
|
||||||
|
label: createI18n('API Key'),
|
||||||
|
placeholder: createI18n('API Key'),
|
||||||
|
required: true,
|
||||||
|
default: 'default-key',
|
||||||
|
}),
|
||||||
|
createTextSchema({
|
||||||
|
variable: 'secret',
|
||||||
|
type: FormTypeEnum.secretInput,
|
||||||
|
label: createI18n('Secret'),
|
||||||
|
placeholder: createI18n('Secret'),
|
||||||
|
}),
|
||||||
|
createNumberSchema({
|
||||||
|
variable: 'limit',
|
||||||
|
label: createI18n('Limit'),
|
||||||
|
placeholder: createI18n('Limit'),
|
||||||
|
default: '5',
|
||||||
|
}),
|
||||||
|
createTextSchema({
|
||||||
|
variable: 'hidden',
|
||||||
|
label: createI18n('Hidden'),
|
||||||
|
show_on: [{ variable: 'toggle', value: 'on' }],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const value: FormValue = {
|
||||||
|
api_key: '',
|
||||||
|
secret: 'top-secret',
|
||||||
|
limit: '',
|
||||||
|
toggle: 'off',
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Form
|
||||||
|
value={value}
|
||||||
|
onChange={vi.fn()}
|
||||||
|
formSchemas={formSchemas}
|
||||||
|
validating={false}
|
||||||
|
validatedSuccess={false}
|
||||||
|
showOnVariableMap={{}}
|
||||||
|
isEditMode={false}
|
||||||
|
isShowDefaultValue
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('API Key')).toHaveValue('default-key')
|
||||||
|
expect(screen.getByPlaceholderText('Secret')).toHaveValue('top-secret')
|
||||||
|
expect(screen.getByPlaceholderText('Limit')).toHaveValue(5)
|
||||||
|
expect(screen.queryByText('Hidden')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('*')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Interaction updates
|
||||||
|
describe('Interactions', () => {
|
||||||
|
it('should update values and clear dependent fields when a field changes', () => {
|
||||||
|
const formSchemas: AnyFormSchema[] = [
|
||||||
|
createTextSchema({
|
||||||
|
variable: 'api_key',
|
||||||
|
label: createI18n('API Key'),
|
||||||
|
placeholder: createI18n('API Key'),
|
||||||
|
}),
|
||||||
|
createTextSchema({
|
||||||
|
variable: 'dependent',
|
||||||
|
label: createI18n('Dependent'),
|
||||||
|
default: 'reset',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const value: FormValue = { api_key: 'old', dependent: 'keep' }
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Form
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
formSchemas={formSchemas}
|
||||||
|
validating
|
||||||
|
validatedSuccess={false}
|
||||||
|
showOnVariableMap={{ api_key: ['dependent'] }}
|
||||||
|
isEditMode={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('API Key'), { target: { value: 'new-key' } })
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ api_key: 'new-key', dependent: 'reset' })
|
||||||
|
expect(screen.getByText('Validating...')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render radio options based on show conditions and ignore edit-locked changes', () => {
|
||||||
|
const formSchemas: AnyFormSchema[] = [
|
||||||
|
createRadioSchema({
|
||||||
|
variable: 'region',
|
||||||
|
label: createI18n('Region'),
|
||||||
|
options: [
|
||||||
|
{ label: createI18n('US'), value: 'us', show_on: [] },
|
||||||
|
{ label: createI18n('EU'), value: 'eu', show_on: [{ variable: 'toggle', value: 'on' }] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createRadioSchema({
|
||||||
|
variable: 'hidden_region',
|
||||||
|
label: createI18n('Hidden Region'),
|
||||||
|
show_on: [{ variable: 'toggle', value: 'hidden' }],
|
||||||
|
options: [
|
||||||
|
{ label: createI18n('Hidden A'), value: 'a', show_on: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createRadioSchema({
|
||||||
|
variable: '__model_name',
|
||||||
|
label: createI18n('Locked'),
|
||||||
|
options: [
|
||||||
|
{ label: createI18n('Locked A'), value: 'a', show_on: [] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const value: FormValue = { region: 'us', toggle: 'on', __model_name: 'a' }
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Form
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
formSchemas={formSchemas}
|
||||||
|
validating={false}
|
||||||
|
validatedSuccess={false}
|
||||||
|
showOnVariableMap={{}}
|
||||||
|
isEditMode
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('EU')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Hidden Region')).not.toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByText('EU'))
|
||||||
|
fireEvent.click(screen.getByText('Locked A'))
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ region: 'eu', toggle: 'on', __model_name: 'a' })
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render select and checkbox fields and update checkbox value', () => {
|
||||||
|
const formSchemas: AnyFormSchema[] = [
|
||||||
|
createSelectSchema({
|
||||||
|
variable: 'model',
|
||||||
|
label: createI18n('Model'),
|
||||||
|
placeholder: createI18n('Pick model'),
|
||||||
|
show_on: [{ variable: 'toggle', value: 'on' }],
|
||||||
|
options: [
|
||||||
|
{ label: createI18n('Select A'), value: 'a', show_on: [] },
|
||||||
|
{ label: createI18n('Select B'), value: 'b', show_on: [{ variable: 'toggle', value: 'on' }] },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createRadioSchema({
|
||||||
|
variable: 'agree',
|
||||||
|
type: FormTypeEnum.checkbox,
|
||||||
|
label: createI18n('Agree'),
|
||||||
|
options: [],
|
||||||
|
show_on: [{ variable: 'toggle', value: 'on' }],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const value: FormValue = { model: 'a', agree: false, toggle: 'off' }
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<Form
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
formSchemas={formSchemas}
|
||||||
|
validating={false}
|
||||||
|
validatedSuccess={false}
|
||||||
|
showOnVariableMap={{}}
|
||||||
|
isEditMode={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.queryByText('Pick model')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Agree')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Form
|
||||||
|
value={{ model: 'a', agree: false, toggle: 'on' }}
|
||||||
|
onChange={onChange}
|
||||||
|
formSchemas={formSchemas}
|
||||||
|
validating={false}
|
||||||
|
validatedSuccess={false}
|
||||||
|
showOnVariableMap={{}}
|
||||||
|
isEditMode={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Select A')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByText('Select A'))
|
||||||
|
fireEvent.click(screen.getByText('Select B'))
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('True'))
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ model: 'b', agree: false, toggle: 'on' })
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ model: 'a', agree: true, toggle: 'on' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass selected items from model and tool selectors to the form value', () => {
|
||||||
|
const formSchemas: AnyFormSchema[] = [
|
||||||
|
createTextSchema({
|
||||||
|
variable: 'model_selector',
|
||||||
|
type: FormTypeEnum.modelSelector,
|
||||||
|
label: createI18n('Model Selector'),
|
||||||
|
}),
|
||||||
|
createTextSchema({
|
||||||
|
variable: 'tool_selector',
|
||||||
|
type: FormTypeEnum.toolSelector,
|
||||||
|
label: createI18n('Tool Selector'),
|
||||||
|
}),
|
||||||
|
createTextSchema({
|
||||||
|
variable: 'multi_tool',
|
||||||
|
type: FormTypeEnum.multiToolSelector,
|
||||||
|
label: createI18n('Multi Tool'),
|
||||||
|
tooltip: createI18n('Tips'),
|
||||||
|
}),
|
||||||
|
createTextSchema({
|
||||||
|
variable: 'app_selector',
|
||||||
|
type: FormTypeEnum.appSelector,
|
||||||
|
label: createI18n('App Selector'),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const value: FormValue = { model_selector: {}, tool_selector: null, multi_tool: [], app_selector: null }
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Form
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
formSchemas={formSchemas}
|
||||||
|
validating={false}
|
||||||
|
validatedSuccess={false}
|
||||||
|
showOnVariableMap={{}}
|
||||||
|
isEditMode={false}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Select Model'))
|
||||||
|
fireEvent.click(screen.getByText('Select Tool'))
|
||||||
|
fireEvent.click(screen.getByText('Remove Tool'))
|
||||||
|
fireEvent.click(screen.getByText('Select Tools'))
|
||||||
|
fireEvent.click(screen.getByText('Select App'))
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
model_selector: { model: 'gpt-1', model_type: 'llm', type: FormTypeEnum.modelSelector },
|
||||||
|
}))
|
||||||
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
tool_selector: { id: 'tool-1' },
|
||||||
|
}))
|
||||||
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
tool_selector: null,
|
||||||
|
}))
|
||||||
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
multi_tool: [{ id: 'tool-1' }],
|
||||||
|
}))
|
||||||
|
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
app_selector: { id: 'app-1', type: FormTypeEnum.appSelector },
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render variable picker and custom render overrides', () => {
|
||||||
|
const formSchemas: Array<AnyFormSchema | CustomSchema> = [
|
||||||
|
createTextSchema({
|
||||||
|
variable: 'override',
|
||||||
|
label: createI18n('Override'),
|
||||||
|
type: FormTypeEnum.textInput,
|
||||||
|
}),
|
||||||
|
createTextSchema({
|
||||||
|
variable: 'any_var',
|
||||||
|
type: FormTypeEnum.any,
|
||||||
|
label: createI18n('Any Var'),
|
||||||
|
scope: 'text&audio',
|
||||||
|
}),
|
||||||
|
createTextSchema({
|
||||||
|
variable: 'any_without_scope',
|
||||||
|
type: FormTypeEnum.any,
|
||||||
|
label: createI18n('Any Without Scope'),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
...createTextSchema({
|
||||||
|
variable: 'custom_field',
|
||||||
|
label: createI18n('Custom Field'),
|
||||||
|
}),
|
||||||
|
type: 'custom-type',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const value: FormValue = { override: '', any_var: [], any_without_scope: [], custom_field: '' }
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Form<CustomSchema>
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
formSchemas={formSchemas}
|
||||||
|
validating={false}
|
||||||
|
validatedSuccess={false}
|
||||||
|
showOnVariableMap={{}}
|
||||||
|
isEditMode={false}
|
||||||
|
fieldMoreInfo={() => <div>Extra Info</div>}
|
||||||
|
override={[[FormTypeEnum.textInput], () => <div>Override Field</div>]}
|
||||||
|
customRenderField={schema => (
|
||||||
|
<div>
|
||||||
|
Custom Render:
|
||||||
|
{schema.variable}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Override Field')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Custom Render:.*custom_field/)).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('allowed')).toHaveLength(3)
|
||||||
|
expect(screen.getAllByText('blocked')).toHaveLength(1)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByText('Pick Variable')[0])
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ override: '', any_var: [{ name: 'var-1' }], any_without_scope: [], custom_field: '' })
|
||||||
|
expect(screen.getAllByText('Extra Info')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import Input from './Input'
|
||||||
|
|
||||||
|
describe('Input', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rendering basics
|
||||||
|
it('should render with the provided placeholder and value', () => {
|
||||||
|
render(
|
||||||
|
<Input
|
||||||
|
value="hello"
|
||||||
|
placeholder="API Key"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('API Key')).toHaveValue('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
// User interaction
|
||||||
|
it('should call onChange when the user types', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Input
|
||||||
|
placeholder="API Key"
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('API Key'), { target: { value: 'next' } })
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith('next')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Edge cases: min/max enforcement
|
||||||
|
it('should clamp to the min value when the input is below min on blur', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Input
|
||||||
|
placeholder="Limit"
|
||||||
|
onChange={onChange}
|
||||||
|
min={2}
|
||||||
|
max={6}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Limit')
|
||||||
|
fireEvent.change(input, { target: { value: '1' } })
|
||||||
|
fireEvent.blur(input)
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clamp to the max value when the input is above max on blur', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Input
|
||||||
|
placeholder="Limit"
|
||||||
|
onChange={onChange}
|
||||||
|
min={2}
|
||||||
|
max={6}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Limit')
|
||||||
|
fireEvent.change(input, { target: { value: '8' } })
|
||||||
|
fireEvent.blur(input)
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith('6')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep the value when it is within the min/max range on blur', () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Input
|
||||||
|
placeholder="Limit"
|
||||||
|
onChange={onChange}
|
||||||
|
min={2}
|
||||||
|
max={6}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Limit')
|
||||||
|
fireEvent.change(input, { target: { value: '4' } })
|
||||||
|
fireEvent.blur(input)
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalledWith('2')
|
||||||
|
expect(onChange).not.toHaveBeenCalledWith('6')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import type { Credential, CredentialFormSchema, ModelProvider } from '../declarations'
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import {
|
||||||
|
ConfigurationMethodEnum,
|
||||||
|
CurrentSystemQuotaTypeEnum,
|
||||||
|
CustomConfigurationStatusEnum,
|
||||||
|
ModelModalModeEnum,
|
||||||
|
ModelTypeEnum,
|
||||||
|
PreferredProviderTypeEnum,
|
||||||
|
QuotaUnitEnum,
|
||||||
|
} from '../declarations'
|
||||||
|
import ModelModal from './index'
|
||||||
|
|
||||||
|
type CredentialData = {
|
||||||
|
credentials: Record<string, unknown>
|
||||||
|
available_credentials: Credential[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelFormSchemas = {
|
||||||
|
formSchemas: CredentialFormSchema[]
|
||||||
|
formValues: Record<string, unknown>
|
||||||
|
modelNameAndTypeFormSchemas: CredentialFormSchema[]
|
||||||
|
modelNameAndTypeFormValues: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockState = vi.hoisted(() => ({
|
||||||
|
isLoading: false,
|
||||||
|
credentialData: { credentials: {}, available_credentials: [] } as CredentialData,
|
||||||
|
doingAction: false,
|
||||||
|
deleteCredentialId: null as string | null,
|
||||||
|
isCurrentWorkspaceManager: true,
|
||||||
|
formSchemas: [] as CredentialFormSchema[],
|
||||||
|
formValues: {} as Record<string, unknown>,
|
||||||
|
modelNameAndTypeFormSchemas: [] as CredentialFormSchema[],
|
||||||
|
modelNameAndTypeFormValues: {} as Record<string, unknown>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockHandlers = vi.hoisted(() => ({
|
||||||
|
handleSaveCredential: vi.fn(),
|
||||||
|
handleConfirmDelete: vi.fn(),
|
||||||
|
closeConfirmDelete: vi.fn(),
|
||||||
|
openConfirmDelete: vi.fn(),
|
||||||
|
handleActiveCredential: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
type FormResponse = {
|
||||||
|
isCheckValidated: boolean
|
||||||
|
values: Record<string, unknown>
|
||||||
|
}
|
||||||
|
const mockFormState = vi.hoisted(() => ({
|
||||||
|
responses: [] as FormResponse[],
|
||||||
|
setFieldValue: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../model-auth/hooks', () => ({
|
||||||
|
useCredentialData: () => ({
|
||||||
|
isLoading: mockState.isLoading,
|
||||||
|
credentialData: mockState.credentialData,
|
||||||
|
}),
|
||||||
|
useAuth: () => ({
|
||||||
|
handleSaveCredential: mockHandlers.handleSaveCredential,
|
||||||
|
handleConfirmDelete: mockHandlers.handleConfirmDelete,
|
||||||
|
deleteCredentialId: mockState.deleteCredentialId,
|
||||||
|
closeConfirmDelete: mockHandlers.closeConfirmDelete,
|
||||||
|
openConfirmDelete: mockHandlers.openConfirmDelete,
|
||||||
|
doingAction: mockState.doingAction,
|
||||||
|
handleActiveCredential: mockHandlers.handleActiveCredential,
|
||||||
|
}),
|
||||||
|
useModelFormSchemas: (): ModelFormSchemas => ({
|
||||||
|
formSchemas: mockState.formSchemas,
|
||||||
|
formValues: mockState.formValues,
|
||||||
|
modelNameAndTypeFormSchemas: mockState.modelNameAndTypeFormSchemas,
|
||||||
|
modelNameAndTypeFormValues: mockState.modelNameAndTypeFormValues,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/context/app-context', () => ({
|
||||||
|
useAppContext: () => ({ isCurrentWorkspaceManager: mockState.isCurrentWorkspaceManager }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/hooks/use-i18n', () => ({
|
||||||
|
useRenderI18nObject: () => (value: { en_US: string }) => value.en_US,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../hooks', () => ({
|
||||||
|
useLanguage: () => 'en_US',
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/form/form-scenarios/auth', async () => {
|
||||||
|
const React = await import('react')
|
||||||
|
const AuthForm = React.forwardRef(({
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
onChange?: (field: string, value: string) => void
|
||||||
|
}, ref: React.ForwardedRef<{ getFormValues: () => FormResponse, getForm: () => { setFieldValue: (field: string, value: string) => void } }>) => {
|
||||||
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
getFormValues: () => mockFormState.responses.shift() || { isCheckValidated: false, values: {} },
|
||||||
|
getForm: () => ({ setFieldValue: mockFormState.setFieldValue }),
|
||||||
|
}))
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={() => onChange?.('__model_name', 'updated-model')}>Model Name Change</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { default: AuthForm }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('../model-auth', () => ({
|
||||||
|
CredentialSelector: ({ onSelect }: { onSelect: (credential: Credential & { addNewCredential?: boolean }) => void }) => (
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={() => onSelect({ credential_id: 'existing' })}>Choose Existing</button>
|
||||||
|
<button type="button" onClick={() => onSelect({ credential_id: 'new', addNewCredential: true })}>Add New</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
|
||||||
|
|
||||||
|
const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
|
||||||
|
provider: 'openai',
|
||||||
|
label: createI18n('OpenAI'),
|
||||||
|
help: {
|
||||||
|
title: createI18n('Help'),
|
||||||
|
url: createI18n('https://example.com'),
|
||||||
|
},
|
||||||
|
icon_small: createI18n('icon'),
|
||||||
|
supported_model_types: [ModelTypeEnum.textGeneration],
|
||||||
|
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
|
||||||
|
provider_credential_schema: { credential_form_schemas: [] },
|
||||||
|
model_credential_schema: {
|
||||||
|
model: { label: createI18n('Model'), placeholder: createI18n('Model') },
|
||||||
|
credential_form_schemas: [],
|
||||||
|
},
|
||||||
|
preferred_provider_type: PreferredProviderTypeEnum.system,
|
||||||
|
custom_configuration: {
|
||||||
|
status: CustomConfigurationStatusEnum.active,
|
||||||
|
available_credentials: [],
|
||||||
|
custom_models: [],
|
||||||
|
can_added_models: [],
|
||||||
|
},
|
||||||
|
system_configuration: {
|
||||||
|
enabled: true,
|
||||||
|
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
|
||||||
|
quota_configurations: [
|
||||||
|
{
|
||||||
|
quota_type: CurrentSystemQuotaTypeEnum.trial,
|
||||||
|
quota_unit: QuotaUnitEnum.times,
|
||||||
|
quota_limit: 0,
|
||||||
|
quota_used: 0,
|
||||||
|
last_used: 0,
|
||||||
|
is_valid: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
allow_custom_token: true,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderModal = (overrides?: Partial<React.ComponentProps<typeof ModelModal>>) => {
|
||||||
|
const provider = createProvider()
|
||||||
|
const props = {
|
||||||
|
provider,
|
||||||
|
configurateMethod: ConfigurationMethodEnum.predefinedModel,
|
||||||
|
onCancel: vi.fn(),
|
||||||
|
onSave: vi.fn(),
|
||||||
|
onRemove: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
const view = render(<ModelModal {...props} />)
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
unmount: view.unmount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ModelModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockState.isLoading = false
|
||||||
|
mockState.credentialData = { credentials: {}, available_credentials: [] }
|
||||||
|
mockState.doingAction = false
|
||||||
|
mockState.deleteCredentialId = null
|
||||||
|
mockState.isCurrentWorkspaceManager = true
|
||||||
|
mockState.formSchemas = []
|
||||||
|
mockState.formValues = {}
|
||||||
|
mockState.modelNameAndTypeFormSchemas = []
|
||||||
|
mockState.modelNameAndTypeFormValues = {}
|
||||||
|
mockFormState.responses = []
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show title, description, and loading state for predefined models', () => {
|
||||||
|
mockState.isLoading = true
|
||||||
|
|
||||||
|
const predefined = renderModal()
|
||||||
|
|
||||||
|
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.title')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.desc')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
|
||||||
|
|
||||||
|
predefined.unmount()
|
||||||
|
const customizable = renderModal({ configurateMethod: ConfigurationMethodEnum.customizableModel })
|
||||||
|
expect(screen.queryByText('common.modelProvider.auth.apiKeyModal.desc')).not.toBeInTheDocument()
|
||||||
|
customizable.unmount()
|
||||||
|
|
||||||
|
mockState.credentialData = { credentials: {}, available_credentials: [] }
|
||||||
|
renderModal({ mode: ModelModalModeEnum.configModelCredential, model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } })
|
||||||
|
expect(screen.getByText('common.modelProvider.auth.addModelCredential')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reveal the credential label when adding a new credential', () => {
|
||||||
|
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList })
|
||||||
|
|
||||||
|
expect(screen.queryByText('common.modelProvider.auth.modelCredential')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Add New'))
|
||||||
|
|
||||||
|
expect(screen.getByText('common.modelProvider.auth.modelCredential')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onCancel when the cancel button is clicked', () => {
|
||||||
|
const { onCancel } = renderModal()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||||
|
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call onCancel when the escape key is pressed', () => {
|
||||||
|
const { onCancel } = renderModal()
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' })
|
||||||
|
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should confirm deletion when a delete dialog is shown', () => {
|
||||||
|
mockState.credentialData = { credentials: { api_key: 'secret' }, available_credentials: [] }
|
||||||
|
mockState.deleteCredentialId = 'delete-id'
|
||||||
|
|
||||||
|
const credential: Credential = { credential_id: 'cred-1' }
|
||||||
|
const { onCancel } = renderModal({ credential })
|
||||||
|
|
||||||
|
expect(screen.getByText('common.modelProvider.confirmDelete')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||||
|
|
||||||
|
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle save flows for different modal modes', async () => {
|
||||||
|
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text-input' } as unknown as CredentialFormSchema]
|
||||||
|
mockState.formSchemas = [{ variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema]
|
||||||
|
mockFormState.responses = [
|
||||||
|
{ isCheckValidated: true, values: { __model_name: 'custom-model', __model_type: ModelTypeEnum.textGeneration } },
|
||||||
|
{ isCheckValidated: true, values: { __authorization_name__: 'Auth Name', api_key: 'secret' } },
|
||||||
|
]
|
||||||
|
const configCustomModel = renderModal({ mode: ModelModalModeEnum.configCustomModel })
|
||||||
|
fireEvent.click(screen.getAllByText('Model Name Change')[0])
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||||
|
|
||||||
|
expect(mockFormState.setFieldValue).toHaveBeenCalledWith('__model_name', 'updated-model')
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
|
||||||
|
credential_id: undefined,
|
||||||
|
credentials: { api_key: 'secret' },
|
||||||
|
name: 'Auth Name',
|
||||||
|
model: 'custom-model',
|
||||||
|
model_type: ModelTypeEnum.textGeneration,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(configCustomModel.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Auth Name', api_key: 'secret' })
|
||||||
|
configCustomModel.unmount()
|
||||||
|
|
||||||
|
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Model Auth', api_key: 'abc' } }]
|
||||||
|
const model = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration }
|
||||||
|
const configModelCredential = renderModal({
|
||||||
|
mode: ModelModalModeEnum.configModelCredential,
|
||||||
|
model,
|
||||||
|
credential: { credential_id: 'cred-123' },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
|
||||||
|
credential_id: 'cred-123',
|
||||||
|
credentials: { api_key: 'abc' },
|
||||||
|
name: 'Model Auth',
|
||||||
|
model: 'gpt-4',
|
||||||
|
model_type: ModelTypeEnum.textGeneration,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(configModelCredential.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Model Auth', api_key: 'abc' })
|
||||||
|
configModelCredential.unmount()
|
||||||
|
|
||||||
|
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Provider Auth', api_key: 'provider-key' } }]
|
||||||
|
const configProviderCredential = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
|
||||||
|
credential_id: undefined,
|
||||||
|
credentials: { api_key: 'provider-key' },
|
||||||
|
name: 'Provider Auth',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
configProviderCredential.unmount()
|
||||||
|
|
||||||
|
const addToModelList = renderModal({
|
||||||
|
mode: ModelModalModeEnum.addCustomModelToModelList,
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByText('Choose Existing'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||||
|
expect(mockHandlers.handleActiveCredential).toHaveBeenCalledWith({ credential_id: 'existing' }, model)
|
||||||
|
expect(addToModelList.onCancel).toHaveBeenCalled()
|
||||||
|
addToModelList.unmount()
|
||||||
|
|
||||||
|
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'New Auth', api_key: 'new-key' } }]
|
||||||
|
const addToModelListWithNew = renderModal({
|
||||||
|
mode: ModelModalModeEnum.addCustomModelToModelList,
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByText('Add New'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
|
||||||
|
credential_id: undefined,
|
||||||
|
credentials: { api_key: 'new-key' },
|
||||||
|
name: 'New Auth',
|
||||||
|
model: 'gpt-4',
|
||||||
|
model_type: ModelTypeEnum.textGeneration,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
addToModelListWithNew.unmount()
|
||||||
|
|
||||||
|
mockFormState.responses = [{ isCheckValidated: false, values: {} }]
|
||||||
|
const invalidSave = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledTimes(4)
|
||||||
|
})
|
||||||
|
invalidSave.unmount()
|
||||||
|
|
||||||
|
mockState.credentialData = { credentials: { api_key: 'value' }, available_credentials: [] }
|
||||||
|
mockState.formValues = { api_key: 'value' }
|
||||||
|
const removable = renderModal({ credential: { credential_id: 'remove-1' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
|
||||||
|
expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith({ credential_id: 'remove-1' }, undefined)
|
||||||
|
removable.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { ModelItem } from '../declarations'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import {
|
||||||
|
ConfigurationMethodEnum,
|
||||||
|
ModelFeatureEnum,
|
||||||
|
ModelStatusEnum,
|
||||||
|
ModelTypeEnum,
|
||||||
|
} from '../declarations'
|
||||||
|
import ModelName from './index'
|
||||||
|
|
||||||
|
let mockLocale = 'en-US'
|
||||||
|
|
||||||
|
vi.mock('#i18n', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
i18n: {
|
||||||
|
language: mockLocale,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||||
|
model: 'gpt-4o',
|
||||||
|
label: {
|
||||||
|
en_US: 'English Model',
|
||||||
|
zh_Hans: 'Chinese Model',
|
||||||
|
},
|
||||||
|
model_type: ModelTypeEnum.textGeneration,
|
||||||
|
features: [],
|
||||||
|
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||||
|
status: ModelStatusEnum.active,
|
||||||
|
model_properties: {},
|
||||||
|
load_balancing_enabled: false,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ModelName', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockLocale = 'en-US'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Rendering scenarios for the model name label.
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render the localized model label when translation exists', () => {
|
||||||
|
mockLocale = 'zh-Hans'
|
||||||
|
const modelItem = createModelItem()
|
||||||
|
|
||||||
|
render(<ModelName modelItem={modelItem} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Chinese Model')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fall back to en_US label when localized label is missing', () => {
|
||||||
|
mockLocale = 'fr-FR'
|
||||||
|
const modelItem = createModelItem({
|
||||||
|
label: {
|
||||||
|
en_US: 'English Only',
|
||||||
|
zh_Hans: 'Chinese Model',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<ModelName modelItem={modelItem} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('English Only')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render nothing when modelItem is null', () => {
|
||||||
|
const { container } = render(<ModelName modelItem={null as unknown as ModelItem} />)
|
||||||
|
|
||||||
|
expect(container).toBeEmptyDOMElement()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Badges that surface model metadata to the user.
|
||||||
|
describe('badges', () => {
|
||||||
|
it('should show model type, mode, and context size when enabled', () => {
|
||||||
|
const modelItem = createModelItem({
|
||||||
|
model_type: ModelTypeEnum.textEmbedding,
|
||||||
|
model_properties: {
|
||||||
|
mode: 'chat',
|
||||||
|
context_size: 2000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ModelName
|
||||||
|
modelItem={modelItem}
|
||||||
|
showModelType
|
||||||
|
showMode
|
||||||
|
showContextSize
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('TEXT EMBEDDING')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('CHAT')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('2K')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render feature labels when showFeaturesLabel is enabled', () => {
|
||||||
|
const modelItem = createModelItem({
|
||||||
|
features: [ModelFeatureEnum.vision, ModelFeatureEnum.audio],
|
||||||
|
})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ModelName
|
||||||
|
modelItem={modelItem}
|
||||||
|
showFeatures
|
||||||
|
showFeaturesLabel
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Vision')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Audio')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import type { MouseEvent } from 'react'
|
||||||
|
import type { ModelProvider } from '../declarations'
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
CurrentSystemQuotaTypeEnum,
|
||||||
|
CustomConfigurationStatusEnum,
|
||||||
|
ModelTypeEnum,
|
||||||
|
QuotaUnitEnum,
|
||||||
|
} from '../declarations'
|
||||||
|
import AgentModelTrigger from './agent-model-trigger'
|
||||||
|
|
||||||
|
let modelProviders: ModelProvider[] = []
|
||||||
|
let pluginInfo: { latest_package_identifier: string } | null = null
|
||||||
|
let pluginLoading = false
|
||||||
|
let inModelList = true
|
||||||
|
const invalidateInstalledPluginList = vi.fn()
|
||||||
|
const handleOpenModal = vi.fn()
|
||||||
|
const updateModelProviders = vi.fn()
|
||||||
|
const updateModelList = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/context/provider-context', () => ({
|
||||||
|
useProviderContext: () => ({
|
||||||
|
modelProviders,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/service/use-plugins', () => ({
|
||||||
|
useInvalidateInstalledPluginList: () => invalidateInstalledPluginList,
|
||||||
|
useModelInList: () => ({ data: inModelList }),
|
||||||
|
usePluginInfo: () => ({ data: pluginInfo, isLoading: pluginLoading }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../hooks', () => ({
|
||||||
|
useModelModalHandler: () => handleOpenModal,
|
||||||
|
useUpdateModelList: () => updateModelList,
|
||||||
|
useUpdateModelProviders: () => updateModelProviders,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../model-icon', () => ({
|
||||||
|
default: () => <div>Icon</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./model-display', () => ({
|
||||||
|
default: () => <div>ModelDisplay</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./status-indicators', () => ({
|
||||||
|
default: () => <div>StatusIndicators</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
|
||||||
|
InstallPluginButton: ({ onClick, onSuccess }: { onClick: (event: MouseEvent<HTMLButtonElement>) => void, onSuccess: () => void }) => (
|
||||||
|
<button
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick(event)
|
||||||
|
onSuccess()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Install Plugin
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('AgentModelTrigger', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
modelProviders = []
|
||||||
|
pluginInfo = null
|
||||||
|
pluginLoading = false
|
||||||
|
inModelList = true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render loading state when plugin info is still fetching', () => {
|
||||||
|
pluginLoading = true
|
||||||
|
render(
|
||||||
|
<AgentModelTrigger
|
||||||
|
modelId="gpt-4"
|
||||||
|
providerName="openai"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render model actions for configured provider', () => {
|
||||||
|
modelProviders = [{
|
||||||
|
provider: 'openai',
|
||||||
|
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||||
|
system_configuration: {
|
||||||
|
enabled: true,
|
||||||
|
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
|
||||||
|
quota_configurations: [{
|
||||||
|
quota_type: CurrentSystemQuotaTypeEnum.paid,
|
||||||
|
quota_unit: QuotaUnitEnum.times,
|
||||||
|
quota_limit: 10,
|
||||||
|
quota_used: 1,
|
||||||
|
last_used: 1,
|
||||||
|
is_valid: true,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}] as unknown as ModelProvider[]
|
||||||
|
render(
|
||||||
|
<AgentModelTrigger
|
||||||
|
modelId="gpt-4"
|
||||||
|
providerName="openai"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('ModelDisplay')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('StatusIndicators')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support plugin installation flow when provider is missing', () => {
|
||||||
|
pluginInfo = { latest_package_identifier: 'plugin/demo@1.0.0' }
|
||||||
|
render(
|
||||||
|
<AgentModelTrigger
|
||||||
|
modelId="gpt-4"
|
||||||
|
providerName="openai"
|
||||||
|
scope={`${ModelTypeEnum.textGeneration},${ModelTypeEnum.tts}`}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Install Plugin'))
|
||||||
|
expect(updateModelList).toHaveBeenCalledWith(ModelTypeEnum.textGeneration)
|
||||||
|
expect(updateModelList).toHaveBeenCalledWith(ModelTypeEnum.tts)
|
||||||
|
expect(updateModelProviders).toHaveBeenCalledTimes(1)
|
||||||
|
expect(invalidateInstalledPluginList).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show configuration action when provider requires setup', () => {
|
||||||
|
modelProviders = [{
|
||||||
|
provider: 'openai',
|
||||||
|
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||||
|
system_configuration: {
|
||||||
|
enabled: false,
|
||||||
|
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
|
||||||
|
quota_configurations: [],
|
||||||
|
},
|
||||||
|
}] as unknown as ModelProvider[]
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentModelTrigger
|
||||||
|
modelId="gpt-4"
|
||||||
|
providerName="openai"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('workflow.nodes.agent.notAuthorized')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render unconfigured state when model is not selected', () => {
|
||||||
|
render(<AgentModelTrigger />)
|
||||||
|
expect(screen.getByText('workflow.nodes.agent.configureModel')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import { ConfigurationMethodEnum } from '../declarations'
|
||||||
|
import ConfigurationButton from './configuration-button'
|
||||||
|
|
||||||
|
describe('ConfigurationButton', () => {
|
||||||
|
it('should render and handle click', () => {
|
||||||
|
const handleOpenModal = vi.fn()
|
||||||
|
const modelProvider = { id: 1 }
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConfigurationButton
|
||||||
|
modelProvider={modelProvider as unknown as ComponentProps<typeof ConfigurationButton>['modelProvider']}
|
||||||
|
handleOpenModal={handleOpenModal}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const button = screen.getByRole('button')
|
||||||
|
fireEvent.click(button)
|
||||||
|
|
||||||
|
expect(handleOpenModal).toHaveBeenCalledWith(
|
||||||
|
modelProvider,
|
||||||
|
ConfigurationMethodEnum.predefinedModel,
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import ModelParameterModal from './index'
|
||||||
|
|
||||||
|
let isAPIKeySet = true
|
||||||
|
let parameterRules = [
|
||||||
|
{
|
||||||
|
name: 'temperature',
|
||||||
|
label: { en_US: 'Temperature' },
|
||||||
|
type: 'float',
|
||||||
|
default: 0.7,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
help: { en_US: 'Control randomness' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
let isRulesLoading = false
|
||||||
|
let currentProvider: Record<string, unknown> | undefined = { provider: 'openai', label: { en_US: 'OpenAI' } }
|
||||||
|
let currentModel: Record<string, unknown> | undefined = {
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
status: 'active',
|
||||||
|
model_properties: { mode: 'chat' },
|
||||||
|
}
|
||||||
|
let activeTextGenerationModelList: Array<Record<string, unknown>> = [
|
||||||
|
{
|
||||||
|
provider: 'openai',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
model_properties: { mode: 'chat' },
|
||||||
|
features: ['vision'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: 'gpt-4.1',
|
||||||
|
model_properties: { mode: 'chat' },
|
||||||
|
features: ['vision', 'tool-call'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
vi.mock('@/context/provider-context', () => ({
|
||||||
|
useProviderContext: () => ({
|
||||||
|
isAPIKeySet,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/service/use-common', () => ({
|
||||||
|
useModelParameterRules: () => ({
|
||||||
|
data: {
|
||||||
|
data: parameterRules,
|
||||||
|
},
|
||||||
|
isPending: isRulesLoading,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../hooks', () => ({
|
||||||
|
useTextGenerationCurrentProviderAndModelAndModelList: () => ({
|
||||||
|
currentProvider,
|
||||||
|
currentModel,
|
||||||
|
activeTextGenerationModelList,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock PortalToFollowElem components to control visibility and simplify testing
|
||||||
|
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
||||||
|
return {
|
||||||
|
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="portal-wrapper">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
|
||||||
|
<div data-testid="portal-trigger" onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className: string }) => (
|
||||||
|
<div data-testid="portal-content" className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('./parameter-item', () => ({
|
||||||
|
default: ({ parameterRule, value, onChange, onSwitch }: { parameterRule: { name: string, label: { en_US: string } }, value: string | number, onChange: (v: number) => void, onSwitch: (checked: boolean, val: unknown) => void }) => (
|
||||||
|
<div data-testid={`param-${parameterRule.name}`}>
|
||||||
|
{parameterRule.label.en_US}
|
||||||
|
<input
|
||||||
|
aria-label={parameterRule.name}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={e => onChange(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<button onClick={() => onSwitch?.(false, undefined)}>Remove</button>
|
||||||
|
<button onClick={() => onSwitch?.(true, 'assigned')}>Add</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../model-selector', () => ({
|
||||||
|
default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => (
|
||||||
|
<div data-testid="model-selector">
|
||||||
|
Model Selector
|
||||||
|
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>Select GPT-4.1</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./presets-parameter', () => ({
|
||||||
|
default: ({ onSelect }: { onSelect: (id: number) => void }) => (
|
||||||
|
<button onClick={() => onSelect(1)}>Preset 1</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./trigger', () => ({
|
||||||
|
default: () => <button>Open Settings</button>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/classnames', () => ({
|
||||||
|
cn: (...args: (string | undefined | null | false)[]) => args.filter(Boolean).join(' '),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock config
|
||||||
|
vi.mock('@/config', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/config')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
PROVIDER_WITH_PRESET_TONE: ['openai'], // ensure presets mock renders
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ModelParameterModal', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
isAdvancedMode: false,
|
||||||
|
modelId: 'gpt-3.5-turbo',
|
||||||
|
provider: 'openai',
|
||||||
|
setModel: vi.fn(),
|
||||||
|
completionParams: { temperature: 0.7 },
|
||||||
|
onCompletionParamsChange: vi.fn(),
|
||||||
|
hideDebugWithMultipleModel: false,
|
||||||
|
debugWithMultipleModel: false,
|
||||||
|
onDebugWithMultipleModelChange: vi.fn(),
|
||||||
|
readonly: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
isAPIKeySet = true
|
||||||
|
isRulesLoading = false
|
||||||
|
parameterRules = [
|
||||||
|
{
|
||||||
|
name: 'temperature',
|
||||||
|
label: { en_US: 'Temperature' },
|
||||||
|
type: 'float',
|
||||||
|
default: 0.7,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
help: { en_US: 'Control randomness' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } }
|
||||||
|
currentModel = {
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
status: 'active',
|
||||||
|
model_properties: { mode: 'chat' },
|
||||||
|
}
|
||||||
|
activeTextGenerationModelList = [
|
||||||
|
{
|
||||||
|
provider: 'openai',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
model: 'gpt-3.5-turbo',
|
||||||
|
model_properties: { mode: 'chat' },
|
||||||
|
features: ['vision'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: 'gpt-4.1',
|
||||||
|
model_properties: { mode: 'chat' },
|
||||||
|
features: ['vision', 'tool-call'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render trigger and content', () => {
|
||||||
|
render(<ModelParameterModal {...defaultProps} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Open Settings')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Temperature')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update params when changed and handle switch add/remove', () => {
|
||||||
|
render(<ModelParameterModal {...defaultProps} />)
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('temperature')
|
||||||
|
fireEvent.change(input, { target: { value: '0.9' } })
|
||||||
|
|
||||||
|
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
|
||||||
|
...defaultProps.completionParams,
|
||||||
|
temperature: 0.9,
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Remove'))
|
||||||
|
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
|
||||||
|
...defaultProps.completionParams,
|
||||||
|
temperature: 'assigned',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle preset selection', () => {
|
||||||
|
render(<ModelParameterModal {...defaultProps} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Preset 1'))
|
||||||
|
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle debug mode toggle', () => {
|
||||||
|
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
|
||||||
|
const toggle = screen.getByText(/debugAsMultipleModel/i)
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled()
|
||||||
|
|
||||||
|
rerender(<ModelParameterModal {...defaultProps} debugWithMultipleModel />)
|
||||||
|
expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
it('should handle custom renderTrigger', () => {
|
||||||
|
const renderTrigger = vi.fn().mockReturnValue(<div>Custom Trigger</div>)
|
||||||
|
render(<ModelParameterModal {...defaultProps} renderTrigger={renderTrigger} readonly />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Custom Trigger')).toBeInTheDocument()
|
||||||
|
expect(renderTrigger).toHaveBeenCalled()
|
||||||
|
fireEvent.click(screen.getByTestId('portal-trigger'))
|
||||||
|
expect(renderTrigger).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle model selection and advanced mode parameters', () => {
|
||||||
|
parameterRules = [
|
||||||
|
{
|
||||||
|
name: 'temperature',
|
||||||
|
label: { en_US: 'Temperature' },
|
||||||
|
type: 'float',
|
||||||
|
default: 0.7,
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
help: { en_US: 'Control randomness' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
|
||||||
|
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(<ModelParameterModal {...defaultProps} isAdvancedMode />)
|
||||||
|
expect(screen.getByTestId('param-stop')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Select GPT-4.1'))
|
||||||
|
expect(defaultProps.setModel).toHaveBeenCalledWith({
|
||||||
|
modelId: 'gpt-4.1',
|
||||||
|
provider: 'openai',
|
||||||
|
mode: 'chat',
|
||||||
|
features: ['vision', 'tool-call'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import ModelDisplay from './model-display'
|
||||||
|
|
||||||
|
vi.mock('../model-name', () => ({
|
||||||
|
default: ({ modelItem }: { modelItem: { model: string } }) => <div>{modelItem.model}</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('ModelDisplay', () => {
|
||||||
|
it('should render model name when model is present', () => {
|
||||||
|
const currentModel = { model: 'gpt-4' }
|
||||||
|
render(<ModelDisplay currentModel={currentModel} modelId="gpt-4" />)
|
||||||
|
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render modelID when currentModel is missing', () => {
|
||||||
|
render(<ModelDisplay currentModel={null} modelId="unknown-model" />)
|
||||||
|
expect(screen.getByText('unknown-model')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import type { ModelParameterRule } from '../declarations'
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import ParameterItem from './parameter-item'
|
||||||
|
|
||||||
|
vi.mock('../hooks', () => ({
|
||||||
|
useLanguage: () => 'en_US',
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/radio', () => {
|
||||||
|
const Radio = ({ children, value }: { children: React.ReactNode, value: boolean }) => <button data-testid={`radio-${value}`}>{children}</button>
|
||||||
|
Radio.Group = ({ children, onChange }: { children: React.ReactNode, onChange: (value: boolean) => void }) => (
|
||||||
|
<div>
|
||||||
|
{children}
|
||||||
|
<button onClick={() => onChange(true)}>Select True</button>
|
||||||
|
<button onClick={() => onChange(false)}>Select False</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
return { default: Radio }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/select', () => ({
|
||||||
|
SimpleSelect: ({ onSelect, items }: { onSelect: (item: { value: string }) => void, items: { value: string, name: string }[] }) => (
|
||||||
|
<select onChange={e => onSelect({ value: e.target.value })}>
|
||||||
|
{items.map(item => (
|
||||||
|
<option key={item.value} value={item.value}>{item.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/slider', () => ({
|
||||||
|
default: ({ value, onChange }: { value: number, onChange: (val: number) => void }) => (
|
||||||
|
<input type="range" value={value} onChange={e => onChange(Number(e.target.value))} />
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/switch', () => ({
|
||||||
|
default: ({ onChange, value }: { onChange: (val: boolean) => void, value: boolean }) => (
|
||||||
|
<button onClick={() => onChange(!value)}>Switch</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/tag-input', () => ({
|
||||||
|
default: ({ onChange }: { onChange: (val: string[]) => void }) => (
|
||||||
|
<input onChange={e => onChange(e.target.value.split(','))} />
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/tooltip', () => ({
|
||||||
|
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('ParameterItem', () => {
|
||||||
|
const createRule = (overrides: Partial<ModelParameterRule> = {}): ModelParameterRule => ({
|
||||||
|
name: 'temp',
|
||||||
|
label: { en_US: 'Temperature', zh_Hans: 'Temperature' },
|
||||||
|
type: 'float',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
help: { en_US: 'Help text', zh_Hans: 'Help text' },
|
||||||
|
required: false,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createProps = (overrides: {
|
||||||
|
parameterRule?: ModelParameterRule
|
||||||
|
value?: number | string | boolean | string[]
|
||||||
|
} = {}) => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const onSwitch = vi.fn()
|
||||||
|
return {
|
||||||
|
parameterRule: createRule(),
|
||||||
|
value: 0.7,
|
||||||
|
onChange,
|
||||||
|
onSwitch,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render float input with slider', () => {
|
||||||
|
const props = createProps()
|
||||||
|
const { rerender } = render(<ParameterItem {...props} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Temperature')).toBeInTheDocument()
|
||||||
|
const input = screen.getByRole('spinbutton')
|
||||||
|
fireEvent.change(input, { target: { value: '0.8' } })
|
||||||
|
expect(props.onChange).toHaveBeenCalledWith(0.8)
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: '1.4' } })
|
||||||
|
expect(props.onChange).toHaveBeenCalledWith(1)
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: '-0.2' } })
|
||||||
|
expect(props.onChange).toHaveBeenCalledWith(0)
|
||||||
|
|
||||||
|
const slider = screen.getByRole('slider')
|
||||||
|
fireEvent.change(slider, { target: { value: '2' } })
|
||||||
|
expect(props.onChange).toHaveBeenCalledWith(1)
|
||||||
|
|
||||||
|
fireEvent.change(slider, { target: { value: '-1' } })
|
||||||
|
expect(props.onChange).toHaveBeenCalledWith(0)
|
||||||
|
|
||||||
|
fireEvent.change(slider, { target: { value: '0.4' } })
|
||||||
|
expect(props.onChange).toHaveBeenCalledWith(0.4)
|
||||||
|
|
||||||
|
fireEvent.blur(input)
|
||||||
|
expect(input).toHaveValue(0.7)
|
||||||
|
|
||||||
|
const minBoundedProps = createProps({
|
||||||
|
parameterRule: createRule({ type: 'float', min: 1, max: 2 }),
|
||||||
|
value: 1.5,
|
||||||
|
})
|
||||||
|
rerender(<ParameterItem {...minBoundedProps} />)
|
||||||
|
fireEvent.change(screen.getByRole('slider'), { target: { value: '0' } })
|
||||||
|
expect(minBoundedProps.onChange).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render boolean radio', () => {
|
||||||
|
const props = createProps({ parameterRule: createRule({ type: 'boolean', default: false }), value: true })
|
||||||
|
render(<ParameterItem {...props} />)
|
||||||
|
expect(screen.getByText('True')).toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByText('Select False'))
|
||||||
|
expect(props.onChange).toHaveBeenCalledWith(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render string input and select options', () => {
|
||||||
|
const props = createProps({ parameterRule: createRule({ type: 'string' }), value: 'test' })
|
||||||
|
const { rerender } = render(<ParameterItem {...props} />)
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
fireEvent.change(input, { target: { value: 'new' } })
|
||||||
|
expect(props.onChange).toHaveBeenCalledWith('new')
|
||||||
|
|
||||||
|
const selectProps = createProps({
|
||||||
|
parameterRule: createRule({ type: 'string', options: ['opt1', 'opt2'] }),
|
||||||
|
value: 'opt1',
|
||||||
|
})
|
||||||
|
rerender(<ParameterItem {...selectProps} />)
|
||||||
|
const select = screen.getByRole('combobox')
|
||||||
|
fireEvent.change(select, { target: { value: 'opt2' } })
|
||||||
|
expect(selectProps.onChange).toHaveBeenCalledWith('opt2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle switch toggle', () => {
|
||||||
|
const props = createProps()
|
||||||
|
let view = render(<ParameterItem {...props} />)
|
||||||
|
fireEvent.click(screen.getByText('Switch'))
|
||||||
|
expect(props.onSwitch).toHaveBeenCalledWith(false, 0.7)
|
||||||
|
|
||||||
|
const intDefaultProps = createProps({
|
||||||
|
parameterRule: createRule({ type: 'int', min: 0, default: undefined }),
|
||||||
|
value: undefined,
|
||||||
|
})
|
||||||
|
view.unmount()
|
||||||
|
view = render(<ParameterItem {...intDefaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Switch'))
|
||||||
|
expect(intDefaultProps.onSwitch).toHaveBeenCalledWith(true, 0)
|
||||||
|
|
||||||
|
const stringDefaultProps = createProps({
|
||||||
|
parameterRule: createRule({ type: 'string', default: 'preset-value' }),
|
||||||
|
value: undefined,
|
||||||
|
})
|
||||||
|
view.unmount()
|
||||||
|
view = render(<ParameterItem {...stringDefaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Switch'))
|
||||||
|
expect(stringDefaultProps.onSwitch).toHaveBeenCalledWith(true, 'preset-value')
|
||||||
|
|
||||||
|
const booleanDefaultProps = createProps({
|
||||||
|
parameterRule: createRule({ type: 'boolean', default: true }),
|
||||||
|
value: undefined,
|
||||||
|
})
|
||||||
|
view.unmount()
|
||||||
|
view = render(<ParameterItem {...booleanDefaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Switch'))
|
||||||
|
expect(booleanDefaultProps.onSwitch).toHaveBeenCalledWith(true, true)
|
||||||
|
|
||||||
|
const tagDefaultProps = createProps({
|
||||||
|
parameterRule: createRule({ type: 'tag', default: ['one'] }),
|
||||||
|
value: undefined,
|
||||||
|
})
|
||||||
|
view.unmount()
|
||||||
|
const tagView = render(<ParameterItem {...tagDefaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Switch'))
|
||||||
|
expect(tagDefaultProps.onSwitch).toHaveBeenCalledWith(true, ['one'])
|
||||||
|
|
||||||
|
const zeroValueProps = createProps({
|
||||||
|
parameterRule: createRule({ type: 'float', default: 0.5 }),
|
||||||
|
value: 0,
|
||||||
|
})
|
||||||
|
tagView.unmount()
|
||||||
|
render(<ParameterItem {...zeroValueProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Switch'))
|
||||||
|
expect(zeroValueProps.onSwitch).toHaveBeenCalledWith(false, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support text and tag parameter interactions', () => {
|
||||||
|
const textProps = createProps({
|
||||||
|
parameterRule: createRule({ type: 'text', name: 'prompt' }),
|
||||||
|
value: 'initial prompt',
|
||||||
|
})
|
||||||
|
const { rerender } = render(<ParameterItem {...textProps} />)
|
||||||
|
const textarea = screen.getByRole('textbox')
|
||||||
|
fireEvent.change(textarea, { target: { value: 'rewritten prompt' } })
|
||||||
|
expect(textProps.onChange).toHaveBeenCalledWith('rewritten prompt')
|
||||||
|
|
||||||
|
const tagProps = createProps({
|
||||||
|
parameterRule: createRule({
|
||||||
|
type: 'tag',
|
||||||
|
name: 'tags',
|
||||||
|
tagPlaceholder: { en_US: 'Tag hint', zh_Hans: 'Tag hint' },
|
||||||
|
}),
|
||||||
|
value: ['alpha'],
|
||||||
|
})
|
||||||
|
rerender(<ParameterItem {...tagProps} />)
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'one,two' } })
|
||||||
|
expect(tagProps.onChange).toHaveBeenCalledWith(['one', 'two'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support int parameters and unknown type fallback', () => {
|
||||||
|
const intProps = createProps({
|
||||||
|
parameterRule: createRule({ type: 'int', min: 0, max: 500, default: 100 }),
|
||||||
|
value: 100,
|
||||||
|
})
|
||||||
|
const { rerender } = render(<ParameterItem {...intProps} />)
|
||||||
|
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '350' } })
|
||||||
|
expect(intProps.onChange).toHaveBeenCalledWith(350)
|
||||||
|
|
||||||
|
const unknownTypeProps = createProps({
|
||||||
|
parameterRule: createRule({ type: 'unsupported' }),
|
||||||
|
value: 0.7,
|
||||||
|
})
|
||||||
|
rerender(<ParameterItem {...unknownTypeProps} />)
|
||||||
|
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -109,7 +109,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
|||||||
|
|
||||||
const handleSwitch = (checked: boolean) => {
|
const handleSwitch = (checked: boolean) => {
|
||||||
if (onSwitch) {
|
if (onSwitch) {
|
||||||
const assignValue: ParameterValue = localValue || getDefaultValue()
|
const assignValue: ParameterValue = localValue ?? getDefaultValue()
|
||||||
|
|
||||||
onSwitch(checked, assignValue)
|
onSwitch(checked, assignValue)
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((parameterRule.type === 'int' || parameterRule.type === 'float') && numberInputRef.current)
|
if ((parameterRule.type === 'int' || parameterRule.type === 'float') && numberInputRef.current)
|
||||||
numberInputRef.current.value = `${renderValue}`
|
numberInputRef.current.value = `${renderValue}`
|
||||||
}, [value])
|
}, [value, parameterRule.type, renderValue])
|
||||||
|
|
||||||
const renderInput = () => {
|
const renderInput = () => {
|
||||||
const numberInputWithSlide = (parameterRule.type === 'int' || parameterRule.type === 'float')
|
const numberInputWithSlide = (parameterRule.type === 'int' || parameterRule.type === 'float')
|
||||||
@@ -148,7 +148,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
|||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
ref={numberInputRef}
|
ref={numberInputRef}
|
||||||
className="system-sm-regular ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-components-input-text-filled outline-none"
|
className="ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-components-input-text-filled outline-none system-sm-regular"
|
||||||
type="number"
|
type="number"
|
||||||
max={parameterRule.max}
|
max={parameterRule.max}
|
||||||
min={parameterRule.min}
|
min={parameterRule.min}
|
||||||
@@ -175,7 +175,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
|||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
ref={numberInputRef}
|
ref={numberInputRef}
|
||||||
className="system-sm-regular ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-components-input-text-filled outline-none"
|
className="ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-components-input-text-filled outline-none system-sm-regular"
|
||||||
type="number"
|
type="number"
|
||||||
max={parameterRule.max}
|
max={parameterRule.max}
|
||||||
min={parameterRule.min}
|
min={parameterRule.min}
|
||||||
@@ -203,7 +203,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
|||||||
if (parameterRule.type === 'string' && !parameterRule.options?.length) {
|
if (parameterRule.type === 'string' && !parameterRule.options?.length) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className={cn(isInWorkflow ? 'w-[150px]' : 'w-full', 'system-sm-regular ml-4 flex h-8 appearance-none items-center rounded-lg bg-components-input-bg-normal px-3 text-components-input-text-filled outline-none')}
|
className={cn(isInWorkflow ? 'w-[150px]' : 'w-full', 'ml-4 flex h-8 appearance-none items-center rounded-lg bg-components-input-bg-normal px-3 text-components-input-text-filled outline-none system-sm-regular')}
|
||||||
value={renderValue as string}
|
value={renderValue as string}
|
||||||
onChange={handleStringInputChange}
|
onChange={handleStringInputChange}
|
||||||
/>
|
/>
|
||||||
@@ -213,7 +213,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
|||||||
if (parameterRule.type === 'text') {
|
if (parameterRule.type === 'text') {
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
className="system-sm-regular ml-4 h-20 w-full rounded-lg bg-components-input-bg-normal px-1 text-components-input-text-filled"
|
className="ml-4 h-20 w-full rounded-lg bg-components-input-bg-normal px-1 text-components-input-text-filled system-sm-regular"
|
||||||
value={renderValue as string}
|
value={renderValue as string}
|
||||||
onChange={handleStringInputChange}
|
onChange={handleStringInputChange}
|
||||||
/>
|
/>
|
||||||
@@ -265,7 +265,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
<div
|
<div
|
||||||
className="system-xs-regular mr-0.5 truncate text-text-secondary"
|
className="mr-0.5 truncate text-text-secondary system-xs-regular"
|
||||||
title={parameterRule.label[language] || parameterRule.label.en_US}
|
title={parameterRule.label[language] || parameterRule.label.en_US}
|
||||||
>
|
>
|
||||||
{parameterRule.label[language] || parameterRule.label.en_US}
|
{parameterRule.label[language] || parameterRule.label.en_US}
|
||||||
@@ -284,7 +284,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{
|
{
|
||||||
parameterRule.type === 'tag' && (
|
parameterRule.type === 'tag' && (
|
||||||
<div className={cn(!isInWorkflow && 'w-[150px]', 'system-xs-regular text-text-tertiary')}>
|
<div className={cn(!isInWorkflow && 'w-[150px]', 'text-text-tertiary system-xs-regular')}>
|
||||||
{parameterRule?.tagPlaceholder?.[language]}
|
{parameterRule?.tagPlaceholder?.[language]}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import PresetsParameter from './presets-parameter'
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/dropdown', () => ({
|
||||||
|
default: ({ renderTrigger, items, onSelect }: { renderTrigger: (open: boolean) => React.ReactNode, items: { value: number, text: string }[], onSelect: (item: { value: number }) => void }) => (
|
||||||
|
<div>
|
||||||
|
{renderTrigger(false)}
|
||||||
|
{items.map(item => (
|
||||||
|
<button key={item.value} onClick={() => onSelect(item)}>
|
||||||
|
{item.text}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('PresetsParameter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render presets and handle selection', () => {
|
||||||
|
const onSelect = vi.fn()
|
||||||
|
render(<PresetsParameter onSelect={onSelect} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('common.modelProvider.loadPresets')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('common.model.tone.Creative'))
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { vi } from 'vitest'
|
||||||
|
import StatusIndicators from './status-indicators'
|
||||||
|
|
||||||
|
let installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }]
|
||||||
|
|
||||||
|
vi.mock('@/service/use-plugins', () => ({
|
||||||
|
useInstalledPluginList: () => ({ data: { plugins: installedPlugins } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/tooltip', () => ({
|
||||||
|
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({
|
||||||
|
SwitchPluginVersion: ({ uniqueIdentifier }: { uniqueIdentifier: string }) => <div>{`SwitchVersion:${uniqueIdentifier}`}</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const t = (key: string) => key
|
||||||
|
|
||||||
|
describe('StatusIndicators', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render nothing when model is available and enabled', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<StatusIndicators
|
||||||
|
needsConfiguration={false}
|
||||||
|
modelProvider={true}
|
||||||
|
inModelList={true}
|
||||||
|
disabled={false}
|
||||||
|
pluginInfo={null}
|
||||||
|
t={t}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(container).toBeEmptyDOMElement()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render warning states when provider model is disabled', () => {
|
||||||
|
const parentClick = vi.fn()
|
||||||
|
const { rerender } = render(
|
||||||
|
<div onClick={parentClick}>
|
||||||
|
<StatusIndicators
|
||||||
|
needsConfiguration={false}
|
||||||
|
modelProvider={true}
|
||||||
|
inModelList={true}
|
||||||
|
disabled={true}
|
||||||
|
pluginInfo={null}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<div onClick={parentClick}>
|
||||||
|
<StatusIndicators
|
||||||
|
needsConfiguration={false}
|
||||||
|
modelProvider={true}
|
||||||
|
inModelList={false}
|
||||||
|
disabled={true}
|
||||||
|
pluginInfo={null}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('nodes.agent.linkToPlugin').closest('a')).toHaveAttribute('href', '/plugins')
|
||||||
|
fireEvent.click(screen.getByText('nodes.agent.modelNotSupport.title'))
|
||||||
|
fireEvent.click(screen.getByText('nodes.agent.linkToPlugin'))
|
||||||
|
expect(parentClick).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<div onClick={parentClick}>
|
||||||
|
<StatusIndicators
|
||||||
|
needsConfiguration={false}
|
||||||
|
modelProvider={true}
|
||||||
|
inModelList={false}
|
||||||
|
disabled={true}
|
||||||
|
pluginInfo={{ name: 'demo-plugin' }}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('SwitchVersion:demo@1.0.0')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render marketplace warning when provider is unavailable', () => {
|
||||||
|
render(
|
||||||
|
<StatusIndicators
|
||||||
|
needsConfiguration={false}
|
||||||
|
modelProvider={false}
|
||||||
|
inModelList={false}
|
||||||
|
disabled={false}
|
||||||
|
pluginInfo={null}
|
||||||
|
t={t}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import Trigger from './trigger'
|
||||||
|
|
||||||
|
vi.mock('../hooks', () => ({
|
||||||
|
useLanguage: () => 'en_US',
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/context/provider-context', () => ({
|
||||||
|
useProviderContext: () => ({
|
||||||
|
modelProviders: [{ provider: 'openai', label: { en_US: 'OpenAI' } }],
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../model-icon', () => ({
|
||||||
|
default: () => <div data-testid="model-icon">Icon</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../model-name', () => ({
|
||||||
|
default: ({ modelItem }: { modelItem: { model: string } }) => <div>{modelItem.model}</div>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('Trigger', () => {
|
||||||
|
const currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } } as unknown as ComponentProps<typeof Trigger>['currentProvider']
|
||||||
|
const currentModel = { model: 'gpt-4' } as unknown as ComponentProps<typeof Trigger>['currentModel']
|
||||||
|
|
||||||
|
it('should render initialized state', () => {
|
||||||
|
render(
|
||||||
|
<Trigger
|
||||||
|
currentProvider={currentProvider}
|
||||||
|
currentModel={currentModel}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render fallback model id when current model is missing', () => {
|
||||||
|
render(
|
||||||
|
<Trigger
|
||||||
|
modelId="gpt-4"
|
||||||
|
providerName="openai"
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4387,11 +4387,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx": {
|
|
||||||
"tailwindcss/enforce-consistent-class-order": {
|
|
||||||
"count": 6
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.tsx": {
|
"app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.tsx": {
|
||||||
"tailwindcss/enforce-consistent-class-order": {
|
"tailwindcss/enforce-consistent-class-order": {
|
||||||
"count": 1
|
"count": 1
|
||||||
|
|||||||
Reference in New Issue
Block a user