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
|
||||
@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.
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@ except ImportError:
|
||||
from importlib_metadata import version # type: ignore[import-not-found]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.metrics import Meter
|
||||
from opentelemetry.metrics._internal.instrument import Histogram
|
||||
from opentelemetry.metrics import Histogram, Meter
|
||||
from opentelemetry.sdk.metrics.export import MetricReader
|
||||
|
||||
from opentelemetry import trace as trace_api
|
||||
|
||||
@@ -15,7 +15,7 @@ class BaseVector(ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs):
|
||||
def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs) -> list[str] | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
Integration tests for Dataset and Document model properties using testcontainers.
|
||||
|
||||
These tests validate database-backed model properties (total_documents, word_count, etc.)
|
||||
without mocking SQLAlchemy queries, ensuring real query behavior against PostgreSQL.
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.dataset import Dataset, Document, DocumentSegment
|
||||
|
||||
|
||||
class TestDatasetDocumentProperties:
|
||||
"""Integration tests for Dataset and Document model properties."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _auto_rollback(self, db_session_with_containers: Session) -> Generator[None, None, None]:
|
||||
"""Automatically rollback session changes after each test."""
|
||||
yield
|
||||
db_session_with_containers.rollback()
|
||||
|
||||
def test_dataset_with_documents_relationship(self, db_session_with_containers: Session) -> None:
|
||||
"""Test dataset can track its documents."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i in range(3):
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=i + 1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name=f"doc_{i}.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.total_documents == 3
|
||||
|
||||
def test_dataset_available_documents_count(self, db_session_with_containers: Session) -> None:
|
||||
"""Test dataset can count available documents."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc_available = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="available.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
indexing_status="completed",
|
||||
enabled=True,
|
||||
archived=False,
|
||||
)
|
||||
doc_pending = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=2,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="pending.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
indexing_status="waiting",
|
||||
enabled=True,
|
||||
archived=False,
|
||||
)
|
||||
doc_disabled = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=3,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="disabled.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
indexing_status="completed",
|
||||
enabled=False,
|
||||
archived=False,
|
||||
)
|
||||
db_session_with_containers.add_all([doc_available, doc_pending, doc_disabled])
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.total_available_documents == 1
|
||||
|
||||
def test_dataset_word_count_aggregation(self, db_session_with_containers: Session) -> None:
|
||||
"""Test dataset can aggregate word count from documents."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i, wc in enumerate([2000, 3000]):
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=i + 1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name=f"doc_{i}.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
word_count=wc,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.word_count == 5000
|
||||
|
||||
def test_dataset_available_segment_count(self, db_session_with_containers: Session) -> None:
|
||||
"""Test Dataset.available_segment_count counts completed and enabled segments."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="doc.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i in range(2):
|
||||
seg = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=i + 1,
|
||||
content=f"segment {i}",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
status="completed",
|
||||
enabled=True,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg)
|
||||
|
||||
seg_waiting = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=3,
|
||||
content="waiting segment",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
status="waiting",
|
||||
enabled=True,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg_waiting)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert dataset.available_segment_count == 2
|
||||
|
||||
def test_document_segment_count_property(self, db_session_with_containers: Session) -> None:
|
||||
"""Test document can count its segments."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="doc.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i in range(3):
|
||||
seg = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=i + 1,
|
||||
content=f"segment {i}",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert doc.segment_count == 3
|
||||
|
||||
def test_document_hit_count_aggregation(self, db_session_with_containers: Session) -> None:
|
||||
"""Test document can aggregate hit count from segments."""
|
||||
tenant_id = str(uuid4())
|
||||
created_by = str(uuid4())
|
||||
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id, name="Test Dataset", data_source_type="upload_file", created_by=created_by
|
||||
)
|
||||
db_session_with_containers.add(dataset)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
doc = Document(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="doc.pdf",
|
||||
created_from="web",
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(doc)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
for i, hits in enumerate([10, 15]):
|
||||
seg = DocumentSegment(
|
||||
tenant_id=tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
document_id=doc.id,
|
||||
position=i + 1,
|
||||
content=f"segment {i}",
|
||||
word_count=100,
|
||||
tokens=50,
|
||||
hit_count=hits,
|
||||
created_by=created_by,
|
||||
)
|
||||
db_session_with_containers.add(seg)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
assert doc.hit_count == 25
|
||||
@@ -12,7 +12,7 @@ This test suite covers:
|
||||
import json
|
||||
import pickle
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from models.dataset import (
|
||||
@@ -954,156 +954,6 @@ class TestChildChunk:
|
||||
assert child_chunk.index_node_hash == index_node_hash
|
||||
|
||||
|
||||
class TestDatasetDocumentCascadeDeletes:
|
||||
"""Test suite for Dataset-Document cascade delete operations."""
|
||||
|
||||
def test_dataset_with_documents_relationship(self):
|
||||
"""Test dataset can track its documents."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid4())
|
||||
dataset = Dataset(
|
||||
tenant_id=str(uuid4()),
|
||||
name="Test Dataset",
|
||||
data_source_type="upload_file",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
dataset.id = dataset_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.where.return_value.scalar.return_value = 3
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
total_docs = dataset.total_documents
|
||||
|
||||
# Assert
|
||||
assert total_docs == 3
|
||||
|
||||
def test_dataset_available_documents_count(self):
|
||||
"""Test dataset can count available documents."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid4())
|
||||
dataset = Dataset(
|
||||
tenant_id=str(uuid4()),
|
||||
name="Test Dataset",
|
||||
data_source_type="upload_file",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
dataset.id = dataset_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.where.return_value.scalar.return_value = 2
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
available_docs = dataset.total_available_documents
|
||||
|
||||
# Assert
|
||||
assert available_docs == 2
|
||||
|
||||
def test_dataset_word_count_aggregation(self):
|
||||
"""Test dataset can aggregate word count from documents."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid4())
|
||||
dataset = Dataset(
|
||||
tenant_id=str(uuid4()),
|
||||
name="Test Dataset",
|
||||
data_source_type="upload_file",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
dataset.id = dataset_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.with_entities.return_value.where.return_value.scalar.return_value = 5000
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
total_words = dataset.word_count
|
||||
|
||||
# Assert
|
||||
assert total_words == 5000
|
||||
|
||||
def test_dataset_available_segment_count(self):
|
||||
"""Test dataset can count available segments."""
|
||||
# Arrange
|
||||
dataset_id = str(uuid4())
|
||||
dataset = Dataset(
|
||||
tenant_id=str(uuid4()),
|
||||
name="Test Dataset",
|
||||
data_source_type="upload_file",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
dataset.id = dataset_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.where.return_value.scalar.return_value = 15
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
segment_count = dataset.available_segment_count
|
||||
|
||||
# Assert
|
||||
assert segment_count == 15
|
||||
|
||||
def test_document_segment_count_property(self):
|
||||
"""Test document can count its segments."""
|
||||
# Arrange
|
||||
document_id = str(uuid4())
|
||||
document = Document(
|
||||
tenant_id=str(uuid4()),
|
||||
dataset_id=str(uuid4()),
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="test.pdf",
|
||||
created_from="web",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
document.id = document_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.where.return_value.count.return_value = 10
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
segment_count = document.segment_count
|
||||
|
||||
# Assert
|
||||
assert segment_count == 10
|
||||
|
||||
def test_document_hit_count_aggregation(self):
|
||||
"""Test document can aggregate hit count from segments."""
|
||||
# Arrange
|
||||
document_id = str(uuid4())
|
||||
document = Document(
|
||||
tenant_id=str(uuid4()),
|
||||
dataset_id=str(uuid4()),
|
||||
position=1,
|
||||
data_source_type="upload_file",
|
||||
batch="batch_001",
|
||||
name="test.pdf",
|
||||
created_from="web",
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
document.id = document_id
|
||||
|
||||
# Mock the database session query
|
||||
mock_query = MagicMock()
|
||||
mock_query.with_entities.return_value.where.return_value.scalar.return_value = 25
|
||||
|
||||
with patch("models.dataset.db.session.query", return_value=mock_query):
|
||||
# Act
|
||||
hit_count = document.hit_count
|
||||
|
||||
# Assert
|
||||
assert hit_count == 25
|
||||
|
||||
|
||||
class TestDocumentSegmentNavigation:
|
||||
"""Test suite for DocumentSegment navigation properties."""
|
||||
|
||||
|
||||
@@ -32,10 +32,10 @@ describe('YearAndMonthPicker Options', () => {
|
||||
it('should render year options', () => {
|
||||
const props = createOptionsProps()
|
||||
|
||||
render(<Options {...props} />)
|
||||
const { container } = render(<Options {...props} />)
|
||||
|
||||
const allItems = screen.getAllByRole('listitem')
|
||||
expect(allItems).toHaveLength(212)
|
||||
const yearList = container.querySelectorAll('ul')[1]
|
||||
expect(yearList?.children).toHaveLength(200)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 { 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 { ToastContext } from '@/app/components/base/toast'
|
||||
import Operation from './index'
|
||||
@@ -55,20 +56,45 @@ describe('Operation', () => {
|
||||
})
|
||||
|
||||
it('shows dataset operator option when the feature flag is enabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
|
||||
renderOperation()
|
||||
|
||||
fireEvent.click(screen.getByText('common.members.editor'))
|
||||
await user.click(screen.getByText('common.members.editor'))
|
||||
|
||||
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 () => {
|
||||
const user = userEvent.setup()
|
||||
const onOperate = vi.fn()
|
||||
renderOperation({}, 'owner', onOperate)
|
||||
|
||||
fireEvent.click(screen.getByText('common.members.editor'))
|
||||
fireEvent.click(await screen.findByText('common.members.normal'))
|
||||
await user.click(screen.getByText('common.members.editor'))
|
||||
await user.click(await screen.findByText('common.members.normal'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateMemberRole).toHaveBeenCalled()
|
||||
@@ -77,11 +103,12 @@ describe('Operation', () => {
|
||||
})
|
||||
|
||||
it('calls deleteMemberOrCancelInvitation when removing the member', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOperate = vi.fn()
|
||||
renderOperation({}, 'owner', onOperate)
|
||||
|
||||
fireEvent.click(screen.getByText('common.members.editor'))
|
||||
fireEvent.click(await screen.findByText('common.members.removeFromTeam'))
|
||||
await user.click(screen.getByText('common.members.editor'))
|
||||
await user.click(await screen.findByText('common.members.removeFromTeam'))
|
||||
|
||||
await waitFor(() => {
|
||||
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) => {
|
||||
if (onSwitch) {
|
||||
const assignValue: ParameterValue = localValue || getDefaultValue()
|
||||
const assignValue: ParameterValue = localValue ?? getDefaultValue()
|
||||
|
||||
onSwitch(checked, assignValue)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
useEffect(() => {
|
||||
if ((parameterRule.type === 'int' || parameterRule.type === 'float') && numberInputRef.current)
|
||||
numberInputRef.current.value = `${renderValue}`
|
||||
}, [value])
|
||||
}, [value, parameterRule.type, renderValue])
|
||||
|
||||
const renderInput = () => {
|
||||
const numberInputWithSlide = (parameterRule.type === 'int' || parameterRule.type === 'float')
|
||||
@@ -148,7 +148,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
)}
|
||||
<input
|
||||
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"
|
||||
max={parameterRule.max}
|
||||
min={parameterRule.min}
|
||||
@@ -175,7 +175,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
)}
|
||||
<input
|
||||
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"
|
||||
max={parameterRule.max}
|
||||
min={parameterRule.min}
|
||||
@@ -203,7 +203,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
if (parameterRule.type === 'string' && !parameterRule.options?.length) {
|
||||
return (
|
||||
<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}
|
||||
onChange={handleStringInputChange}
|
||||
/>
|
||||
@@ -213,7 +213,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
if (parameterRule.type === 'text') {
|
||||
return (
|
||||
<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}
|
||||
onChange={handleStringInputChange}
|
||||
/>
|
||||
@@ -265,7 +265,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
)
|
||||
}
|
||||
<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}
|
||||
>
|
||||
{parameterRule.label[language] || parameterRule.label.en_US}
|
||||
@@ -284,7 +284,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
</div>
|
||||
{
|
||||
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]}
|
||||
</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
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
|
||||
Reference in New Issue
Block a user