Compare commits

..

2 Commits

Author SHA1 Message Date
0c29b67e22 Merge remote-tracking branch 'origin/main' into refactor/configuration 2026-01-27 11:43:36 +08:00
c080c48aba refactor(debug): extract hooks and components, add comprehensive tests
Extract reusable hooks and components from debug/index.tsx:
- useInputValidation, useFormattingChangeConfirm, useModalWidth hooks
- useTextCompletion hook for text completion logic
- DebugHeader component for header UI
- TextCompletionResult component for completion display

Add comprehensive test coverage for debug-with-multiple-model:
- chat-item.spec.tsx (23 tests)
- debug-item.spec.tsx (25 tests)
- model-parameter-trigger.spec.tsx (14 tests)
- text-generation-item.spec.tsx (16 tests)
- index.spec.tsx expanded (84 tests)

Total: 183 tests passing with 95%+ coverage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:42:09 +08:00
186 changed files with 6200 additions and 45011 deletions

View File

@ -8,7 +8,7 @@ intercept and respond to GraphEngine events.
from abc import ABC, abstractmethod
from core.workflow.graph_engine.protocols.command_channel import CommandChannel
from core.workflow.graph_events import GraphEngineEvent, GraphNodeEventBase
from core.workflow.graph_events import GraphEngineEvent
from core.workflow.nodes.base.node import Node
from core.workflow.runtime import ReadOnlyGraphRuntimeState
@ -98,7 +98,7 @@ class GraphEngineLayer(ABC):
"""
pass
def on_node_run_start(self, node: Node) -> None:
def on_node_run_start(self, node: Node) -> None: # noqa: B027
"""
Called immediately before a node begins execution.
@ -109,11 +109,9 @@ class GraphEngineLayer(ABC):
Args:
node: The node instance about to be executed
"""
return
pass
def on_node_run_end(
self, node: Node, error: Exception | None, result_event: GraphNodeEventBase | None = None
) -> None:
def on_node_run_end(self, node: Node, error: Exception | None) -> None: # noqa: B027
"""
Called after a node finishes execution.
@ -123,6 +121,5 @@ class GraphEngineLayer(ABC):
Args:
node: The node instance that just finished execution
error: Exception instance if the node failed, otherwise None
result_event: The final result event from node execution (succeeded/failed/paused), if any
"""
return
pass

View File

@ -0,0 +1,61 @@
"""
Node-level OpenTelemetry parser interfaces and defaults.
"""
import json
from typing import Protocol
from opentelemetry.trace import Span
from opentelemetry.trace.status import Status, StatusCode
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.tool.entities import ToolNodeData
class NodeOTelParser(Protocol):
"""Parser interface for node-specific OpenTelemetry enrichment."""
def parse(self, *, node: Node, span: "Span", error: Exception | None) -> None: ...
class DefaultNodeOTelParser:
"""Fallback parser used when no node-specific parser is registered."""
def parse(self, *, node: Node, span: "Span", error: Exception | None) -> None:
span.set_attribute("node.id", node.id)
if node.execution_id:
span.set_attribute("node.execution_id", node.execution_id)
if hasattr(node, "node_type") and node.node_type:
span.set_attribute("node.type", node.node_type.value)
if error:
span.record_exception(error)
span.set_status(Status(StatusCode.ERROR, str(error)))
else:
span.set_status(Status(StatusCode.OK))
class ToolNodeOTelParser:
"""Parser for tool nodes that captures tool-specific metadata."""
def __init__(self) -> None:
self._delegate = DefaultNodeOTelParser()
def parse(self, *, node: Node, span: "Span", error: Exception | None) -> None:
self._delegate.parse(node=node, span=span, error=error)
tool_data = getattr(node, "_node_data", None)
if not isinstance(tool_data, ToolNodeData):
return
span.set_attribute("tool.provider.id", tool_data.provider_id)
span.set_attribute("tool.provider.type", tool_data.provider_type.value)
span.set_attribute("tool.provider.name", tool_data.provider_name)
span.set_attribute("tool.name", tool_data.tool_name)
span.set_attribute("tool.label", tool_data.tool_label)
if tool_data.plugin_unique_identifier:
span.set_attribute("tool.plugin.id", tool_data.plugin_unique_identifier)
if tool_data.credential_id:
span.set_attribute("tool.credential.id", tool_data.credential_id)
if tool_data.tool_configurations:
span.set_attribute("tool.config", json.dumps(tool_data.tool_configurations, ensure_ascii=False))

View File

@ -18,15 +18,12 @@ from typing_extensions import override
from configs import dify_config
from core.workflow.enums import NodeType
from core.workflow.graph_engine.layers.base import GraphEngineLayer
from core.workflow.graph_events import GraphNodeEventBase
from core.workflow.nodes.base.node import Node
from extensions.otel.parser import (
from core.workflow.graph_engine.layers.node_parsers import (
DefaultNodeOTelParser,
LLMNodeOTelParser,
NodeOTelParser,
RetrievalNodeOTelParser,
ToolNodeOTelParser,
)
from core.workflow.nodes.base.node import Node
from extensions.otel.runtime import is_instrument_flag_enabled
logger = logging.getLogger(__name__)
@ -75,8 +72,6 @@ class ObservabilityLayer(GraphEngineLayer):
"""Initialize parser registry for node types."""
self._parsers = {
NodeType.TOOL: ToolNodeOTelParser(),
NodeType.LLM: LLMNodeOTelParser(),
NodeType.KNOWLEDGE_RETRIEVAL: RetrievalNodeOTelParser(),
}
def _get_parser(self, node: Node) -> NodeOTelParser:
@ -124,9 +119,7 @@ class ObservabilityLayer(GraphEngineLayer):
logger.warning("Failed to create OpenTelemetry span for node %s: %s", node.id, e)
@override
def on_node_run_end(
self, node: Node, error: Exception | None, result_event: GraphNodeEventBase | None = None
) -> None:
def on_node_run_end(self, node: Node, error: Exception | None) -> None:
"""
Called when a node finishes execution.
@ -146,7 +139,7 @@ class ObservabilityLayer(GraphEngineLayer):
span = node_context.span
parser = self._get_parser(node)
try:
parser.parse(node=node, span=span, error=error, result_event=result_event)
parser.parse(node=node, span=span, error=error)
span.end()
finally:
token = node_context.token

View File

@ -17,7 +17,7 @@ from typing_extensions import override
from core.workflow.context import IExecutionContext
from core.workflow.graph import Graph
from core.workflow.graph_engine.layers.base import GraphEngineLayer
from core.workflow.graph_events import GraphNodeEventBase, NodeRunFailedEvent, is_node_result_event
from core.workflow.graph_events import GraphNodeEventBase, NodeRunFailedEvent
from core.workflow.nodes.base.node import Node
from .ready_queue import ReadyQueue
@ -131,7 +131,6 @@ class Worker(threading.Thread):
node.ensure_execution_id()
error: Exception | None = None
result_event: GraphNodeEventBase | None = None
# Execute the node with preserved context if execution context is provided
if self._execution_context is not None:
@ -141,26 +140,22 @@ class Worker(threading.Thread):
node_events = node.run()
for event in node_events:
self._event_queue.put(event)
if is_node_result_event(event):
result_event = event
except Exception as exc:
error = exc
raise
finally:
self._invoke_node_run_end_hooks(node, error, result_event)
self._invoke_node_run_end_hooks(node, error)
else:
self._invoke_node_run_start_hooks(node)
try:
node_events = node.run()
for event in node_events:
self._event_queue.put(event)
if is_node_result_event(event):
result_event = event
except Exception as exc:
error = exc
raise
finally:
self._invoke_node_run_end_hooks(node, error, result_event)
self._invoke_node_run_end_hooks(node, error)
def _invoke_node_run_start_hooks(self, node: Node) -> None:
"""Invoke on_node_run_start hooks for all layers."""
@ -171,13 +166,11 @@ class Worker(threading.Thread):
# Silently ignore layer errors to prevent disrupting node execution
continue
def _invoke_node_run_end_hooks(
self, node: Node, error: Exception | None, result_event: GraphNodeEventBase | None = None
) -> None:
def _invoke_node_run_end_hooks(self, node: Node, error: Exception | None) -> None:
"""Invoke on_node_run_end hooks for all layers."""
for layer in self._layers:
try:
layer.on_node_run_end(node, error, result_event)
layer.on_node_run_end(node, error)
except Exception:
# Silently ignore layer errors to prevent disrupting node execution
continue

View File

@ -44,7 +44,6 @@ from .node import (
NodeRunStartedEvent,
NodeRunStreamChunkEvent,
NodeRunSucceededEvent,
is_node_result_event,
)
__all__ = [
@ -74,5 +73,4 @@ __all__ = [
"NodeRunStartedEvent",
"NodeRunStreamChunkEvent",
"NodeRunSucceededEvent",
"is_node_result_event",
]

View File

@ -56,26 +56,3 @@ class NodeRunRetryEvent(NodeRunStartedEvent):
class NodeRunPauseRequestedEvent(GraphNodeEventBase):
reason: PauseReason = Field(..., description="pause reason")
def is_node_result_event(event: GraphNodeEventBase) -> bool:
"""
Check if an event is a final result event from node execution.
A result event indicates the completion of a node execution and contains
runtime information such as inputs, outputs, or error details.
Args:
event: The event to check
Returns:
True if the event is a node result event (succeeded/failed/paused), False otherwise
"""
return isinstance(
event,
(
NodeRunSucceededEvent,
NodeRunFailedEvent,
NodeRunPauseRequestedEvent,
),
)

View File

@ -1,20 +0,0 @@
"""
OpenTelemetry node parsers for workflow nodes.
This module provides parsers that extract node-specific metadata and set
OpenTelemetry span attributes according to semantic conventions.
"""
from extensions.otel.parser.base import DefaultNodeOTelParser, NodeOTelParser, safe_json_dumps
from extensions.otel.parser.llm import LLMNodeOTelParser
from extensions.otel.parser.retrieval import RetrievalNodeOTelParser
from extensions.otel.parser.tool import ToolNodeOTelParser
__all__ = [
"DefaultNodeOTelParser",
"LLMNodeOTelParser",
"NodeOTelParser",
"RetrievalNodeOTelParser",
"ToolNodeOTelParser",
"safe_json_dumps",
]

View File

@ -1,117 +0,0 @@
"""
Base parser interface and utilities for OpenTelemetry node parsers.
"""
import json
from typing import Any, Protocol
from opentelemetry.trace import Span
from opentelemetry.trace.status import Status, StatusCode
from pydantic import BaseModel
from core.file.models import File
from core.variables import Segment
from core.workflow.enums import NodeType
from core.workflow.graph_events import GraphNodeEventBase
from core.workflow.nodes.base.node import Node
from extensions.otel.semconv.gen_ai import ChainAttributes, GenAIAttributes
def safe_json_dumps(obj: Any, ensure_ascii: bool = False) -> str:
"""
Safely serialize objects to JSON, handling non-serializable types.
Handles:
- Segment types (ArrayFileSegment, FileSegment, etc.) - converts to their value
- File objects - converts to dict using to_dict()
- BaseModel objects - converts using model_dump()
- Other types - falls back to str() representation
Args:
obj: Object to serialize
ensure_ascii: Whether to ensure ASCII encoding
Returns:
JSON string representation of the object
"""
def _convert_value(value: Any) -> Any:
"""Recursively convert non-serializable values."""
if value is None:
return None
if isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, Segment):
# Convert Segment to its underlying value
return _convert_value(value.value)
if isinstance(value, File):
# Convert File to dict
return value.to_dict()
if isinstance(value, BaseModel):
# Convert Pydantic model to dict
return _convert_value(value.model_dump(mode="json"))
if isinstance(value, dict):
return {k: _convert_value(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_convert_value(item) for item in value]
# Fallback to string representation for unknown types
return str(value)
try:
converted = _convert_value(obj)
return json.dumps(converted, ensure_ascii=ensure_ascii)
except (TypeError, ValueError) as e:
# If conversion still fails, return error message as string
return json.dumps(
{"error": f"Failed to serialize: {type(obj).__name__}", "message": str(e)}, ensure_ascii=ensure_ascii
)
class NodeOTelParser(Protocol):
"""Parser interface for node-specific OpenTelemetry enrichment."""
def parse(
self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None
) -> None: ...
class DefaultNodeOTelParser:
"""Fallback parser used when no node-specific parser is registered."""
def parse(
self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None
) -> None:
span.set_attribute("node.id", node.id)
if node.execution_id:
span.set_attribute("node.execution_id", node.execution_id)
if hasattr(node, "node_type") and node.node_type:
span.set_attribute("node.type", node.node_type.value)
span.set_attribute(GenAIAttributes.FRAMEWORK, "dify")
node_type = getattr(node, "node_type", None)
if isinstance(node_type, NodeType):
if node_type == NodeType.LLM:
span.set_attribute(GenAIAttributes.SPAN_KIND, "LLM")
elif node_type == NodeType.KNOWLEDGE_RETRIEVAL:
span.set_attribute(GenAIAttributes.SPAN_KIND, "RETRIEVER")
elif node_type == NodeType.TOOL:
span.set_attribute(GenAIAttributes.SPAN_KIND, "TOOL")
else:
span.set_attribute(GenAIAttributes.SPAN_KIND, "TASK")
else:
span.set_attribute(GenAIAttributes.SPAN_KIND, "TASK")
# Extract inputs and outputs from result_event
if result_event and result_event.node_run_result:
node_run_result = result_event.node_run_result
if node_run_result.inputs:
span.set_attribute(ChainAttributes.INPUT_VALUE, safe_json_dumps(node_run_result.inputs))
if node_run_result.outputs:
span.set_attribute(ChainAttributes.OUTPUT_VALUE, safe_json_dumps(node_run_result.outputs))
if error:
span.record_exception(error)
span.set_status(Status(StatusCode.ERROR, str(error)))
else:
span.set_status(Status(StatusCode.OK))

View File

@ -1,155 +0,0 @@
"""
Parser for LLM nodes that captures LLM-specific metadata.
"""
import logging
from collections.abc import Mapping
from typing import Any
from opentelemetry.trace import Span
from core.workflow.graph_events import GraphNodeEventBase
from core.workflow.nodes.base.node import Node
from extensions.otel.parser.base import DefaultNodeOTelParser, safe_json_dumps
from extensions.otel.semconv.gen_ai import LLMAttributes
logger = logging.getLogger(__name__)
def _format_input_messages(process_data: Mapping[str, Any]) -> str:
"""
Format input messages from process_data for LLM spans.
Args:
process_data: Process data containing prompts
Returns:
JSON string of formatted input messages
"""
try:
if not isinstance(process_data, dict):
return safe_json_dumps([])
prompts = process_data.get("prompts", [])
if not prompts:
return safe_json_dumps([])
valid_roles = {"system", "user", "assistant", "tool"}
input_messages = []
for prompt in prompts:
if not isinstance(prompt, dict):
continue
role = prompt.get("role", "")
text = prompt.get("text", "")
if not role or role not in valid_roles:
continue
if text:
message = {"role": role, "parts": [{"type": "text", "content": text}]}
input_messages.append(message)
return safe_json_dumps(input_messages)
except Exception as e:
logger.warning("Failed to format input messages: %s", e, exc_info=True)
return safe_json_dumps([])
def _format_output_messages(outputs: Mapping[str, Any]) -> str:
"""
Format output messages from outputs for LLM spans.
Args:
outputs: Output data containing text and finish_reason
Returns:
JSON string of formatted output messages
"""
try:
if not isinstance(outputs, dict):
return safe_json_dumps([])
text = outputs.get("text", "")
finish_reason = outputs.get("finish_reason", "")
if not text:
return safe_json_dumps([])
valid_finish_reasons = {"stop", "length", "content_filter", "tool_call", "error"}
if finish_reason not in valid_finish_reasons:
finish_reason = "stop"
output_message = {
"role": "assistant",
"parts": [{"type": "text", "content": text}],
"finish_reason": finish_reason,
}
return safe_json_dumps([output_message])
except Exception as e:
logger.warning("Failed to format output messages: %s", e, exc_info=True)
return safe_json_dumps([])
class LLMNodeOTelParser:
"""Parser for LLM nodes that captures LLM-specific metadata."""
def __init__(self) -> None:
self._delegate = DefaultNodeOTelParser()
def parse(
self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None
) -> None:
self._delegate.parse(node=node, span=span, error=error, result_event=result_event)
if not result_event or not result_event.node_run_result:
return
node_run_result = result_event.node_run_result
process_data = node_run_result.process_data or {}
outputs = node_run_result.outputs or {}
# Extract usage data (from process_data or outputs)
usage_data = process_data.get("usage") or outputs.get("usage") or {}
# Model and provider information
model_name = process_data.get("model_name") or ""
model_provider = process_data.get("model_provider") or ""
if model_name:
span.set_attribute(LLMAttributes.REQUEST_MODEL, model_name)
if model_provider:
span.set_attribute(LLMAttributes.PROVIDER_NAME, model_provider)
# Token usage
if usage_data:
prompt_tokens = usage_data.get("prompt_tokens", 0)
completion_tokens = usage_data.get("completion_tokens", 0)
total_tokens = usage_data.get("total_tokens", 0)
span.set_attribute(LLMAttributes.USAGE_INPUT_TOKENS, prompt_tokens)
span.set_attribute(LLMAttributes.USAGE_OUTPUT_TOKENS, completion_tokens)
span.set_attribute(LLMAttributes.USAGE_TOTAL_TOKENS, total_tokens)
# Prompts and completion
prompts = process_data.get("prompts", [])
if prompts:
prompts_json = safe_json_dumps(prompts)
span.set_attribute(LLMAttributes.PROMPT, prompts_json)
text_output = str(outputs.get("text", ""))
if text_output:
span.set_attribute(LLMAttributes.COMPLETION, text_output)
# Finish reason
finish_reason = outputs.get("finish_reason") or ""
if finish_reason:
span.set_attribute(LLMAttributes.RESPONSE_FINISH_REASON, finish_reason)
# Structured input/output messages
gen_ai_input_message = _format_input_messages(process_data)
gen_ai_output_message = _format_output_messages(outputs)
span.set_attribute(LLMAttributes.INPUT_MESSAGE, gen_ai_input_message)
span.set_attribute(LLMAttributes.OUTPUT_MESSAGE, gen_ai_output_message)

View File

@ -1,105 +0,0 @@
"""
Parser for knowledge retrieval nodes that captures retrieval-specific metadata.
"""
import logging
from collections.abc import Sequence
from typing import Any
from opentelemetry.trace import Span
from core.variables import Segment
from core.workflow.graph_events import GraphNodeEventBase
from core.workflow.nodes.base.node import Node
from extensions.otel.parser.base import DefaultNodeOTelParser, safe_json_dumps
from extensions.otel.semconv.gen_ai import RetrieverAttributes
logger = logging.getLogger(__name__)
def _format_retrieval_documents(retrieval_documents: list[Any]) -> list:
"""
Format retrieval documents for semantic conventions.
Args:
retrieval_documents: List of retrieval document dictionaries
Returns:
List of formatted semantic documents
"""
try:
if not isinstance(retrieval_documents, list):
return []
semantic_documents = []
for doc in retrieval_documents:
if not isinstance(doc, dict):
continue
metadata = doc.get("metadata", {})
content = doc.get("content", "")
title = doc.get("title", "")
score = metadata.get("score", 0.0)
document_id = metadata.get("document_id", "")
semantic_metadata = {}
if title:
semantic_metadata["title"] = title
if metadata.get("source"):
semantic_metadata["source"] = metadata["source"]
elif metadata.get("_source"):
semantic_metadata["source"] = metadata["_source"]
if metadata.get("doc_metadata"):
doc_metadata = metadata["doc_metadata"]
if isinstance(doc_metadata, dict):
semantic_metadata.update(doc_metadata)
semantic_doc = {
"document": {"content": content, "metadata": semantic_metadata, "score": score, "id": document_id}
}
semantic_documents.append(semantic_doc)
return semantic_documents
except Exception as e:
logger.warning("Failed to format retrieval documents: %s", e, exc_info=True)
return []
class RetrievalNodeOTelParser:
"""Parser for knowledge retrieval nodes that captures retrieval-specific metadata."""
def __init__(self) -> None:
self._delegate = DefaultNodeOTelParser()
def parse(
self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None
) -> None:
self._delegate.parse(node=node, span=span, error=error, result_event=result_event)
if not result_event or not result_event.node_run_result:
return
node_run_result = result_event.node_run_result
inputs = node_run_result.inputs or {}
outputs = node_run_result.outputs or {}
# Extract query from inputs
query = str(inputs.get("query", "")) if inputs else ""
if query:
span.set_attribute(RetrieverAttributes.QUERY, query)
# Extract and format retrieval documents from outputs
result_value = outputs.get("result") if outputs else None
retrieval_documents: list[Any] = []
if result_value:
value_to_check = result_value
if isinstance(result_value, Segment):
value_to_check = result_value.value
if isinstance(value_to_check, (list, Sequence)):
retrieval_documents = list(value_to_check)
if retrieval_documents:
semantic_retrieval_documents = _format_retrieval_documents(retrieval_documents)
semantic_retrieval_documents_json = safe_json_dumps(semantic_retrieval_documents)
span.set_attribute(RetrieverAttributes.DOCUMENT, semantic_retrieval_documents_json)

View File

@ -1,47 +0,0 @@
"""
Parser for tool nodes that captures tool-specific metadata.
"""
from opentelemetry.trace import Span
from core.workflow.enums import WorkflowNodeExecutionMetadataKey
from core.workflow.graph_events import GraphNodeEventBase
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.tool.entities import ToolNodeData
from extensions.otel.parser.base import DefaultNodeOTelParser, safe_json_dumps
from extensions.otel.semconv.gen_ai import ToolAttributes
class ToolNodeOTelParser:
"""Parser for tool nodes that captures tool-specific metadata."""
def __init__(self) -> None:
self._delegate = DefaultNodeOTelParser()
def parse(
self, *, node: Node, span: "Span", error: Exception | None, result_event: GraphNodeEventBase | None = None
) -> None:
self._delegate.parse(node=node, span=span, error=error, result_event=result_event)
tool_data = getattr(node, "_node_data", None)
if not isinstance(tool_data, ToolNodeData):
return
span.set_attribute(ToolAttributes.TOOL_NAME, node.title)
span.set_attribute(ToolAttributes.TOOL_TYPE, tool_data.provider_type.value)
# Extract tool info from metadata (consistent with aliyun_trace)
tool_info = {}
if result_event and result_event.node_run_result:
node_run_result = result_event.node_run_result
if node_run_result.metadata:
tool_info = node_run_result.metadata.get(WorkflowNodeExecutionMetadataKey.TOOL_INFO, {})
if tool_info:
span.set_attribute(ToolAttributes.TOOL_DESCRIPTION, safe_json_dumps(tool_info))
if result_event and result_event.node_run_result and result_event.node_run_result.inputs:
span.set_attribute(ToolAttributes.TOOL_CALL_ARGUMENTS, safe_json_dumps(result_event.node_run_result.inputs))
if result_event and result_event.node_run_result and result_event.node_run_result.outputs:
span.set_attribute(ToolAttributes.TOOL_CALL_RESULT, safe_json_dumps(result_event.node_run_result.outputs))

View File

@ -1,13 +1,6 @@
"""Semantic convention shortcuts for Dify-specific spans."""
from .dify import DifySpanAttributes
from .gen_ai import ChainAttributes, GenAIAttributes, LLMAttributes, RetrieverAttributes, ToolAttributes
from .gen_ai import GenAIAttributes
__all__ = [
"ChainAttributes",
"DifySpanAttributes",
"GenAIAttributes",
"LLMAttributes",
"RetrieverAttributes",
"ToolAttributes",
]
__all__ = ["DifySpanAttributes", "GenAIAttributes"]

View File

@ -62,37 +62,3 @@ class ToolAttributes:
TOOL_CALL_RESULT = "gen_ai.tool.call.result"
"""Tool invocation result."""
class LLMAttributes:
"""LLM operation attribute keys."""
REQUEST_MODEL = "gen_ai.request.model"
"""Model identifier."""
PROVIDER_NAME = "gen_ai.provider.name"
"""Provider name."""
USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
"""Number of input tokens."""
USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
"""Number of output tokens."""
USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens"
"""Total number of tokens."""
PROMPT = "gen_ai.prompt"
"""Prompt text."""
COMPLETION = "gen_ai.completion"
"""Completion text."""
RESPONSE_FINISH_REASON = "gen_ai.response.finish_reason"
"""Finish reason for the response."""
INPUT_MESSAGE = "gen_ai.input.messages"
"""Input messages in structured format."""
OUTPUT_MESSAGE = "gen_ai.output.messages"
"""Output messages in structured format."""

View File

@ -99,38 +99,3 @@ def mock_is_instrument_flag_enabled_true():
"""Mock is_instrument_flag_enabled to return True."""
with patch("core.workflow.graph_engine.layers.observability.is_instrument_flag_enabled", return_value=True):
yield
@pytest.fixture
def mock_retrieval_node():
"""Create a mock Knowledge Retrieval Node."""
node = MagicMock()
node.id = "test-retrieval-node-id"
node.title = "Retrieval Node"
node.execution_id = "test-retrieval-execution-id"
node.node_type = NodeType.KNOWLEDGE_RETRIEVAL
return node
@pytest.fixture
def mock_result_event():
"""Create a mock result event with NodeRunResult."""
from datetime import datetime
from core.workflow.graph_events.node import NodeRunSucceededEvent
from core.workflow.node_events.base import NodeRunResult
node_run_result = NodeRunResult(
inputs={"query": "test query"},
outputs={"result": [{"content": "test content", "metadata": {}}]},
process_data={},
metadata={},
)
return NodeRunSucceededEvent(
id="test-execution-id",
node_id="test-node-id",
node_type=NodeType.LLM,
start_at=datetime.now(),
node_run_result=node_run_result,
)

View File

@ -4,8 +4,7 @@ Tests for ObservabilityLayer.
Test coverage:
- Initialization and enable/disable logic
- Node span lifecycle (start, end, error handling)
- Parser integration (default, tool, LLM, and retrieval parsers)
- Result event parameter extraction (inputs/outputs)
- Parser integration (default and tool-specific)
- Graph lifecycle management
- Disabled mode behavior
"""
@ -135,101 +134,9 @@ class TestObservabilityLayerParserIntegration:
assert len(spans) == 1
attrs = spans[0].attributes
assert attrs["node.id"] == mock_tool_node.id
assert attrs["gen_ai.tool.name"] == mock_tool_node.title
assert attrs["gen_ai.tool.type"] == mock_tool_node._node_data.provider_type.value
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_llm_parser_used_for_llm_node(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_llm_node, mock_result_event
):
"""Test that LLM parser is used for LLM nodes and extracts LLM-specific attributes."""
from core.workflow.node_events.base import NodeRunResult
mock_result_event.node_run_result = NodeRunResult(
inputs={},
outputs={"text": "test completion", "finish_reason": "stop"},
process_data={
"model_name": "gpt-4",
"model_provider": "openai",
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
"prompts": [{"role": "user", "text": "test prompt"}],
},
metadata={},
)
layer = ObservabilityLayer()
layer.on_graph_start()
layer.on_node_run_start(mock_llm_node)
layer.on_node_run_end(mock_llm_node, None, mock_result_event)
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
attrs = spans[0].attributes
assert attrs["node.id"] == mock_llm_node.id
assert attrs["gen_ai.request.model"] == "gpt-4"
assert attrs["gen_ai.provider.name"] == "openai"
assert attrs["gen_ai.usage.input_tokens"] == 10
assert attrs["gen_ai.usage.output_tokens"] == 20
assert attrs["gen_ai.usage.total_tokens"] == 30
assert attrs["gen_ai.completion"] == "test completion"
assert attrs["gen_ai.response.finish_reason"] == "stop"
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_retrieval_parser_used_for_retrieval_node(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_retrieval_node, mock_result_event
):
"""Test that retrieval parser is used for retrieval nodes and extracts retrieval-specific attributes."""
from core.workflow.node_events.base import NodeRunResult
mock_result_event.node_run_result = NodeRunResult(
inputs={"query": "test query"},
outputs={"result": [{"content": "test content", "metadata": {"score": 0.9, "document_id": "doc1"}}]},
process_data={},
metadata={},
)
layer = ObservabilityLayer()
layer.on_graph_start()
layer.on_node_run_start(mock_retrieval_node)
layer.on_node_run_end(mock_retrieval_node, None, mock_result_event)
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
attrs = spans[0].attributes
assert attrs["node.id"] == mock_retrieval_node.id
assert attrs["retrieval.query"] == "test query"
assert "retrieval.document" in attrs
@patch("core.workflow.graph_engine.layers.observability.dify_config.ENABLE_OTEL", True)
@pytest.mark.usefixtures("mock_is_instrument_flag_enabled_false")
def test_result_event_extracts_inputs_and_outputs(
self, tracer_provider_with_memory_exporter, memory_span_exporter, mock_start_node, mock_result_event
):
"""Test that result_event parameter allows parsers to extract inputs and outputs."""
from core.workflow.node_events.base import NodeRunResult
mock_result_event.node_run_result = NodeRunResult(
inputs={"input_key": "input_value"},
outputs={"output_key": "output_value"},
process_data={},
metadata={},
)
layer = ObservabilityLayer()
layer.on_graph_start()
layer.on_node_run_start(mock_start_node)
layer.on_node_run_end(mock_start_node, None, mock_result_event)
spans = memory_span_exporter.get_finished_spans()
assert len(spans) == 1
attrs = spans[0].attributes
assert "input.value" in attrs
assert "output.value" in attrs
assert attrs["tool.provider.id"] == mock_tool_node._node_data.provider_id
assert attrs["tool.provider.type"] == mock_tool_node._node_data.provider_type.value
assert attrs["tool.name"] == mock_tool_node._node_data.tool_name
class TestObservabilityLayerGraphLifecycle:

View File

@ -0,0 +1,91 @@
'use client'
import type { FC } from 'react'
import type { ModelAndParameter } from './types'
import {
RiAddLine,
RiEqualizer2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
import TooltipPlus from '@/app/components/base/tooltip'
import { AppModeEnum } from '@/types/app'
type DebugHeaderProps = {
readonly?: boolean
mode: AppModeEnum
debugWithMultipleModel: boolean
multipleModelConfigs: ModelAndParameter[]
varListLength: number
expanded: boolean
onExpandedChange: (expanded: boolean) => void
onClearConversation: () => void
onAddModel: () => void
}
const DebugHeader: FC<DebugHeaderProps> = ({
readonly,
mode,
debugWithMultipleModel,
multipleModelConfigs,
varListLength,
expanded,
onExpandedChange,
onClearConversation,
onAddModel,
}) => {
const { t } = useTranslation()
return (
<div className="flex items-center justify-between px-4 pb-2 pt-3">
<div className="system-xl-semibold text-text-primary">{t('inputs.title', { ns: 'appDebug' })}</div>
<div className="flex items-center">
{debugWithMultipleModel && (
<>
<Button
variant="ghost-accent"
onClick={onAddModel}
disabled={multipleModelConfigs.length >= 4}
>
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('modelProvider.addModel', { ns: 'common' })}
(
{multipleModelConfigs.length}
/4)
</Button>
<div className="mx-2 h-[14px] w-[1px] bg-divider-regular" />
</>
)}
{mode !== AppModeEnum.COMPLETION && (
<>
{!readonly && (
<TooltipPlus popupContent={t('operation.refresh', { ns: 'common' })}>
<ActionButton onClick={onClearConversation}>
<RefreshCcw01 className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
)}
{varListLength > 0 && (
<div className="relative ml-1 mr-2">
<TooltipPlus popupContent={t('panel.userInputField', { ns: 'workflow' })}>
<ActionButton
state={expanded ? ActionButtonState.Active : undefined}
onClick={() => !readonly && onExpandedChange(!expanded)}
>
<RiEqualizer2Line className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
{expanded && (
<div className="absolute bottom-[-14px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />
)}
</div>
)}
</>
)}
</div>
</div>
)
}
export default DebugHeader

View File

@ -0,0 +1,737 @@
import type { ModelAndParameter } from '../types'
import type { ChatConfig } from '@/app/components/base/chat/types'
import { render, screen, waitFor } from '@testing-library/react'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { ModelModeType } from '@/types/app'
import { APP_CHAT_WITH_MULTIPLE_MODEL, APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } from '../types'
import ChatItem from './chat-item'
const mockUseAppContext = vi.fn()
const mockUseDebugConfigurationContext = vi.fn()
const mockUseProviderContext = vi.fn()
const mockUseFeatures = vi.fn()
const mockUseConfigFromDebugContext = vi.fn()
const mockUseFormattingChangedSubscription = vi.fn()
const mockUseChat = vi.fn()
const mockUseEventEmitterContextContext = vi.fn()
vi.mock('@/context/app-context', () => ({
useAppContext: () => mockUseAppContext(),
}))
vi.mock('@/context/debug-configuration', () => ({
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: unknown) => unknown) => mockUseFeatures(selector),
}))
vi.mock('../hooks', () => ({
useConfigFromDebugContext: () => mockUseConfigFromDebugContext(),
useFormattingChangedSubscription: (chatList: unknown) => mockUseFormattingChangedSubscription(chatList),
}))
vi.mock('@/app/components/base/chat/chat/hooks', () => ({
useChat: (...args: unknown[]) => mockUseChat(...args),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => mockUseEventEmitterContextContext(),
}))
const mockStopChatMessageResponding = vi.fn()
const mockFetchConversationMessages = vi.fn()
const mockFetchSuggestedQuestions = vi.fn()
vi.mock('@/service/debug', () => ({
fetchConversationMessages: (...args: unknown[]) => mockFetchConversationMessages(...args),
fetchSuggestedQuestions: (...args: unknown[]) => mockFetchSuggestedQuestions(...args),
stopChatMessageResponding: (...args: unknown[]) => mockStopChatMessageResponding(...args),
}))
vi.mock('@/utils', () => ({
canFindTool: (collectionId: string, providerId: string) => collectionId === providerId,
}))
vi.mock('@/app/components/base/chat/utils', () => ({
getLastAnswer: (chatList: { id: string }[]) => chatList.length > 0 ? chatList[chatList.length - 1] : null,
}))
let capturedChatProps: Record<string, unknown> | null = null
vi.mock('@/app/components/base/chat/chat', () => ({
default: (props: Record<string, unknown>) => {
capturedChatProps = props
return <div data-testid="chat-component">Chat</div>
},
}))
vi.mock('@/app/components/base/avatar', () => ({
default: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
}))
let modelIdCounter = 0
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
id: `model-${++modelIdCounter}`,
model: 'gpt-3.5-turbo',
provider: 'openai',
parameters: { temperature: 0.7 },
...overrides,
})
const createDefaultModelConfig = () => ({
provider: 'openai',
model_id: 'gpt-4',
mode: ModelModeType.chat,
configs: {
prompt_template: 'Hello {{name}}',
prompt_variables: [
{ key: 'name', name: 'Name', type: 'string' as const },
{ key: 'api-var', name: 'API Var', type: 'api' as const },
],
},
chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG,
completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG,
opening_statement: '',
more_like_this: null,
suggested_questions: [],
suggested_questions_after_answer: null,
speech_to_text: null,
text_to_speech: null,
file_upload: null,
retriever_resource: null,
sensitive_word_avoidance: null,
annotation_reply: null,
external_data_tools: [],
dataSets: [],
agentConfig: DEFAULT_AGENT_SETTING,
system_parameters: {
audio_file_size_limit: 0,
file_size_limit: 0,
image_file_size_limit: 0,
video_file_size_limit: 0,
workflow_file_upload_limit: 0,
},
})
const createDefaultFeatures = () => ({
moreLikeThis: { enabled: false },
opening: { enabled: true, opening_statement: 'Hello', suggested_questions: ['Q1'] },
moderation: { enabled: false },
speech2text: { enabled: true },
text2speech: { enabled: false },
file: { enabled: true, image: { enabled: true } },
suggested: { enabled: true },
citation: { enabled: false },
annotationReply: { enabled: false },
})
const createTextGenerationModelList = (models: Array<{
provider: string
model: string
features?: string[]
mode?: string
}> = []) => {
const providerMap = new Map<string, { model: string, features: string[], model_properties: { mode: string } }[]>()
for (const m of models) {
if (!providerMap.has(m.provider)) {
providerMap.set(m.provider, [])
}
providerMap.get(m.provider)!.push({
model: m.model,
features: m.features ?? [],
model_properties: { mode: m.mode ?? 'chat' },
})
}
return Array.from(providerMap.entries()).map(([provider, modelsList]) => ({
provider,
models: modelsList,
}))
}
describe('ChatItem', () => {
let subscriptionCallback: ((v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) | null = null
beforeEach(() => {
vi.clearAllMocks()
modelIdCounter = 0
capturedChatProps = null
subscriptionCallback = null
mockUseAppContext.mockReturnValue({
userProfile: { avatar_url: 'avatar.png', name: 'Test User' },
})
mockUseDebugConfigurationContext.mockReturnValue({
modelConfig: createDefaultModelConfig(),
appId: 'test-app-id',
inputs: { name: 'World' },
collectionList: [],
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-3.5-turbo', features: [ModelFeatureEnum.vision], mode: 'chat' },
{ provider: 'openai', model: 'gpt-4', features: [], mode: 'chat' },
]),
})
const features = createDefaultFeatures()
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
mockUseConfigFromDebugContext.mockReturnValue({
baseConfig: true,
})
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1', content: 'Hello' }],
isResponding: false,
handleSend: vi.fn(),
suggestedQuestions: [],
handleRestart: vi.fn(),
})
mockUseEventEmitterContextContext.mockReturnValue({
eventEmitter: {
// eslint-disable-next-line react/no-unnecessary-use-prefix -- mocking real API
useSubscription: (callback: (v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) => {
subscriptionCallback = callback
},
},
})
})
describe('rendering', () => {
it('should render Chat component when chatList is not empty', () => {
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
it('should return null when chatList is empty', () => {
mockUseChat.mockReturnValue({
chatList: [],
isResponding: false,
handleSend: vi.fn(),
suggestedQuestions: [],
handleRestart: vi.fn(),
})
const modelAndParameter = createModelAndParameter()
const { container } = render(<ChatItem modelAndParameter={modelAndParameter} />)
expect(container.firstChild).toBeNull()
})
it('should pass correct props to Chat component', () => {
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
expect(capturedChatProps!.noChatInput).toBe(true)
expect(capturedChatProps!.noStopResponding).toBe(true)
expect(capturedChatProps!.showPromptLog).toBe(true)
expect(capturedChatProps!.hideLogModal).toBe(true)
expect(capturedChatProps!.noSpacing).toBe(true)
expect(capturedChatProps!.chatContainerClassName).toBe('p-4')
expect(capturedChatProps!.chatFooterClassName).toBe('p-4 pb-0')
})
})
describe('config building', () => {
it('should merge configTemplate with features', () => {
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
const config = capturedChatProps!.config as ChatConfig & { baseConfig?: boolean }
expect(config.baseConfig).toBe(true)
expect(config.more_like_this).toEqual({ enabled: false })
expect(config.opening_statement).toBe('Hello')
expect(config.suggested_questions).toEqual(['Q1'])
expect(config.speech_to_text).toEqual({ enabled: true })
expect(config.file_upload).toEqual({ enabled: true, image: { enabled: true } })
})
it('should use empty opening_statement when opening is disabled', () => {
const features = createDefaultFeatures()
features.opening = { enabled: false, opening_statement: 'Hello', suggested_questions: ['Q1'] }
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
const config = capturedChatProps!.config as ChatConfig
expect(config.opening_statement).toBe('')
expect(config.suggested_questions).toEqual([])
})
it('should use empty string fallback when opening_statement is undefined', () => {
const features = createDefaultFeatures()
// eslint-disable-next-line ts/no-explicit-any -- Testing edge case with undefined
features.opening = { enabled: true, opening_statement: undefined as any, suggested_questions: ['Q1'] }
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
const config = capturedChatProps!.config as ChatConfig
expect(config.opening_statement).toBe('')
})
it('should use empty array fallback when suggested_questions is undefined', () => {
const features = createDefaultFeatures()
// eslint-disable-next-line ts/no-explicit-any -- Testing edge case with undefined
features.opening = { enabled: true, opening_statement: 'Hello', suggested_questions: undefined as any }
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
const config = capturedChatProps!.config as ChatConfig
expect(config.suggested_questions).toEqual([])
})
it('should handle undefined opening feature', () => {
const features = createDefaultFeatures()
// eslint-disable-next-line ts/no-explicit-any -- Testing edge case with undefined
features.opening = undefined as any
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
const config = capturedChatProps!.config as ChatConfig
expect(config.opening_statement).toBe('')
expect(config.suggested_questions).toEqual([])
})
})
describe('inputsForm transformation', () => {
it('should filter out api type variables and map to InputForm', () => {
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
// The useChat is called with inputsForm
const useChatCall = mockUseChat.mock.calls[0]
const inputsForm = useChatCall[1].inputsForm
expect(inputsForm).toHaveLength(1)
expect(inputsForm[0]).toEqual(expect.objectContaining({
key: 'name',
label: 'Name',
variable: 'name',
}))
})
})
describe('event subscription', () => {
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL event', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
// Trigger the event
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test message', files: [{ id: 'file-1' }] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalled()
})
})
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL_RESTART event', async () => {
const handleRestart = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend: vi.fn(),
suggestedQuestions: [],
handleRestart,
})
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
// Trigger the event
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
})
await waitFor(() => {
expect(handleRestart).toHaveBeenCalled()
})
})
})
describe('doSend', () => {
it('should find current provider and model from textGenerationModelList', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalledWith(
'apps/test-app-id/chat-messages',
expect.objectContaining({
query: 'test',
inputs: { name: 'World' },
model_config: expect.objectContaining({
model: expect.objectContaining({
provider: 'openai',
name: 'gpt-3.5-turbo',
mode: 'chat',
}),
}),
}),
expect.any(Object),
)
})
})
it('should include files when file upload is enabled and vision is supported', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
// gpt-3.5-turbo has vision feature
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
const files = [{ id: 'file-1', name: 'image.png' }]
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
files,
}),
expect.any(Object),
)
})
})
it('should not include files when vision is not supported', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
// gpt-4 does not have vision feature
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
const files = [{ id: 'file-1', name: 'image.png' }]
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files },
})
await waitFor(() => {
const callArgs = handleSend.mock.calls[0][1]
expect(callArgs.files).toBeUndefined()
})
})
it('should handle provider not found in textGenerationModelList', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
// Use a provider that doesn't exist in the list
const modelAndParameter = createModelAndParameter({ provider: 'unknown-provider', model: 'unknown-model' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [{ id: 'file-1' }] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalled()
const callArgs = handleSend.mock.calls[0][1]
// Files should not be included when provider/model not found (no vision support)
expect(callArgs.files).toBeUndefined()
})
})
it('should handle model with no features array', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
// Model list where model has no features property
mockUseProviderContext.mockReturnValue({
textGenerationModelList: [
{
provider: 'custom',
models: [{ model: 'custom-model', model_properties: { mode: 'chat' } }],
},
],
})
const modelAndParameter = createModelAndParameter({ provider: 'custom', model: 'custom-model' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [{ id: 'file-1' }] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalled()
const callArgs = handleSend.mock.calls[0][1]
// Files should not be included when features is undefined
expect(callArgs.files).toBeUndefined()
})
})
it('should handle undefined files parameter', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: undefined },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalled()
const callArgs = handleSend.mock.calls[0][1]
expect(callArgs.files).toBeUndefined()
})
})
})
describe('tool icons building', () => {
it('should build tool icons from agent config', () => {
mockUseDebugConfigurationContext.mockReturnValue({
modelConfig: {
...createDefaultModelConfig(),
agentConfig: {
tools: [
{ tool_name: 'search', provider_id: 'provider-1' },
{ tool_name: 'calculator', provider_id: 'provider-2' },
],
},
},
appId: 'test-app-id',
inputs: {},
collectionList: [
{ id: 'provider-1', icon: 'search-icon' },
{ id: 'provider-2', icon: 'calc-icon' },
],
})
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
expect(capturedChatProps!.allToolIcons).toEqual({
search: 'search-icon',
calculator: 'calc-icon',
})
})
it('should handle missing tools gracefully', () => {
mockUseDebugConfigurationContext.mockReturnValue({
modelConfig: {
...createDefaultModelConfig(),
agentConfig: {
tools: undefined,
},
},
appId: 'test-app-id',
inputs: {},
collectionList: [],
})
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
expect(capturedChatProps!.allToolIcons).toEqual({})
})
})
describe('useFormattingChangedSubscription', () => {
it('should call useFormattingChangedSubscription with chatList', () => {
const chatList = [{ id: 'msg-1' }, { id: 'msg-2' }]
mockUseChat.mockReturnValue({
chatList,
isResponding: false,
handleSend: vi.fn(),
suggestedQuestions: [],
handleRestart: vi.fn(),
})
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
expect(mockUseFormattingChangedSubscription).toHaveBeenCalledWith(chatList)
})
})
describe('useChat callbacks', () => {
it('should pass stopChatMessageResponding callback to useChat', () => {
const modelAndParameter = createModelAndParameter()
render(<ChatItem modelAndParameter={modelAndParameter} />)
// Get the stopResponding callback passed to useChat (4th argument)
const useChatCall = mockUseChat.mock.calls[0]
const stopRespondingCallback = useChatCall[3]
// Invoke it with a taskId
stopRespondingCallback('test-task-id')
expect(mockStopChatMessageResponding).toHaveBeenCalledWith('test-app-id', 'test-task-id')
})
it('should pass onGetConversationMessages callback to handleSend', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalled()
})
// Get the callbacks object (3rd argument to handleSend)
const callbacks = handleSend.mock.calls[0][2]
// Invoke onGetConversationMessages
const mockGetAbortController = vi.fn()
callbacks.onGetConversationMessages('conv-123', mockGetAbortController)
expect(mockFetchConversationMessages).toHaveBeenCalledWith('test-app-id', 'conv-123', mockGetAbortController)
})
it('should pass onGetSuggestedQuestions callback to handleSend', async () => {
const handleSend = vi.fn()
mockUseChat.mockReturnValue({
chatList: [{ id: 'msg-1' }],
isResponding: false,
handleSend,
suggestedQuestions: [],
handleRestart: vi.fn(),
})
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-3.5-turbo' })
render(<ChatItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalled()
})
// Get the callbacks object (3rd argument to handleSend)
const callbacks = handleSend.mock.calls[0][2]
// Invoke onGetSuggestedQuestions
const mockGetAbortController = vi.fn()
callbacks.onGetSuggestedQuestions('response-item-123', mockGetAbortController)
expect(mockFetchSuggestedQuestions).toHaveBeenCalledWith('test-app-id', 'response-item-123', mockGetAbortController)
})
})
})

View File

@ -0,0 +1,599 @@
import type { ModelAndParameter } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { AppModeEnum } from '@/types/app'
import DebugItem from './debug-item'
const mockUseTranslation = vi.fn()
const mockUseDebugConfigurationContext = vi.fn()
const mockUseDebugWithMultipleModelContext = vi.fn()
const mockUseProviderContext = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/context/debug-configuration', () => ({
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
}))
vi.mock('./context', () => ({
useDebugWithMultipleModelContext: () => mockUseDebugWithMultipleModelContext(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
}))
vi.mock('./chat-item', () => ({
default: ({ modelAndParameter }: { modelAndParameter: ModelAndParameter }) => (
<div data-testid="chat-item" data-model-id={modelAndParameter.id}>ChatItem</div>
),
}))
vi.mock('./text-generation-item', () => ({
default: ({ modelAndParameter }: { modelAndParameter: ModelAndParameter }) => (
<div data-testid="text-generation-item" data-model-id={modelAndParameter.id}>TextGenerationItem</div>
),
}))
vi.mock('./model-parameter-trigger', () => ({
default: ({ modelAndParameter }: { modelAndParameter: ModelAndParameter }) => (
<div data-testid="model-parameter-trigger" data-model-id={modelAndParameter.id}>ModelParameterTrigger</div>
),
}))
type DropdownItem = { value: string, text: string }
type DropdownProps = {
items?: DropdownItem[]
secondItems?: DropdownItem[]
onSelect: (item: DropdownItem) => void
}
let capturedDropdownProps: DropdownProps | null = null
vi.mock('@/app/components/base/dropdown', () => ({
default: (props: DropdownProps) => {
capturedDropdownProps = props
return (
<div data-testid="dropdown">
<button
type="button"
data-testid="dropdown-trigger"
onClick={() => {
// Mock dropdown menu showing items
}}
>
Dropdown
</button>
{props.items?.map((item: DropdownItem) => (
<button
key={item.value}
type="button"
data-testid={`dropdown-item-${item.value}`}
onClick={() => props.onSelect(item)}
>
{item.text}
</button>
))}
{props.secondItems?.map((item: DropdownItem) => (
<button
key={item.value}
type="button"
data-testid={`dropdown-second-item-${item.value}`}
onClick={() => props.onSelect(item)}
>
{item.text}
</button>
))}
</div>
)
},
}))
let modelIdCounter = 0
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
id: `model-${++modelIdCounter}`,
model: 'gpt-3.5-turbo',
provider: 'openai',
parameters: {},
...overrides,
})
const createTextGenerationModelList = (models: Array<{ provider: string, model: string, status?: ModelStatusEnum }> = []) => {
const providerMap = new Map<string, { model: string, status: ModelStatusEnum, model_properties: { mode: string }, features: string[] }[]>()
for (const m of models) {
if (!providerMap.has(m.provider)) {
providerMap.set(m.provider, [])
}
providerMap.get(m.provider)!.push({
model: m.model,
status: m.status ?? ModelStatusEnum.active,
model_properties: { mode: 'chat' },
features: [],
})
}
return Array.from(providerMap.entries()).map(([provider, modelsList]) => ({
provider,
models: modelsList,
}))
}
describe('DebugItem', () => {
beforeEach(() => {
vi.clearAllMocks()
modelIdCounter = 0
capturedDropdownProps = null
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.CHAT,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: [],
})
})
describe('rendering', () => {
it('should render with index number', () => {
const modelAndParameter = createModelAndParameter({ id: 'model-a' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.getByText('#1')).toBeInTheDocument()
})
it('should render correct index for second model', () => {
const model1 = createModelAndParameter({ id: 'model-a' })
const model2 = createModelAndParameter({ id: 'model-b' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [model1, model2],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={model2} />)
expect(screen.getByText('#2')).toBeInTheDocument()
})
it('should render ModelParameterTrigger', () => {
const modelAndParameter = createModelAndParameter()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('model-parameter-trigger')).toBeInTheDocument()
})
it('should render Dropdown', () => {
const modelAndParameter = createModelAndParameter()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('dropdown')).toBeInTheDocument()
})
it('should apply custom className', () => {
const modelAndParameter = createModelAndParameter()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
const { container } = render(<DebugItem modelAndParameter={modelAndParameter} className="custom-class" />)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should apply custom style', () => {
const modelAndParameter = createModelAndParameter()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
const { container } = render(<DebugItem modelAndParameter={modelAndParameter} style={{ width: '300px' }} />)
expect(container.firstChild).toHaveStyle({ width: '300px' })
})
})
describe('ChatItem rendering', () => {
it('should render ChatItem in CHAT mode with active model', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.CHAT,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
]),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('chat-item')).toBeInTheDocument()
})
it('should render ChatItem in AGENT_CHAT mode with active model', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.AGENT_CHAT,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
]),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('chat-item')).toBeInTheDocument()
})
it('should not render ChatItem when model is not active', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.CHAT,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.disabled },
]),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.queryByTestId('chat-item')).not.toBeInTheDocument()
})
it('should not render ChatItem when provider not found', () => {
const modelAndParameter = createModelAndParameter({ provider: 'unknown', model: 'model' })
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.CHAT,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
]),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.queryByTestId('chat-item')).not.toBeInTheDocument()
})
})
describe('TextGenerationItem rendering', () => {
it('should render TextGenerationItem in COMPLETION mode with active model', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.COMPLETION,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
]),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('text-generation-item')).toBeInTheDocument()
})
it('should not render TextGenerationItem when model is not active', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.COMPLETION,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.disabled },
]),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.queryByTestId('text-generation-item')).not.toBeInTheDocument()
})
it('should not render TextGenerationItem in CHAT mode', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugConfigurationContext.mockReturnValue({
mode: AppModeEnum.CHAT,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-4', status: ModelStatusEnum.active },
]),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(screen.queryByTestId('text-generation-item')).not.toBeInTheDocument()
})
})
describe('dropdown menu items', () => {
it('should show duplicate option when less than 4 models', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter, createModelAndParameter()],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(capturedDropdownProps!.items).toContainEqual(
expect.objectContaining({ value: 'duplicate' }),
)
})
it('should hide duplicate option when 4 or more models', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [
modelAndParameter,
createModelAndParameter(),
createModelAndParameter(),
createModelAndParameter(),
],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(capturedDropdownProps!.items).not.toContainEqual(
expect.objectContaining({ value: 'duplicate' }),
)
})
it('should show debug-as-single-model when provider and model are set', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(capturedDropdownProps!.items).toContainEqual(
expect.objectContaining({ value: 'debug-as-single-model' }),
)
})
it('should hide debug-as-single-model when provider is missing', () => {
const modelAndParameter = createModelAndParameter({ provider: '', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(capturedDropdownProps!.items).not.toContainEqual(
expect.objectContaining({ value: 'debug-as-single-model' }),
)
})
it('should hide debug-as-single-model when model is missing', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: '' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(capturedDropdownProps!.items).not.toContainEqual(
expect.objectContaining({ value: 'debug-as-single-model' }),
)
})
it('should show remove option when more than 2 models', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter, createModelAndParameter(), createModelAndParameter()],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(capturedDropdownProps!.secondItems).toContainEqual(
expect.objectContaining({ value: 'remove' }),
)
})
it('should hide remove option when 2 or fewer models', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter, createModelAndParameter()],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
expect(capturedDropdownProps!.secondItems).toBeUndefined()
})
})
describe('dropdown actions', () => {
it('should duplicate model when clicking duplicate', () => {
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
const model2 = createModelAndParameter({ id: 'model-b' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter, model2],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
fireEvent.click(screen.getByTestId('dropdown-item-duplicate'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
expect.arrayContaining([
expect.objectContaining({ id: 'model-a' }),
expect.objectContaining({ provider: 'openai', model: 'gpt-4' }),
expect.objectContaining({ id: 'model-b' }),
]),
)
expect(onMultipleModelConfigsChange.mock.calls[0][1]).toHaveLength(3)
})
it('should not duplicate when already at 4 models', () => {
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [
modelAndParameter,
createModelAndParameter(),
createModelAndParameter(),
createModelAndParameter(),
],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
// Duplicate option should not be rendered when at 4 models
expect(screen.queryByTestId('dropdown-item-duplicate')).not.toBeInTheDocument()
})
it('should early return when trying to duplicate with 4 models via handleSelect', () => {
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [
modelAndParameter,
createModelAndParameter(),
createModelAndParameter(),
createModelAndParameter(),
],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
// Directly call handleSelect with duplicate action to cover line 42
capturedDropdownProps!.onSelect({ value: 'duplicate', text: 'Duplicate' })
// Should not call onMultipleModelConfigsChange due to early return
expect(onMultipleModelConfigsChange).not.toHaveBeenCalled()
})
it('should call onDebugWithMultipleModelChange when clicking debug-as-single-model', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
const onDebugWithMultipleModelChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange,
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
fireEvent.click(screen.getByTestId('dropdown-item-debug-as-single-model'))
expect(onDebugWithMultipleModelChange).toHaveBeenCalledWith(modelAndParameter)
})
it('should remove model when clicking remove', () => {
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
const model2 = createModelAndParameter({ id: 'model-b' })
const model3 = createModelAndParameter({ id: 'model-c' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter, model2, model3],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<DebugItem modelAndParameter={modelAndParameter} />)
fireEvent.click(screen.getByTestId('dropdown-second-item-remove'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
[
expect.objectContaining({ id: 'model-b' }),
expect.objectContaining({ id: 'model-c' }),
],
)
})
})
})

View File

@ -1,6 +1,7 @@
import type { FC } from 'react'
import type { DebugWithMultipleModelContextType } from './context'
import type { InputForm } from '@/app/components/base/chat/chat/type'
import type { EnableType } from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import {
memo,
@ -40,13 +41,7 @@ const DebugWithMultipleModel = () => {
if (checkCanSend && !checkCanSend())
return
eventEmitter?.emit({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: {
message,
files,
},
} as any)
eventEmitter?.emit({ type: APP_CHAT_WITH_MULTIPLE_MODEL, payload: { message, files } } as any) // eslint-disable-line ts/no-explicit-any
}, [eventEmitter, checkCanSend])
const twoLine = multipleModelConfigs.length === 2
@ -147,7 +142,7 @@ const DebugWithMultipleModel = () => {
showFileUpload={false}
onFeatureBarClick={setShowAppConfigureFeaturesModal}
onSend={handleSend}
speechToTextConfig={speech2text as any}
speechToTextConfig={speech2text as EnableType}
visionConfig={file}
inputs={inputs}
inputsForm={inputsForm}

View File

@ -0,0 +1,436 @@
import type * as React from 'react'
import type { ModelAndParameter } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import ModelParameterTrigger from './model-parameter-trigger'
// Mock MODEL_STATUS_TEXT that is imported in the component
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', async (importOriginal) => {
const original = await importOriginal() as object
return {
...original,
MODEL_STATUS_TEXT: {
'disabled': { en_US: 'Disabled', zh_Hans: '已禁用' },
'quota-exceeded': { en_US: 'Quota Exceeded', zh_Hans: '配额已用完' },
'no-configure': { en_US: 'No Configure', zh_Hans: '未配置凭据' },
},
}
})
const mockUseTranslation = vi.fn()
const mockUseDebugConfigurationContext = vi.fn()
const mockUseDebugWithMultipleModelContext = vi.fn()
const mockUseLanguage = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/context/debug-configuration', () => ({
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
}))
vi.mock('./context', () => ({
useDebugWithMultipleModelContext: () => mockUseDebugWithMultipleModelContext(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => mockUseLanguage(),
}))
type RenderTriggerParams = {
open: boolean
currentProvider: { provider: string, icon: string } | null
currentModel: { model: string, status: ModelStatusEnum } | null
}
type ModalProps = {
provider: string
modelId: string
isAdvancedMode: boolean
completionParams: Record<string, unknown>
debugWithMultipleModel: boolean
setModel: (model: { modelId: string, provider: string }) => void
onCompletionParamsChange: (params: Record<string, unknown>) => void
onDebugWithMultipleModelChange: () => void
renderTrigger: (params: RenderTriggerParams) => React.ReactElement
}
let capturedModalProps: ModalProps | null = null
let mockRenderTriggerFn: ((params: RenderTriggerParams) => React.ReactElement) | null = null
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
default: (props: ModalProps) => {
capturedModalProps = props
mockRenderTriggerFn = props.renderTrigger
// Render the trigger with some mock data
const triggerElement = props.renderTrigger({
open: false,
currentProvider: props.provider
? { provider: props.provider, icon: 'provider-icon' }
: null,
currentModel: props.modelId
? { model: props.modelId, status: ModelStatusEnum.active }
: null,
})
return (
<div data-testid="model-parameter-modal">
{triggerElement}
<button
type="button"
data-testid="select-model-btn"
onClick={() => props.setModel({ modelId: 'new-model', provider: 'new-provider' })}
>
Select Model
</button>
<button
type="button"
data-testid="change-params-btn"
onClick={() => props.onCompletionParamsChange({ temperature: 0.9 })}
>
Change Params
</button>
<button
type="button"
data-testid="debug-single-btn"
onClick={() => props.onDebugWithMultipleModelChange()}
>
Debug Single
</button>
</div>
)
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({
default: ({ provider, modelName }: { provider: { provider: string } | null, modelName?: string }) => (
<div data-testid="model-icon" data-provider={provider?.provider} data-model={modelName}>
ModelIcon
</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-name', () => ({
default: ({ modelItem }: { modelItem: { model: string } | null }) => (
<div data-testid="model-name" data-model={modelItem?.model}>
{modelItem?.model}
</div>
),
}))
vi.mock('@/app/components/base/icons/src/vender/line/shapes', () => ({
CubeOutline: () => <div data-testid="cube-icon">CubeOutline</div>,
}))
vi.mock('@/app/components/base/icons/src/vender/line/alertsAndFeedback', () => ({
AlertTriangle: () => <div data-testid="alert-icon">AlertTriangle</div>,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
}))
let modelIdCounter = 0
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
id: `model-${++modelIdCounter}`,
model: 'gpt-3.5-turbo',
provider: 'openai',
parameters: { temperature: 0.7 },
...overrides,
})
describe('ModelParameterTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
modelIdCounter = 0
capturedModalProps = null
mockRenderTriggerFn = null
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: false,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseLanguage.mockReturnValue('en_US')
})
describe('rendering', () => {
it('should render ModelParameterModal with correct props', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('model-parameter-modal')).toBeInTheDocument()
expect(capturedModalProps!.isAdvancedMode).toBe(false)
expect(capturedModalProps!.provider).toBe('openai')
expect(capturedModalProps!.modelId).toBe('gpt-4')
expect(capturedModalProps!.completionParams).toEqual({ temperature: 0.7 })
expect(capturedModalProps!.debugWithMultipleModel).toBe(true)
})
it('should pass isAdvancedMode from context', () => {
const modelAndParameter = createModelAndParameter()
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: true,
})
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
expect(capturedModalProps!.isAdvancedMode).toBe(true)
})
})
describe('trigger rendering', () => {
it('should render model icon when provider exists', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
})
it('should render cube icon when no provider', () => {
const modelAndParameter = createModelAndParameter({ provider: '', model: '' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('cube-icon')).toBeInTheDocument()
})
it('should render model name when model exists', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('model-name')).toBeInTheDocument()
})
it('should render select model text when no model', () => {
const modelAndParameter = createModelAndParameter({ provider: '', model: '' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
expect(screen.getByText('modelProvider.selectModel')).toBeInTheDocument()
})
})
describe('handleSelectModel', () => {
it('should update model and provider in configs', () => {
const model1 = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-3.5' })
const model2 = createModelAndParameter({ id: 'model-b' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [model1, model2],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={model1} />)
fireEvent.click(screen.getByTestId('select-model-btn'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
[
expect.objectContaining({ id: 'model-a', model: 'new-model', provider: 'new-provider' }),
expect.objectContaining({ id: 'model-b' }),
],
)
})
it('should update correct model when multiple configs exist', () => {
const model1 = createModelAndParameter({ id: 'model-a' })
const model2 = createModelAndParameter({ id: 'model-b' })
const model3 = createModelAndParameter({ id: 'model-c' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [model1, model2, model3],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={model2} />)
fireEvent.click(screen.getByTestId('select-model-btn'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
[
expect.objectContaining({ id: 'model-a' }),
expect.objectContaining({ id: 'model-b', model: 'new-model', provider: 'new-provider' }),
expect.objectContaining({ id: 'model-c' }),
],
)
})
})
describe('handleParamsChange', () => {
it('should update parameters in configs', () => {
const model1 = createModelAndParameter({ id: 'model-a', parameters: { temperature: 0.5 } })
const model2 = createModelAndParameter({ id: 'model-b' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [model1, model2],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={model1} />)
fireEvent.click(screen.getByTestId('change-params-btn'))
expect(onMultipleModelConfigsChange).toHaveBeenCalledWith(
true,
[
expect.objectContaining({ id: 'model-a', parameters: { temperature: 0.9 } }),
expect.objectContaining({ id: 'model-b' }),
],
)
})
})
describe('onDebugWithMultipleModelChange', () => {
it('should call onDebugWithMultipleModelChange with current modelAndParameter', () => {
const modelAndParameter = createModelAndParameter({ id: 'model-a', provider: 'openai', model: 'gpt-4' })
const onDebugWithMultipleModelChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange,
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
fireEvent.click(screen.getByTestId('debug-single-btn'))
expect(onDebugWithMultipleModelChange).toHaveBeenCalledWith(modelAndParameter)
})
})
describe('index finding', () => {
it('should find correct index for model in middle of array', () => {
const model1 = createModelAndParameter({ id: 'model-a' })
const model2 = createModelAndParameter({ id: 'model-b' })
const model3 = createModelAndParameter({ id: 'model-c' })
const onMultipleModelConfigsChange = vi.fn()
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [model1, model2, model3],
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={model2} />)
// Verify that the correct index is used by checking the result of handleSelectModel
fireEvent.click(screen.getByTestId('select-model-btn'))
// The second model (index 1) should be updated
const updatedConfigs = onMultipleModelConfigsChange.mock.calls[0][1]
expect(updatedConfigs[0].id).toBe('model-a')
expect(updatedConfigs[1].model).toBe('new-model') // This one should be updated
expect(updatedConfigs[2].id).toBe('model-c')
})
})
describe('renderTrigger styling and states', () => {
it('should render trigger with open state styling', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
// Call renderTrigger with open=true to test the open styling branch
const triggerWithOpen = mockRenderTriggerFn!({
open: true,
currentProvider: { provider: 'openai', icon: 'provider-icon' },
currentModel: { model: 'gpt-4', status: ModelStatusEnum.active },
})
expect(triggerWithOpen).toBeDefined()
})
it('should render warning tooltip when model status is not active', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
// Call renderTrigger with inactive model status to test the warning branch
const triggerWithInactiveModel = mockRenderTriggerFn!({
open: false,
currentProvider: { provider: 'openai', icon: 'provider-icon' },
currentModel: { model: 'gpt-4', status: ModelStatusEnum.disabled },
})
expect(triggerWithInactiveModel).toBeDefined()
})
it('should render warning background and tooltip for inactive model', () => {
const modelAndParameter = createModelAndParameter({ provider: 'openai', model: 'gpt-4' })
mockUseDebugWithMultipleModelContext.mockReturnValue({
multipleModelConfigs: [modelAndParameter],
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
render(<ModelParameterTrigger modelAndParameter={modelAndParameter} />)
// Test with quota_exceeded status (another inactive status)
const triggerWithQuotaExceeded = mockRenderTriggerFn!({
open: false,
currentProvider: { provider: 'openai', icon: 'provider-icon' },
currentModel: { model: 'gpt-4', status: ModelStatusEnum.quotaExceeded },
})
expect(triggerWithQuotaExceeded).toBeDefined()
})
})
})

View File

@ -0,0 +1,621 @@
import type { ModelAndParameter } from '../types'
import { render, screen, waitFor } from '@testing-library/react'
import { TransferMethod } from '@/app/components/base/chat/types'
import { DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { ModelModeType } from '@/types/app'
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types'
import TextGenerationItem from './text-generation-item'
const mockUseDebugConfigurationContext = vi.fn()
const mockUseProviderContext = vi.fn()
const mockUseFeatures = vi.fn()
const mockUseTextGeneration = vi.fn()
const mockUseEventEmitterContextContext = vi.fn()
const mockPromptVariablesToUserInputsForm = vi.fn()
vi.mock('@/context/debug-configuration', () => ({
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: unknown) => unknown) => mockUseFeatures(selector),
}))
vi.mock('@/app/components/base/text-generation/hooks', () => ({
useTextGeneration: () => mockUseTextGeneration(),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => mockUseEventEmitterContextContext(),
}))
vi.mock('@/utils/model-config', () => ({
promptVariablesToUserInputsForm: (vars: unknown) => mockPromptVariablesToUserInputsForm(vars),
}))
let capturedTextGenerationProps: Record<string, unknown> | null = null
vi.mock('@/app/components/app/text-generate/item', () => ({
default: (props: Record<string, unknown>) => {
capturedTextGenerationProps = props
return <div data-testid="text-generation-component">TextGeneration</div>
},
}))
let modelIdCounter = 0
const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): ModelAndParameter => ({
id: `model-${++modelIdCounter}`,
model: 'gpt-3.5-turbo',
provider: 'openai',
parameters: { temperature: 0.7 },
...overrides,
})
const createDefaultModelConfig = () => ({
provider: 'openai',
model_id: 'gpt-4',
mode: ModelModeType.completion,
configs: {
prompt_template: 'Hello {{name}}',
prompt_variables: [
{ key: 'name', name: 'Name', type: 'string' as const, is_context_var: false },
{ key: 'context', name: 'Context', type: 'string' as const, is_context_var: true },
],
},
chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG,
completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG,
opening_statement: '',
more_like_this: null,
suggested_questions: [],
suggested_questions_after_answer: null,
speech_to_text: null,
text_to_speech: null,
file_upload: null,
retriever_resource: null,
sensitive_word_avoidance: null,
annotation_reply: null,
external_data_tools: [],
dataSets: [],
agentConfig: DEFAULT_AGENT_SETTING,
system_parameters: {
audio_file_size_limit: 0,
file_size_limit: 0,
image_file_size_limit: 0,
video_file_size_limit: 0,
workflow_file_upload_limit: 0,
},
})
const createDefaultFeatures = () => ({
moreLikeThis: { enabled: true },
moderation: { enabled: false },
text2speech: { enabled: true },
file: { enabled: true },
})
const createTextGenerationModelList = (models: Array<{
provider: string
model: string
mode?: string
}> = []) => {
const providerMap = new Map<string, { model: string, model_properties: { mode: string } }[]>()
for (const m of models) {
if (!providerMap.has(m.provider)) {
providerMap.set(m.provider, [])
}
providerMap.get(m.provider)!.push({
model: m.model,
model_properties: { mode: m.mode ?? 'completion' },
})
}
return Array.from(providerMap.entries()).map(([provider, modelsList]) => ({
provider,
models: modelsList,
}))
}
describe('TextGenerationItem', () => {
let subscriptionCallback: ((v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) | null = null
beforeEach(() => {
vi.clearAllMocks()
modelIdCounter = 0
capturedTextGenerationProps = null
subscriptionCallback = null
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: false,
modelConfig: createDefaultModelConfig(),
appId: 'test-app-id',
inputs: { name: 'World' },
promptMode: 'simple',
speechToTextConfig: { enabled: true },
introduction: 'Welcome',
suggestedQuestionsAfterAnswerConfig: { enabled: false },
citationConfig: { enabled: false },
externalDataToolsConfig: [],
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
dataSets: [{ id: 'ds-1', name: 'Dataset 1' }],
datasetConfigs: { retrieval_model: 'single' },
})
mockUseProviderContext.mockReturnValue({
textGenerationModelList: createTextGenerationModelList([
{ provider: 'openai', model: 'gpt-3.5-turbo', mode: 'completion' },
{ provider: 'openai', model: 'gpt-4', mode: 'completion' },
]),
})
const features = createDefaultFeatures()
mockUseFeatures.mockImplementation((selector: (state: { features: ReturnType<typeof createDefaultFeatures> }) => unknown) => selector({ features }))
mockUseTextGeneration.mockReturnValue({
completion: 'Generated text',
handleSend: vi.fn(),
isResponding: false,
messageId: 'msg-1',
})
mockUseEventEmitterContextContext.mockReturnValue({
eventEmitter: {
// eslint-disable-next-line react/no-unnecessary-use-prefix -- mocking real API
useSubscription: (callback: (v: { type: string, payload?: { message: string, files?: unknown[] } }) => void) => {
subscriptionCallback = callback
},
},
})
mockPromptVariablesToUserInputsForm.mockReturnValue([
{ key: 'name', label: 'Name', variable: 'name' },
])
})
describe('rendering', () => {
it('should render TextGeneration component', () => {
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
expect(screen.getByTestId('text-generation-component')).toBeInTheDocument()
})
it('should pass correct props to TextGeneration component', () => {
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
expect(capturedTextGenerationProps!.content).toBe('Generated text')
expect(capturedTextGenerationProps!.isLoading).toBe(false)
expect(capturedTextGenerationProps!.isResponding).toBe(false)
expect(capturedTextGenerationProps!.messageId).toBe('msg-1')
expect(capturedTextGenerationProps!.isError).toBe(false)
expect(capturedTextGenerationProps!.inSidePanel).toBe(true)
expect(capturedTextGenerationProps!.siteInfo).toBeNull()
})
it('should show loading state when no completion and is responding', () => {
mockUseTextGeneration.mockReturnValue({
completion: '',
handleSend: vi.fn(),
isResponding: true,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
expect(capturedTextGenerationProps!.isLoading).toBe(true)
})
it('should not show loading state when has completion', () => {
mockUseTextGeneration.mockReturnValue({
completion: 'Some text',
handleSend: vi.fn(),
isResponding: true,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
expect(capturedTextGenerationProps!.isLoading).toBe(false)
})
})
describe('config building', () => {
it('should build config with correct pre_prompt in simple mode', () => {
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
// The config is built internally, we verify via the handleSend call
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
const handleSend = mockUseTextGeneration().handleSend
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
pre_prompt: 'Hello {{name}}',
}),
}),
)
})
it('should use empty pre_prompt in advanced mode', () => {
mockUseDebugConfigurationContext.mockReturnValue({
...mockUseDebugConfigurationContext(),
isAdvancedMode: true,
modelConfig: createDefaultModelConfig(),
appId: 'test-app-id',
inputs: {},
promptMode: 'advanced',
speechToTextConfig: { enabled: true },
introduction: '',
suggestedQuestionsAfterAnswerConfig: { enabled: false },
citationConfig: { enabled: false },
externalDataToolsConfig: [],
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
dataSets: [],
datasetConfigs: { retrieval_model: 'single' },
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
const handleSend = mockUseTextGeneration().handleSend
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
pre_prompt: '',
}),
}),
)
})
it('should find context variable from prompt_variables', () => {
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
const handleSend = mockUseTextGeneration().handleSend
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
dataset_query_variable: 'context',
}),
}),
)
})
it('should use empty string for dataset_query_variable when no context var exists', () => {
const modelConfigWithoutContextVar = {
...createDefaultModelConfig(),
configs: {
prompt_template: 'Hello {{name}}',
prompt_variables: [
{ key: 'name', name: 'Name', type: 'string' as const, is_context_var: false },
],
},
}
mockUseDebugConfigurationContext.mockReturnValue({
isAdvancedMode: false,
modelConfig: modelConfigWithoutContextVar,
appId: 'test-app-id',
inputs: { name: 'World' },
promptMode: 'simple',
speechToTextConfig: { enabled: true },
introduction: 'Welcome',
suggestedQuestionsAfterAnswerConfig: { enabled: false },
citationConfig: { enabled: false },
externalDataToolsConfig: [],
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
dataSets: [],
datasetConfigs: { retrieval_model: 'single' },
})
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: 'text',
handleSend,
isResponding: false,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
dataset_query_variable: '',
}),
}),
)
})
})
describe('datasets transformation', () => {
it('should transform dataSets to postDatasets format', () => {
mockUseDebugConfigurationContext.mockReturnValue({
...mockUseDebugConfigurationContext(),
isAdvancedMode: false,
modelConfig: createDefaultModelConfig(),
appId: 'test-app-id',
inputs: {},
promptMode: 'simple',
speechToTextConfig: { enabled: true },
introduction: '',
suggestedQuestionsAfterAnswerConfig: { enabled: false },
citationConfig: { enabled: false },
externalDataToolsConfig: [],
chatPromptConfig: DEFAULT_CHAT_PROMPT_CONFIG,
completionPromptConfig: DEFAULT_COMPLETION_PROMPT_CONFIG,
dataSets: [
{ id: 'ds-1', name: 'Dataset 1' },
{ id: 'ds-2', name: 'Dataset 2' },
],
datasetConfigs: { retrieval_model: 'single' },
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
const handleSend = mockUseTextGeneration().handleSend
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
dataset_configs: expect.objectContaining({
datasets: {
datasets: [
{ dataset: { enabled: true, id: 'ds-1' } },
{ dataset: { enabled: true, id: 'ds-2' } },
],
},
}),
}),
}),
)
})
})
describe('event subscription', () => {
it('should handle APP_CHAT_WITH_MULTIPLE_MODEL event', async () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: 'text',
handleSend,
isResponding: false,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test message', files: [] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalledWith(
'apps/test-app-id/completion-messages',
expect.any(Object),
)
})
})
it('should ignore non-matching events', async () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: 'text',
handleSend,
isResponding: false,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: 'SOME_OTHER_EVENT',
payload: { message: 'test' },
})
expect(handleSend).not.toHaveBeenCalled()
})
})
describe('doSend', () => {
it('should build config data with model info', async () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: 'text',
handleSend,
isResponding: false,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter({
provider: 'openai',
model: 'gpt-3.5-turbo',
parameters: { temperature: 0.8 },
})
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
await waitFor(() => {
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
model: {
provider: 'openai',
name: 'gpt-3.5-turbo',
mode: 'completion',
completion_params: { temperature: 0.8 },
},
}),
}),
)
})
})
it('should process local files by clearing url', async () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: 'text',
handleSend,
isResponding: false,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
const files = [
{ id: 'file-1', transfer_method: TransferMethod.local_file, url: 'http://example.com/file1' },
{ id: 'file-2', transfer_method: TransferMethod.remote_url, url: 'http://example.com/file2' },
]
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files },
})
await waitFor(() => {
const callArgs = handleSend.mock.calls[0][1]
expect(callArgs.files[0].url).toBe('')
expect(callArgs.files[1].url).toBe('http://example.com/file2')
})
})
it('should not include files when file upload is disabled', async () => {
const features = { ...createDefaultFeatures(), file: { enabled: false } }
mockUseFeatures.mockImplementation((selector: (state: { features: typeof features }) => unknown) => selector({ features }))
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: 'text',
handleSend,
isResponding: false,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
const files = [{ id: 'file-1', transfer_method: TransferMethod.remote_url }]
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files },
})
await waitFor(() => {
const callArgs = handleSend.mock.calls[0][1]
expect(callArgs.files).toBeUndefined()
})
})
it('should not include files when no files provided', async () => {
const handleSend = vi.fn()
mockUseTextGeneration.mockReturnValue({
completion: 'text',
handleSend,
isResponding: false,
messageId: 'msg-1',
})
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
await waitFor(() => {
const callArgs = handleSend.mock.calls[0][1]
expect(callArgs.files).toBeUndefined()
})
})
})
describe('features integration', () => {
it('should include features in config', () => {
const modelAndParameter = createModelAndParameter()
render(<TextGenerationItem modelAndParameter={modelAndParameter} />)
subscriptionCallback?.({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: { message: 'test', files: [] },
})
const handleSend = mockUseTextGeneration().handleSend
expect(handleSend).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
model_config: expect.objectContaining({
more_like_this: { enabled: true },
sensitive_word_avoidance: { enabled: false },
text_to_speech: { enabled: true },
file_upload: { enabled: true },
}),
}),
)
})
})
})

View File

@ -6,18 +6,26 @@ import type {
ChatConfig,
ChatItem,
} from '@/app/components/base/chat/types'
import type { VisionFile } from '@/types/app'
import { cloneDeep } from 'es-toolkit/object'
import {
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { ToastContext } from '@/app/components/base/toast'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import {
AgentStrategy,
AppModeEnum,
ModelModeType,
TransferMethod,
} from '@/types/app'
import { promptVariablesToUserInputsForm } from '@/utils/model-config'
import { ORCHESTRATE_CHANGED } from './types'
@ -162,3 +170,111 @@ export const useFormattingChangedSubscription = (chatList: ChatItem[]) => {
}
})
}
export const useInputValidation = () => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const {
isAdvancedMode,
mode,
modelModeType,
hasSetBlockStatus,
modelConfig,
} = useDebugConfigurationContext()
const logError = useCallback((message: string) => {
notify({ type: 'error', message })
}, [notify])
const checkCanSend = useCallback((inputs: Record<string, unknown>, completionFiles: VisionFile[]) => {
if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) {
if (modelModeType === ModelModeType.completion) {
if (!hasSetBlockStatus.history) {
notify({ type: 'error', message: t('otherError.historyNoBeEmpty', { ns: 'appDebug' }) })
return false
}
if (!hasSetBlockStatus.query) {
notify({ type: 'error', message: t('otherError.queryNoBeEmpty', { ns: 'appDebug' }) })
return false
}
}
}
let hasEmptyInput = ''
const requiredVars = modelConfig.configs.prompt_variables.filter(({ key, name, required, type }) => {
if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number')
return false
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
return res
})
requiredVars.forEach(({ key, name }) => {
if (hasEmptyInput)
return
if (!inputs[key])
hasEmptyInput = name
})
if (hasEmptyInput) {
logError(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }))
return false
}
if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
return false
}
return !hasEmptyInput
}, [
hasSetBlockStatus.history,
hasSetBlockStatus.query,
isAdvancedMode,
mode,
modelConfig.configs.prompt_variables,
t,
logError,
notify,
modelModeType,
])
return { checkCanSend, logError }
}
export const useFormattingChangeConfirm = () => {
const [isShowFormattingChangeConfirm, setIsShowFormattingChangeConfirm] = useState(false)
const { formattingChanged, setFormattingChanged } = useDebugConfigurationContext()
useEffect(() => {
if (formattingChanged)
setIsShowFormattingChangeConfirm(true) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect
}, [formattingChanged])
const handleConfirm = useCallback((onClear: () => void) => {
onClear()
setIsShowFormattingChangeConfirm(false)
setFormattingChanged(false)
}, [setFormattingChanged])
const handleCancel = useCallback(() => {
setIsShowFormattingChangeConfirm(false)
setFormattingChanged(false)
}, [setFormattingChanged])
return {
isShowFormattingChangeConfirm,
handleConfirm,
handleCancel,
}
}
export const useModalWidth = (containerRef: React.RefObject<HTMLDivElement | null>) => {
const [width, setWidth] = useState(0)
useEffect(() => {
if (containerRef.current) {
const calculatedWidth = document.body.clientWidth - (containerRef.current.clientWidth + 16) - 8
setWidth(calculatedWidth) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect
}
}, [containerRef])
return width
}

View File

@ -3,54 +3,39 @@ import type { FC } from 'react'
import type { DebugWithSingleModelRefType } from './debug-with-single-model'
import type { ModelAndParameter } from './types'
import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import type { Inputs } from '@/models/debug'
import type { ModelConfig as BackendModelConfig, VisionFile, VisionSettings } from '@/types/app'
import {
RiAddLine,
RiEqualizer2Line,
RiSparklingFill,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { cloneDeep } from 'es-toolkit/object'
import type { Inputs, PromptVariable } from '@/models/debug'
import type { VisionFile, VisionSettings } from '@/types/app'
import { produce, setAutoFreeze } from 'immer'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useShallow } from 'zustand/react/shallow'
import ChatUserInput from '@/app/components/app/configuration/debug/chat-user-input'
import PromptValuePanel from '@/app/components/app/configuration/prompt-value-panel'
import { useStore as useAppStore } from '@/app/components/app/store'
import TextGeneration from '@/app/components/app/text-generate/item'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import AgentLogModal from '@/app/components/base/agent-log-modal'
import Button from '@/app/components/base/button'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
import PromptLogModal from '@/app/components/base/prompt-log-modal'
import { ToastContext } from '@/app/components/base/toast'
import TooltipPlus from '@/app/components/base/tooltip'
import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG, IS_CE_EDITION } from '@/config'
import { IS_CE_EDITION } from '@/config'
import ConfigContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useProviderContext } from '@/context/provider-context'
import { sendCompletionMessage } from '@/service/debug'
import { AppSourceType } from '@/service/share'
import { AppModeEnum, ModelModeType, TransferMethod } from '@/types/app'
import { formatBooleanInputs, promptVariablesToUserInputsForm } from '@/utils/model-config'
import GroupName from '../base/group-name'
import { AppModeEnum } from '@/types/app'
import CannotQueryDataset from '../base/warning-mask/cannot-query-dataset'
import FormattingChanged from '../base/warning-mask/formatting-changed'
import HasNotSetAPIKEY from '../base/warning-mask/has-not-set-api'
import DebugHeader from './debug-header'
import DebugWithMultipleModel from './debug-with-multiple-model'
import DebugWithSingleModel from './debug-with-single-model'
import { useFormattingChangeConfirm, useInputValidation, useModalWidth } from './hooks'
import TextCompletionResult from './text-completion-result'
import {
APP_CHAT_WITH_MULTIPLE_MODEL,
APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
} from './types'
import { useTextCompletion } from './use-text-completion'
type IDebug = {
isAPIKeySet: boolean
@ -71,33 +56,17 @@ const Debug: FC<IDebug> = ({
multipleModelConfigs,
onMultipleModelConfigsChange,
}) => {
const { t } = useTranslation()
const {
readonly,
appId,
mode,
modelModeType,
hasSetBlockStatus,
isAdvancedMode,
promptMode,
chatPromptConfig,
completionPromptConfig,
introduction,
suggestedQuestionsAfterAnswerConfig,
speechToTextConfig,
textToSpeechConfig,
citationConfig,
formattingChanged,
setFormattingChanged,
dataSets,
modelConfig,
completionParams,
hasSetContextVar,
datasetConfigs,
externalDataToolsConfig,
} = useContext(ConfigContext)
const { eventEmitter } = useEventEmitterContextContext()
const { data: text2speechDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
const features = useFeatures(s => s.features)
const featuresStore = useFeaturesStore()
// Disable immer auto-freeze for this component
useEffect(() => {
setAutoFreeze(false)
return () => {
@ -105,226 +74,77 @@ const Debug: FC<IDebug> = ({
}
}, [])
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
const [isShowFormattingChangeConfirm, setIsShowFormattingChangeConfirm] = useState(false)
// UI state
const [expanded, setExpanded] = useState(true)
const [isShowCannotQueryDataset, setShowCannotQueryDataset] = useState(false)
useEffect(() => {
if (formattingChanged)
setIsShowFormattingChangeConfirm(true)
}, [formattingChanged])
const containerRef = useRef<HTMLDivElement>(null)
const debugWithSingleModelRef = React.useRef<DebugWithSingleModelRefType>(null!)
const handleClearConversation = () => {
debugWithSingleModelRef.current?.handleRestart()
}
const clearConversation = async () => {
if (debugWithMultipleModel) {
eventEmitter?.emit({
type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
} as any)
return
}
handleClearConversation()
}
// Hooks
const { checkCanSend } = useInputValidation()
const { isShowFormattingChangeConfirm, handleConfirm, handleCancel } = useFormattingChangeConfirm()
const modalWidth = useModalWidth(containerRef)
const handleConfirm = () => {
clearConversation()
setIsShowFormattingChangeConfirm(false)
setFormattingChanged(false)
}
// Wrapper for checkCanSend that uses current completionFiles
const [completionFilesForValidation, setCompletionFilesForValidation] = useState<VisionFile[]>([])
const checkCanSendWithFiles = useCallback(() => {
return checkCanSend(inputs, completionFilesForValidation)
}, [checkCanSend, inputs, completionFilesForValidation])
const handleCancel = () => {
setIsShowFormattingChangeConfirm(false)
setFormattingChanged(false)
}
const { notify } = useContext(ToastContext)
const logError = useCallback((message: string) => {
notify({ type: 'error', message })
}, [notify])
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
const checkCanSend = useCallback(() => {
if (isAdvancedMode && mode !== AppModeEnum.COMPLETION) {
if (modelModeType === ModelModeType.completion) {
if (!hasSetBlockStatus.history) {
notify({ type: 'error', message: t('otherError.historyNoBeEmpty', { ns: 'appDebug' }) })
return false
}
if (!hasSetBlockStatus.query) {
notify({ type: 'error', message: t('otherError.queryNoBeEmpty', { ns: 'appDebug' }) })
return false
}
}
}
let hasEmptyInput = ''
const requiredVars = modelConfig.configs.prompt_variables.filter(({ key, name, required, type }) => {
if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number')
return false
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
return res
}) // compatible with old version
requiredVars.forEach(({ key, name }) => {
if (hasEmptyInput)
return
if (!inputs[key])
hasEmptyInput = name
})
if (hasEmptyInput) {
logError(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: hasEmptyInput }))
return false
}
if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
notify({ type: 'info', message: t('errorMessage.waitForFileUpload', { ns: 'appDebug' }) })
return false
}
return !hasEmptyInput
}, [
const {
isResponding,
completionRes,
messageId,
completionFiles,
hasSetBlockStatus.history,
hasSetBlockStatus.query,
inputs,
isAdvancedMode,
mode,
modelConfig.configs.prompt_variables,
t,
logError,
notify,
modelModeType,
])
const [completionRes, setCompletionRes] = useState('')
const [messageId, setMessageId] = useState<string | null>(null)
const features = useFeatures(s => s.features)
const featuresStore = useFeaturesStore()
const sendTextCompletion = async () => {
if (isResponding) {
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
return false
}
if (dataSets.length > 0 && !hasSetContextVar) {
setShowCannotQueryDataset(true)
return true
}
if (!checkCanSend())
return
const postDatasets = dataSets.map(({ id }) => ({
dataset: {
enabled: true,
id,
},
}))
const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key
const postModelConfig: BackendModelConfig = {
pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '',
prompt_type: promptMode,
chat_prompt_config: isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG),
completion_prompt_config: isAdvancedMode ? completionPromptConfig : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG),
user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables),
dataset_query_variable: contextVar || '',
dataset_configs: {
...datasetConfigs,
datasets: {
datasets: [...postDatasets],
} as any,
},
agent_mode: {
enabled: false,
tools: [],
},
model: {
provider: modelConfig.provider,
name: modelConfig.model_id,
mode: modelConfig.mode,
completion_params: completionParams as any,
},
more_like_this: features.moreLikeThis as any,
sensitive_word_avoidance: features.moderation as any,
text_to_speech: features.text2speech as any,
file_upload: features.file as any,
opening_statement: introduction,
suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig,
speech_to_text: speechToTextConfig,
retriever_resource: citationConfig,
system_parameters: modelConfig.system_parameters,
external_data_tools: externalDataToolsConfig,
}
const data: Record<string, any> = {
inputs: formatBooleanInputs(modelConfig.configs.prompt_variables, inputs),
model_config: postModelConfig,
}
if ((features.file as any).enabled && completionFiles && completionFiles?.length > 0) {
data.files = completionFiles.map((item) => {
if (item.transfer_method === TransferMethod.local_file) {
return {
...item,
url: '',
}
}
return item
})
}
setCompletionRes('')
setMessageId('')
let res: string[] = []
setRespondingTrue()
sendCompletionMessage(appId, data, {
onData: (data: string, _isFirstMessage: boolean, { messageId }) => {
res.push(data)
setCompletionRes(res.join(''))
setMessageId(messageId)
},
onMessageReplace: (messageReplace) => {
res = [messageReplace.answer]
setCompletionRes(res.join(''))
},
onCompleted() {
setRespondingFalse()
},
onError() {
setRespondingFalse()
},
})
}
const handleSendTextCompletion = () => {
if (debugWithMultipleModel) {
eventEmitter?.emit({
type: APP_CHAT_WITH_MULTIPLE_MODEL,
payload: {
message: '',
files: completionFiles,
},
} as any)
return
}
sendTextCompletion()
}
const varList = modelConfig.configs.prompt_variables.map((item: any) => {
return {
label: item.key,
value: inputs[item.key],
}
setCompletionFiles,
sendTextCompletion,
} = useTextCompletion({
checkCanSend: checkCanSendWithFiles,
onShowCannotQueryDataset: () => setShowCannotQueryDataset(true),
})
// Sync completionFiles for validation
useEffect(() => {
setCompletionFilesForValidation(completionFiles as VisionFile[]) // eslint-disable-line react-hooks-extra/no-direct-set-state-in-use-effect
}, [completionFiles])
// App store for modals
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
currentLogItem: state.currentLogItem,
setCurrentLogItem: state.setCurrentLogItem,
showPromptLogModal: state.showPromptLogModal,
setShowPromptLogModal: state.setShowPromptLogModal,
showAgentLogModal: state.showAgentLogModal,
setShowAgentLogModal: state.setShowAgentLogModal,
})))
// Provider context for model list
const { textGenerationModelList } = useProviderContext()
const handleChangeToSingleModel = (item: ModelAndParameter) => {
// Computed values
const varList = modelConfig.configs.prompt_variables.map((item: PromptVariable) => ({
label: item.key,
value: inputs[item.key],
}))
// Handlers
const handleClearConversation = useCallback(() => {
debugWithSingleModelRef.current?.handleRestart()
}, [])
const clearConversation = useCallback(async () => {
if (debugWithMultipleModel) {
eventEmitter?.emit({ type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } as any) // eslint-disable-line ts/no-explicit-any
return
}
handleClearConversation()
}, [debugWithMultipleModel, eventEmitter, handleClearConversation])
const handleFormattingConfirm = useCallback(() => {
handleConfirm(clearConversation)
}, [handleConfirm, clearConversation])
const handleChangeToSingleModel = useCallback((item: ModelAndParameter) => {
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === item.provider)
const currentModel = currentProvider?.models.find(model => model.model === item.model)
@ -335,26 +155,18 @@ const Debug: FC<IDebug> = ({
features: currentModel?.features,
})
modelParameterParams.onCompletionParamsChange(item.parameters)
onMultipleModelConfigsChange(
false,
[],
)
}
onMultipleModelConfigsChange(false, [])
}, [modelParameterParams, onMultipleModelConfigsChange, textGenerationModelList])
const handleVisionConfigInMultipleModel = useCallback(() => {
if (debugWithMultipleModel && mode) {
const supportedVision = multipleModelConfigs.some((modelConfig) => {
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === modelConfig.provider)
const currentModel = currentProvider?.models.find(model => model.model === modelConfig.model)
const supportedVision = multipleModelConfigs.some((config) => {
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === config.provider)
const currentModel = currentProvider?.models.find(model => model.model === config.model)
return currentModel?.features?.includes(ModelFeatureEnum.vision)
})
const {
features,
setFeatures,
} = featuresStore!.getState()
const newFeatures = produce(features, (draft) => {
const { features: storeFeatures, setFeatures } = featuresStore!.getState()
const newFeatures = produce(storeFeatures, (draft) => {
draft.file = {
...draft.file,
enabled: supportedVision,
@ -368,210 +180,131 @@ const Debug: FC<IDebug> = ({
handleVisionConfigInMultipleModel()
}, [multipleModelConfigs, mode, handleVisionConfigInMultipleModel])
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
currentLogItem: state.currentLogItem,
setCurrentLogItem: state.setCurrentLogItem,
showPromptLogModal: state.showPromptLogModal,
setShowPromptLogModal: state.setShowPromptLogModal,
showAgentLogModal: state.showAgentLogModal,
setShowAgentLogModal: state.setShowAgentLogModal,
})))
const [width, setWidth] = useState(0)
const ref = useRef<HTMLDivElement>(null)
const handleSendTextCompletion = useCallback(() => {
if (debugWithMultipleModel) {
eventEmitter?.emit({ type: APP_CHAT_WITH_MULTIPLE_MODEL, payload: { message: '', files: completionFiles } } as any) // eslint-disable-line ts/no-explicit-any
return
}
sendTextCompletion()
}, [completionFiles, debugWithMultipleModel, eventEmitter, sendTextCompletion])
const adjustModalWidth = () => {
if (ref.current)
setWidth(document.body.clientWidth - (ref.current?.clientWidth + 16) - 8)
}
const handleAddModel = useCallback(() => {
onMultipleModelConfigsChange(true, [...multipleModelConfigs, { id: `${Date.now()}`, model: '', provider: '', parameters: {} }])
}, [multipleModelConfigs, onMultipleModelConfigsChange])
useEffect(() => {
adjustModalWidth()
}, [])
const handleClosePromptLogModal = useCallback(() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}, [setCurrentLogItem, setShowPromptLogModal])
const [expanded, setExpanded] = useState(true)
const handleCloseAgentLogModal = useCallback(() => {
setCurrentLogItem()
setShowAgentLogModal(false)
}, [setCurrentLogItem, setShowAgentLogModal])
const isShowTextToSpeech = features.text2speech?.enabled && !!text2speechDefaultModel
return (
<>
<div className="shrink-0">
<div className="flex items-center justify-between px-4 pb-2 pt-3">
<div className="system-xl-semibold text-text-primary">{t('inputs.title', { ns: 'appDebug' })}</div>
<div className="flex items-center">
{
debugWithMultipleModel
? (
<>
<Button
variant="ghost-accent"
onClick={() => onMultipleModelConfigsChange(true, [...multipleModelConfigs, { id: `${Date.now()}`, model: '', provider: '', parameters: {} }])}
disabled={multipleModelConfigs.length >= 4}
>
<RiAddLine className="mr-1 h-3.5 w-3.5" />
{t('modelProvider.addModel', { ns: 'common' })}
(
{multipleModelConfigs.length}
/4)
</Button>
<div className="mx-2 h-[14px] w-[1px] bg-divider-regular" />
</>
)
: null
}
{mode !== AppModeEnum.COMPLETION && (
<>
{
!readonly && (
<TooltipPlus
popupContent={t('operation.refresh', { ns: 'common' })}
>
<ActionButton onClick={clearConversation}>
<RefreshCcw01 className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
)
}
{
varList.length > 0 && (
<div className="relative ml-1 mr-2">
<TooltipPlus
popupContent={t('panel.userInputField', { ns: 'workflow' })}
>
<ActionButton state={expanded ? ActionButtonState.Active : undefined} onClick={() => !readonly && setExpanded(!expanded)}>
<RiEqualizer2Line className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
{expanded && <div className="absolute bottom-[-14px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />}
</div>
)
}
</>
)}
</div>
</div>
<DebugHeader
readonly={readonly}
mode={mode}
debugWithMultipleModel={debugWithMultipleModel}
multipleModelConfigs={multipleModelConfigs}
varListLength={varList.length}
expanded={expanded}
onExpandedChange={setExpanded}
onClearConversation={clearConversation}
onAddModel={handleAddModel}
/>
{mode !== AppModeEnum.COMPLETION && expanded && (
<div className="mx-3">
<ChatUserInput inputs={inputs} />
</div>
)}
{
mode === AppModeEnum.COMPLETION && (
<PromptValuePanel
appType={mode as AppModeEnum}
onSend={handleSendTextCompletion}
inputs={inputs}
visionConfig={{
...features.file! as VisionSettings,
transfer_methods: features.file!.allowed_file_upload_methods || [],
image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit,
}}
onVisionFilesChange={setCompletionFiles}
/>
)
}
</div>
{
debugWithMultipleModel && (
<div className="mt-3 grow overflow-hidden" ref={ref}>
<DebugWithMultipleModel
multipleModelConfigs={multipleModelConfigs}
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
onDebugWithMultipleModelChange={handleChangeToSingleModel}
checkCanSend={checkCanSend}
/>
{showPromptLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}}
/>
)}
{showAgentLogModal && (
<AgentLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowAgentLogModal(false)
}}
/>
)}
</div>
)
}
{
!debugWithMultipleModel && (
<div className="flex grow flex-col" ref={ref}>
{/* Chat */}
{mode !== AppModeEnum.COMPLETION && (
<div className="h-0 grow overflow-hidden">
<DebugWithSingleModel
ref={debugWithSingleModelRef}
checkCanSend={checkCanSend}
/>
</div>
)}
{/* Text Generation */}
{mode === AppModeEnum.COMPLETION && (
<>
{(completionRes || isResponding) && (
<>
<div className="mx-4 mt-3"><GroupName name={t('result', { ns: 'appDebug' })} /></div>
<div className="mx-3 mb-8">
<TextGeneration
appSourceType={AppSourceType.webApp}
className="mt-2"
content={completionRes}
isLoading={!completionRes && isResponding}
isShowTextToSpeech={textToSpeechConfig.enabled && !!text2speechDefaultModel}
isResponding={isResponding}
messageId={messageId}
isError={false}
onRetry={noop}
siteInfo={null}
/>
</div>
</>
)}
{!completionRes && !isResponding && (
<div className="flex grow flex-col items-center justify-center gap-2">
<RiSparklingFill className="h-12 w-12 text-text-empty-state-icon" />
<div className="system-sm-regular text-text-quaternary">{t('noResult', { ns: 'appDebug' })}</div>
</div>
)}
</>
)}
{mode === AppModeEnum.COMPLETION && showPromptLogModal && (
<PromptLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowPromptLogModal(false)
}}
/>
)}
{isShowCannotQueryDataset && (
<CannotQueryDataset
onConfirm={() => setShowCannotQueryDataset(false)}
/>
)}
</div>
)
}
{
isShowFormattingChangeConfirm && (
<FormattingChanged
onConfirm={handleConfirm}
onCancel={handleCancel}
{mode === AppModeEnum.COMPLETION && (
<PromptValuePanel
appType={mode as AppModeEnum}
onSend={handleSendTextCompletion}
inputs={inputs}
visionConfig={{
...features.file! as VisionSettings,
transfer_methods: features.file!.allowed_file_upload_methods || [],
image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit,
}}
onVisionFilesChange={setCompletionFiles}
/>
)
}
{!isAPIKeySet && !readonly && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
)}
</div>
{debugWithMultipleModel && (
<div className="mt-3 grow overflow-hidden" ref={containerRef}>
<DebugWithMultipleModel
multipleModelConfigs={multipleModelConfigs}
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
onDebugWithMultipleModelChange={handleChangeToSingleModel}
checkCanSend={checkCanSendWithFiles}
/>
{showPromptLogModal && (
<PromptLogModal
width={modalWidth}
currentLogItem={currentLogItem}
onCancel={handleClosePromptLogModal}
/>
)}
{showAgentLogModal && (
<AgentLogModal
width={modalWidth}
currentLogItem={currentLogItem}
onCancel={handleCloseAgentLogModal}
/>
)}
</div>
)}
{!debugWithMultipleModel && (
<div className="flex grow flex-col" ref={containerRef}>
{mode !== AppModeEnum.COMPLETION && (
<div className="h-0 grow overflow-hidden">
<DebugWithSingleModel
ref={debugWithSingleModelRef}
checkCanSend={checkCanSendWithFiles}
/>
</div>
)}
{mode === AppModeEnum.COMPLETION && (
<TextCompletionResult
completionRes={completionRes}
isResponding={isResponding}
messageId={messageId}
isShowTextToSpeech={isShowTextToSpeech}
/>
)}
{mode === AppModeEnum.COMPLETION && showPromptLogModal && (
<PromptLogModal
width={modalWidth}
currentLogItem={currentLogItem}
onCancel={handleClosePromptLogModal}
/>
)}
{isShowCannotQueryDataset && (
<CannotQueryDataset onConfirm={() => setShowCannotQueryDataset(false)} />
)}
</div>
)}
{isShowFormattingChangeConfirm && (
<FormattingChanged
onConfirm={handleFormattingConfirm}
onCancel={handleCancel}
/>
)}
{!isAPIKeySet && !readonly && (
<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />
)}
</>
)
}
export default React.memo(Debug)

View File

@ -0,0 +1,57 @@
'use client'
import type { FC } from 'react'
import { RiSparklingFill } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useTranslation } from 'react-i18next'
import TextGeneration from '@/app/components/app/text-generate/item'
import { AppSourceType } from '@/service/share'
import GroupName from '../base/group-name'
type TextCompletionResultProps = {
completionRes: string
isResponding: boolean
messageId: string | null
isShowTextToSpeech?: boolean
}
const TextCompletionResult: FC<TextCompletionResultProps> = ({
completionRes,
isResponding,
messageId,
isShowTextToSpeech,
}) => {
const { t } = useTranslation()
if (!completionRes && !isResponding) {
return (
<div className="flex grow flex-col items-center justify-center gap-2">
<RiSparklingFill className="h-12 w-12 text-text-empty-state-icon" />
<div className="system-sm-regular text-text-quaternary">{t('noResult', { ns: 'appDebug' })}</div>
</div>
)
}
return (
<>
<div className="mx-4 mt-3">
<GroupName name={t('result', { ns: 'appDebug' })} />
</div>
<div className="mx-3 mb-8">
<TextGeneration
appSourceType={AppSourceType.webApp}
className="mt-2"
content={completionRes}
isLoading={!completionRes && isResponding}
isShowTextToSpeech={isShowTextToSpeech}
isResponding={isResponding}
messageId={messageId}
isError={false}
onRetry={noop}
siteInfo={null}
/>
</div>
</>
)
}
export default TextCompletionResult

View File

@ -0,0 +1,187 @@
import type { ModelConfig as BackendModelConfig, VisionFile } from '@/types/app'
import { useBoolean } from 'ahooks'
import { cloneDeep } from 'es-toolkit/object'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useFeatures } from '@/app/components/base/features/hooks'
import { ToastContext } from '@/app/components/base/toast'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { sendCompletionMessage } from '@/service/debug'
import { TransferMethod } from '@/types/app'
import { formatBooleanInputs, promptVariablesToUserInputsForm } from '@/utils/model-config'
type UseTextCompletionOptions = {
checkCanSend: () => boolean
onShowCannotQueryDataset: () => void
}
export const useTextCompletion = ({
checkCanSend,
onShowCannotQueryDataset,
}: UseTextCompletionOptions) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const {
appId,
isAdvancedMode,
promptMode,
chatPromptConfig,
completionPromptConfig,
introduction,
suggestedQuestionsAfterAnswerConfig,
speechToTextConfig,
citationConfig,
dataSets,
modelConfig,
completionParams,
hasSetContextVar,
datasetConfigs,
externalDataToolsConfig,
inputs,
} = useDebugConfigurationContext()
const features = useFeatures(s => s.features)
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
const [completionRes, setCompletionRes] = useState('')
const [messageId, setMessageId] = useState<string | null>(null)
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
const sendTextCompletion = useCallback(async () => {
if (isResponding) {
notify({ type: 'info', message: t('errorMessage.waitForResponse', { ns: 'appDebug' }) })
return false
}
if (dataSets.length > 0 && !hasSetContextVar) {
onShowCannotQueryDataset()
return true
}
if (!checkCanSend())
return
const postDatasets = dataSets.map(({ id }) => ({
dataset: {
enabled: true,
id,
},
}))
const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key
const postModelConfig: BackendModelConfig = {
pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '',
prompt_type: promptMode,
chat_prompt_config: isAdvancedMode ? chatPromptConfig : cloneDeep(DEFAULT_CHAT_PROMPT_CONFIG),
completion_prompt_config: isAdvancedMode ? completionPromptConfig : cloneDeep(DEFAULT_COMPLETION_PROMPT_CONFIG),
user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables),
dataset_query_variable: contextVar || '',
/* eslint-disable ts/no-explicit-any */
dataset_configs: {
...datasetConfigs,
datasets: {
datasets: [...postDatasets],
} as any,
},
agent_mode: {
enabled: false,
tools: [],
},
model: {
provider: modelConfig.provider,
name: modelConfig.model_id,
mode: modelConfig.mode,
completion_params: completionParams as any,
},
more_like_this: features.moreLikeThis as any,
sensitive_word_avoidance: features.moderation as any,
text_to_speech: features.text2speech as any,
file_upload: features.file as any,
/* eslint-enable ts/no-explicit-any */
opening_statement: introduction,
suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig,
speech_to_text: speechToTextConfig,
retriever_resource: citationConfig,
system_parameters: modelConfig.system_parameters,
external_data_tools: externalDataToolsConfig,
}
// eslint-disable-next-line ts/no-explicit-any
const data: Record<string, any> = {
inputs: formatBooleanInputs(modelConfig.configs.prompt_variables, inputs),
model_config: postModelConfig,
}
// eslint-disable-next-line ts/no-explicit-any
if ((features.file as any).enabled && completionFiles && completionFiles?.length > 0) {
data.files = completionFiles.map((item) => {
if (item.transfer_method === TransferMethod.local_file) {
return {
...item,
url: '',
}
}
return item
})
}
setCompletionRes('')
setMessageId('')
let res: string[] = []
setRespondingTrue()
sendCompletionMessage(appId, data, {
onData: (data: string, _isFirstMessage: boolean, { messageId }) => {
res.push(data)
setCompletionRes(res.join(''))
setMessageId(messageId)
},
onMessageReplace: (messageReplace) => {
res = [messageReplace.answer]
setCompletionRes(res.join(''))
},
onCompleted() {
setRespondingFalse()
},
onError() {
setRespondingFalse()
},
})
}, [
appId,
checkCanSend,
chatPromptConfig,
citationConfig,
completionFiles,
completionParams,
completionPromptConfig,
datasetConfigs,
dataSets,
externalDataToolsConfig,
features,
hasSetContextVar,
inputs,
introduction,
isAdvancedMode,
isResponding,
modelConfig,
notify,
onShowCannotQueryDataset,
promptMode,
setRespondingFalse,
setRespondingTrue,
speechToTextConfig,
suggestedQuestionsAfterAnswerConfig,
t,
])
return {
isResponding,
completionRes,
messageId,
completionFiles,
setCompletionFiles,
sendTextCompletion,
}
}

View File

@ -1,20 +1,10 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { createReactI18nextMock } from '@/test/i18n-mock'
import InputWithCopy from './index'
// Create a controllable mock for useClipboard
const mockCopy = vi.fn()
let mockCopied = false
const mockReset = vi.fn()
vi.mock('foxact/use-clipboard', () => ({
useClipboard: () => ({
copy: mockCopy,
copied: mockCopied,
reset: mockReset,
}),
}))
// Mock navigator.clipboard for foxact/use-clipboard
const mockWriteText = vi.fn(() => Promise.resolve())
// Mock the i18n hook with custom translations for test assertions
vi.mock('react-i18next', () => createReactI18nextMock({
@ -27,9 +17,13 @@ vi.mock('react-i18next', () => createReactI18nextMock({
describe('InputWithCopy component', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCopy.mockClear()
mockReset.mockClear()
mockCopied = false
mockWriteText.mockClear()
// Setup navigator.clipboard mock
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
})
})
it('renders correctly with default props', () => {
@ -50,27 +44,31 @@ describe('InputWithCopy component', () => {
expect(copyButton).not.toBeInTheDocument()
})
it('calls copy function with input value when copy button is clicked', () => {
it('copies input value when copy button is clicked', async () => {
const mockOnChange = vi.fn()
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
expect(mockCopy).toHaveBeenCalledWith('test value')
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('test value')
})
})
it('calls copy function with custom value when copyValue prop is provided', () => {
it('copies custom value when copyValue prop is provided', async () => {
const mockOnChange = vi.fn()
render(<InputWithCopy value="display value" onChange={mockOnChange} copyValue="custom copy value" />)
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
expect(mockCopy).toHaveBeenCalledWith('custom copy value')
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('custom copy value')
})
})
it('calls onCopy callback when copy button is clicked', () => {
it('calls onCopy callback when copy button is clicked', async () => {
const onCopyMock = vi.fn()
const mockOnChange = vi.fn()
render(<InputWithCopy value="test value" onChange={mockOnChange} onCopy={onCopyMock} />)
@ -78,21 +76,25 @@ describe('InputWithCopy component', () => {
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
expect(onCopyMock).toHaveBeenCalledWith('test value')
await waitFor(() => {
expect(onCopyMock).toHaveBeenCalledWith('test value')
})
})
it('shows copied state when copied is true', () => {
mockCopied = true
it('shows copied state after successful copy', async () => {
const mockOnChange = vi.fn()
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
// Hover over the button to trigger tooltip
fireEvent.mouseEnter(copyButton)
// The icon should change to filled version when copied
// We verify the component renders without error in copied state
expect(copyButton).toBeInTheDocument()
// Check if the tooltip shows "Copied" state
await waitFor(() => {
expect(screen.getByText('Copied')).toBeInTheDocument()
}, { timeout: 2000 })
})
it('passes through all input props correctly', () => {
@ -115,22 +117,22 @@ describe('InputWithCopy component', () => {
expect(input).toHaveClass('custom-class')
})
it('handles empty value correctly', () => {
it('handles empty value correctly', async () => {
const mockOnChange = vi.fn()
render(<InputWithCopy value="" onChange={mockOnChange} />)
const input = screen.getByRole('textbox')
const input = screen.getByDisplayValue('')
const copyButton = screen.getByRole('button')
expect(input).toBeInTheDocument()
expect(input).toHaveValue('')
expect(copyButton).toBeInTheDocument()
// Clicking copy button with empty value should call copy with empty string
fireEvent.click(copyButton)
expect(mockCopy).toHaveBeenCalledWith('')
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('')
})
})
it('maintains focus on input after copy', () => {
it('maintains focus on input after copy', async () => {
const mockOnChange = vi.fn()
render(<InputWithCopy value="test value" onChange={mockOnChange} />)

View File

@ -1,24 +0,0 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import ApiIndex from './index'
afterEach(() => {
cleanup()
})
describe('ApiIndex', () => {
it('should render without crashing', () => {
render(<ApiIndex />)
expect(screen.getByText('index')).toBeInTheDocument()
})
it('should render a div with text "index"', () => {
const { container } = render(<ApiIndex />)
expect(container.firstChild).toBeInstanceOf(HTMLDivElement)
expect(container.textContent).toBe('index')
})
it('should be a valid function component', () => {
expect(typeof ApiIndex).toBe('function')
})
})

View File

@ -1,111 +0,0 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { ChunkContainer, ChunkLabel, QAPreview } from './chunk'
afterEach(() => {
cleanup()
})
describe('ChunkLabel', () => {
it('should render label text', () => {
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
expect(screen.getByText('Chunk 1')).toBeInTheDocument()
})
it('should render character count', () => {
render(<ChunkLabel label="Chunk 1" characterCount={150} />)
expect(screen.getByText('150 characters')).toBeInTheDocument()
})
it('should render separator dot', () => {
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
expect(screen.getByText('·')).toBeInTheDocument()
})
it('should render with zero character count', () => {
render(<ChunkLabel label="Empty Chunk" characterCount={0} />)
expect(screen.getByText('0 characters')).toBeInTheDocument()
})
it('should render with large character count', () => {
render(<ChunkLabel label="Large Chunk" characterCount={999999} />)
expect(screen.getByText('999999 characters')).toBeInTheDocument()
})
})
describe('ChunkContainer', () => {
it('should render label and character count', () => {
render(<ChunkContainer label="Container 1" characterCount={200}>Content</ChunkContainer>)
expect(screen.getByText('Container 1')).toBeInTheDocument()
expect(screen.getByText('200 characters')).toBeInTheDocument()
})
it('should render children content', () => {
render(<ChunkContainer label="Container 1" characterCount={200}>Test Content</ChunkContainer>)
expect(screen.getByText('Test Content')).toBeInTheDocument()
})
it('should render with complex children', () => {
render(
<ChunkContainer label="Container" characterCount={100}>
<div data-testid="child-div">
<span>Nested content</span>
</div>
</ChunkContainer>,
)
expect(screen.getByTestId('child-div')).toBeInTheDocument()
expect(screen.getByText('Nested content')).toBeInTheDocument()
})
it('should render empty children', () => {
render(<ChunkContainer label="Empty" characterCount={0}>{null}</ChunkContainer>)
expect(screen.getByText('Empty')).toBeInTheDocument()
})
})
describe('QAPreview', () => {
const mockQA = {
question: 'What is the meaning of life?',
answer: 'The meaning of life is 42.',
}
it('should render question text', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('What is the meaning of life?')).toBeInTheDocument()
})
it('should render answer text', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('The meaning of life is 42.')).toBeInTheDocument()
})
it('should render Q label', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('Q')).toBeInTheDocument()
})
it('should render A label', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should render with empty strings', () => {
render(<QAPreview qa={{ question: '', answer: '' }} />)
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should render with long text', () => {
const longQuestion = 'Q'.repeat(500)
const longAnswer = 'A'.repeat(500)
render(<QAPreview qa={{ question: longQuestion, answer: longAnswer }} />)
expect(screen.getByText(longQuestion)).toBeInTheDocument()
expect(screen.getByText(longAnswer)).toBeInTheDocument()
})
it('should render with special characters', () => {
render(<QAPreview qa={{ question: 'What about <script>?', answer: '& special chars!' }} />)
expect(screen.getByText('What about <script>?')).toBeInTheDocument()
expect(screen.getByText('& special chars!')).toBeInTheDocument()
})
})

View File

@ -1,426 +0,0 @@
import type { DefaultModelResponse, Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RetrievalConfig } from '@/types/app'
import { describe, expect, it } from 'vitest'
import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { RerankingModeEnum } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { ensureRerankModelSelected, isReRankModelSelected } from './check-rerank-model'
// Test data factory
const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
...overrides,
})
const createModelItem = (model: string): ModelItem => ({
model,
label: { en_US: model, zh_Hans: model },
model_type: ModelTypeEnum.rerank,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
})
const createRerankModelList = (): Model[] => [
{
provider: 'openai',
icon_small: { en_US: '', zh_Hans: '' },
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [
createModelItem('gpt-4-turbo'),
createModelItem('gpt-3.5-turbo'),
],
status: ModelStatusEnum.active,
},
{
provider: 'cohere',
icon_small: { en_US: '', zh_Hans: '' },
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
models: [
createModelItem('rerank-english-v2.0'),
createModelItem('rerank-multilingual-v2.0'),
],
status: ModelStatusEnum.active,
},
]
const createDefaultRerankModel = (): DefaultModelResponse => ({
model: 'rerank-english-v2.0',
model_type: ModelTypeEnum.rerank,
provider: {
provider: 'cohere',
icon_small: { en_US: '', zh_Hans: '' },
},
})
describe('check-rerank-model', () => {
describe('isReRankModelSelected', () => {
describe('Core Functionality', () => {
it('should return true when reranking is disabled', () => {
const config = createRetrievalConfig({
reranking_enable: false,
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(true)
})
it('should return true for economy indexMethod', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'economy',
})
expect(result).toBe(true)
})
it('should return true when model is selected and valid', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v2.0',
},
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(true)
})
})
describe('Edge Cases', () => {
it('should return false when reranking enabled but no model selected for semantic search', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(false)
})
it('should return false when reranking enabled but no model selected for fullText search', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.fullText,
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(false)
})
it('should return false for hybrid search without WeightedScore mode and no model selected', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.hybrid,
reranking_enable: true,
reranking_mode: RerankingModeEnum.RerankingModel,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(false)
})
it('should return true for hybrid search with WeightedScore mode even without model', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.hybrid,
reranking_enable: true,
reranking_mode: RerankingModeEnum.WeightedScore,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(true)
})
it('should return false when provider exists but model not found', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'cohere',
reranking_model_name: 'non-existent-model',
},
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(false)
})
it('should return false when provider not found in list', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'non-existent-provider',
reranking_model_name: 'some-model',
},
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: 'high_quality',
})
expect(result).toBe(false)
})
it('should return true with empty rerankModelList when reranking disabled', () => {
const config = createRetrievalConfig({
reranking_enable: false,
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: [],
indexMethod: 'high_quality',
})
expect(result).toBe(true)
})
it('should return true when indexMethod is undefined', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
})
const result = isReRankModelSelected({
retrievalConfig: config,
rerankModelList: createRerankModelList(),
indexMethod: undefined,
})
expect(result).toBe(true)
})
})
})
describe('ensureRerankModelSelected', () => {
describe('Core Functionality', () => {
it('should return original config when reranking model already selected', () => {
const config = createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v2.0',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'high_quality',
})
expect(result).toEqual(config)
})
it('should apply default model when reranking enabled but no model selected', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'high_quality',
})
expect(result.reranking_model).toEqual({
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v2.0',
})
})
it('should apply default model for hybrid search method', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.hybrid,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'high_quality',
})
expect(result.reranking_model).toEqual({
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v2.0',
})
})
})
describe('Edge Cases', () => {
it('should return original config when indexMethod is not high_quality', () => {
const config = createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'economy',
})
expect(result).toEqual(config)
})
it('should return original config when rerankDefaultModel is null', () => {
const config = createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: null as unknown as DefaultModelResponse,
indexMethod: 'high_quality',
})
expect(result).toEqual(config)
})
it('should return original config when reranking disabled and not hybrid search', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'high_quality',
})
expect(result).toEqual(config)
})
it('should return original config when indexMethod is undefined', () => {
const config = createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: undefined,
})
expect(result).toEqual(config)
})
it('should preserve other config properties when applying default model', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
top_k: 10,
score_threshold_enabled: true,
score_threshold: 0.8,
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'high_quality',
})
expect(result.top_k).toBe(10)
expect(result.score_threshold_enabled).toBe(true)
expect(result.score_threshold).toBe(0.8)
expect(result.search_method).toBe(RETRIEVE_METHOD.semantic)
})
})
})
})

View File

@ -1,61 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ChunkingModeLabel from './chunking-mode-label'
describe('ChunkingModeLabel', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
expect(screen.getByText(/general/i)).toBeInTheDocument()
})
it('should render with Badge wrapper', () => {
const { container } = render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
// Badge component renders with specific styles
expect(container.querySelector('.flex')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display general mode text when isGeneralMode is true', () => {
render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
expect(screen.getByText(/general/i)).toBeInTheDocument()
})
it('should display parent-child mode text when isGeneralMode is false', () => {
render(<ChunkingModeLabel isGeneralMode={false} isQAMode={false} />)
expect(screen.getByText(/parentChild/i)).toBeInTheDocument()
})
it('should append QA suffix when isGeneralMode and isQAMode are both true', () => {
render(<ChunkingModeLabel isGeneralMode={true} isQAMode={true} />)
expect(screen.getByText(/general.*QA/i)).toBeInTheDocument()
})
it('should not append QA suffix when isGeneralMode is true but isQAMode is false', () => {
render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
const text = screen.getByText(/general/i)
expect(text.textContent).not.toContain('QA')
})
it('should not display QA suffix for parent-child mode even when isQAMode is true', () => {
render(<ChunkingModeLabel isGeneralMode={false} isQAMode={true} />)
expect(screen.getByText(/parentChild/i)).toBeInTheDocument()
expect(screen.queryByText(/QA/i)).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should render icon element', () => {
const { container } = render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
const iconElement = container.querySelector('svg')
expect(iconElement).toBeInTheDocument()
})
it('should apply correct icon size classes', () => {
const { container } = render(<ChunkingModeLabel isGeneralMode={true} isQAMode={false} />)
const iconElement = container.querySelector('svg')
expect(iconElement).toHaveClass('h-3', 'w-3')
})
})
})

View File

@ -1,136 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { CredentialIcon } from './credential-icon'
describe('CredentialIcon', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CredentialIcon name="Test" />)
expect(screen.getByText('T')).toBeInTheDocument()
})
it('should render first letter when no avatar provided', () => {
render(<CredentialIcon name="Alice" />)
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should render image when avatarUrl is provided', () => {
render(<CredentialIcon name="Test" avatarUrl="https://example.com/avatar.png" />)
const img = screen.getByRole('img')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'https://example.com/avatar.png')
})
})
describe('Props', () => {
it('should apply default size of 20px', () => {
const { container } = render(<CredentialIcon name="Test" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveStyle({ width: '20px', height: '20px' })
})
it('should apply custom size', () => {
const { container } = render(<CredentialIcon name="Test" size={40} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveStyle({ width: '40px', height: '40px' })
})
it('should apply custom className', () => {
const { container } = render(<CredentialIcon name="Test" className="custom-class" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
it('should uppercase the first letter', () => {
render(<CredentialIcon name="bob" />)
expect(screen.getByText('B')).toBeInTheDocument()
})
it('should render fallback when avatarUrl is "default"', () => {
render(<CredentialIcon name="Test" avatarUrl="default" />)
expect(screen.getByText('T')).toBeInTheDocument()
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should fallback to letter when image fails to load', () => {
render(<CredentialIcon name="Test" avatarUrl="https://example.com/broken.png" />)
// Initially shows image
const img = screen.getByRole('img')
expect(img).toBeInTheDocument()
// Trigger error event
fireEvent.error(img)
// Should now show letter fallback
expect(screen.getByText('T')).toBeInTheDocument()
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle single character name', () => {
render(<CredentialIcon name="A" />)
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should handle name starting with number', () => {
render(<CredentialIcon name="123test" />)
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should handle name starting with special character', () => {
render(<CredentialIcon name="@user" />)
expect(screen.getByText('@')).toBeInTheDocument()
})
it('should assign consistent background colors based on first letter', () => {
// Same first letter should get same color
const { container: container1 } = render(<CredentialIcon name="Alice" />)
const { container: container2 } = render(<CredentialIcon name="Anna" />)
const wrapper1 = container1.firstChild as HTMLElement
const wrapper2 = container2.firstChild as HTMLElement
// Both should have the same bg class since they start with 'A'
const classes1 = wrapper1.className
const classes2 = wrapper2.className
const bgClass1 = classes1.match(/bg-components-icon-bg-\S+/)?.[0]
const bgClass2 = classes2.match(/bg-components-icon-bg-\S+/)?.[0]
expect(bgClass1).toBe(bgClass2)
})
it('should apply different background colors for different letters', () => {
// 'A' (65) % 4 = 1 → pink, 'B' (66) % 4 = 2 → indigo
const { container: container1 } = render(<CredentialIcon name="Alice" />)
const { container: container2 } = render(<CredentialIcon name="Bob" />)
const wrapper1 = container1.firstChild as HTMLElement
const wrapper2 = container2.firstChild as HTMLElement
const bgClass1 = wrapper1.className.match(/bg-components-icon-bg-\S+/)?.[0]
const bgClass2 = wrapper2.className.match(/bg-components-icon-bg-\S+/)?.[0]
expect(bgClass1).toBeDefined()
expect(bgClass2).toBeDefined()
expect(bgClass1).not.toBe(bgClass2)
})
it('should handle empty avatarUrl string', () => {
render(<CredentialIcon name="Test" avatarUrl="" />)
expect(screen.getByText('T')).toBeInTheDocument()
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('should render image with correct dimensions', () => {
render(<CredentialIcon name="Test" avatarUrl="https://example.com/avatar.png" size={32} />)
const img = screen.getByRole('img')
expect(img).toHaveAttribute('width', '32')
expect(img).toHaveAttribute('height', '32')
})
})
})

View File

@ -1,115 +0,0 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import DocumentFileIcon from './document-file-icon'
describe('DocumentFileIcon', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<DocumentFileIcon />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render FileTypeIcon component', () => {
const { container } = render(<DocumentFileIcon extension="pdf" />)
// FileTypeIcon renders an svg or img element
expect(container.querySelector('svg, img')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should determine type from extension prop', () => {
const { container } = render(<DocumentFileIcon extension="pdf" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should determine type from name when extension not provided', () => {
const { container } = render(<DocumentFileIcon name="document.pdf" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle uppercase extension', () => {
const { container } = render(<DocumentFileIcon extension="PDF" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle uppercase name extension', () => {
const { container } = render(<DocumentFileIcon name="DOCUMENT.PDF" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should apply custom className', () => {
const { container } = render(<DocumentFileIcon extension="pdf" className="custom-icon" />)
expect(container.querySelector('.custom-icon')).toBeInTheDocument()
})
it('should pass size prop to FileTypeIcon', () => {
// Testing different size values
const { container: smContainer } = render(<DocumentFileIcon extension="pdf" size="sm" />)
const { container: lgContainer } = render(<DocumentFileIcon extension="pdf" size="lg" />)
expect(smContainer.firstChild).toBeInTheDocument()
expect(lgContainer.firstChild).toBeInTheDocument()
})
})
describe('File Type Mapping', () => {
const testCases = [
{ extension: 'pdf', description: 'PDF files' },
{ extension: 'json', description: 'JSON files' },
{ extension: 'html', description: 'HTML files' },
{ extension: 'txt', description: 'TXT files' },
{ extension: 'markdown', description: 'Markdown files' },
{ extension: 'md', description: 'MD files' },
{ extension: 'xlsx', description: 'XLSX files' },
{ extension: 'xls', description: 'XLS files' },
{ extension: 'csv', description: 'CSV files' },
{ extension: 'doc', description: 'DOC files' },
{ extension: 'docx', description: 'DOCX files' },
]
testCases.forEach(({ extension, description }) => {
it(`should handle ${description}`, () => {
const { container } = render(<DocumentFileIcon extension={extension} />)
expect(container.firstChild).toBeInTheDocument()
})
})
})
describe('Edge Cases', () => {
it('should handle unknown extension with default document type', () => {
const { container } = render(<DocumentFileIcon extension="xyz" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle empty extension string', () => {
const { container } = render(<DocumentFileIcon extension="" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle name without extension', () => {
const { container } = render(<DocumentFileIcon name="document" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle name with multiple dots', () => {
const { container } = render(<DocumentFileIcon name="my.document.file.pdf" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should prioritize extension over name', () => {
// If both are provided, extension should take precedence
const { container } = render(<DocumentFileIcon extension="xlsx" name="document.pdf" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle undefined extension and name', () => {
const { container } = render(<DocumentFileIcon />)
expect(container.firstChild).toBeInTheDocument()
})
it('should apply default size of md', () => {
const { container } = render(<DocumentFileIcon extension="pdf" />)
expect(container.firstChild).toBeInTheDocument()
})
})
})

View File

@ -1,166 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { useAutoDisabledDocuments } from '@/service/knowledge/use-document'
import AutoDisabledDocument from './auto-disabled-document'
type AutoDisabledDocumentsResponse = { document_ids: string[] }
const createMockQueryResult = (
data: AutoDisabledDocumentsResponse | undefined,
isLoading: boolean,
) => ({
data,
isLoading,
}) as ReturnType<typeof useAutoDisabledDocuments>
// Mock service hooks
const mockMutateAsync = vi.fn()
const mockInvalidDisabledDocument = vi.fn()
vi.mock('@/service/knowledge/use-document', () => ({
useAutoDisabledDocuments: vi.fn(),
useDocumentEnable: vi.fn(() => ({
mutateAsync: mockMutateAsync,
})),
useInvalidDisabledDocument: vi.fn(() => mockInvalidDisabledDocument),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
const mockUseAutoDisabledDocuments = vi.mocked(useAutoDisabledDocuments)
describe('AutoDisabledDocument', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMutateAsync.mockResolvedValue({})
})
describe('Rendering', () => {
it('should render nothing when loading', () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult(undefined, true),
)
const { container } = render(<AutoDisabledDocument datasetId="test-dataset" />)
expect(container.firstChild).toBeNull()
})
it('should render nothing when no disabled documents', () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: [] }, false),
)
const { container } = render(<AutoDisabledDocument datasetId="test-dataset" />)
expect(container.firstChild).toBeNull()
})
it('should render nothing when document_ids is undefined', () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult(undefined, false),
)
const { container } = render(<AutoDisabledDocument datasetId="test-dataset" />)
expect(container.firstChild).toBeNull()
})
it('should render StatusWithAction when disabled documents exist', () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: ['doc1', 'doc2'] }, false),
)
render(<AutoDisabledDocument datasetId="test-dataset" />)
expect(screen.getByText(/enable/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass datasetId to useAutoDisabledDocuments', () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: [] }, false),
)
render(<AutoDisabledDocument datasetId="my-dataset-id" />)
expect(mockUseAutoDisabledDocuments).toHaveBeenCalledWith('my-dataset-id')
})
})
describe('User Interactions', () => {
it('should call enableDocument when action button is clicked', async () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: ['doc1', 'doc2'] }, false),
)
render(<AutoDisabledDocument datasetId="test-dataset" />)
const actionButton = screen.getByText(/enable/i)
fireEvent.click(actionButton)
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
datasetId: 'test-dataset',
documentIds: ['doc1', 'doc2'],
})
})
})
it('should invalidate cache after enabling documents', async () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: ['doc1'] }, false),
)
render(<AutoDisabledDocument datasetId="test-dataset" />)
const actionButton = screen.getByText(/enable/i)
fireEvent.click(actionButton)
await waitFor(() => {
expect(mockInvalidDisabledDocument).toHaveBeenCalled()
})
})
it('should show success toast after enabling documents', async () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: ['doc1'] }, false),
)
render(<AutoDisabledDocument datasetId="test-dataset" />)
const actionButton = screen.getByText(/enable/i)
fireEvent.click(actionButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
})
})
})
})
describe('Edge Cases', () => {
it('should handle single disabled document', () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: ['doc1'] }, false),
)
render(<AutoDisabledDocument datasetId="test-dataset" />)
expect(screen.getByText(/enable/i)).toBeInTheDocument()
})
it('should handle multiple disabled documents', () => {
mockUseAutoDisabledDocuments.mockReturnValue(
createMockQueryResult({ document_ids: ['doc1', 'doc2', 'doc3', 'doc4', 'doc5'] }, false),
)
render(<AutoDisabledDocument datasetId="test-dataset" />)
expect(screen.getByText(/enable/i)).toBeInTheDocument()
})
})
})

View File

@ -1,280 +0,0 @@
import type { ErrorDocsResponse } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { retryErrorDocs } from '@/service/datasets'
import { useDatasetErrorDocs } from '@/service/knowledge/use-dataset'
import RetryButton from './index-failed'
// Mock service hooks
const mockRefetch = vi.fn()
vi.mock('@/service/knowledge/use-dataset', () => ({
useDatasetErrorDocs: vi.fn(),
}))
vi.mock('@/service/datasets', () => ({
retryErrorDocs: vi.fn(),
}))
const mockUseDatasetErrorDocs = vi.mocked(useDatasetErrorDocs)
const mockRetryErrorDocs = vi.mocked(retryErrorDocs)
// Helper to create mock query result
const createMockQueryResult = (
data: ErrorDocsResponse | undefined,
isLoading: boolean,
) => ({
data,
isLoading,
refetch: mockRefetch,
// Required query result properties
error: null,
isError: false,
isFetched: true,
isFetching: false,
isSuccess: !isLoading && !!data,
status: isLoading ? 'pending' : 'success',
dataUpdatedAt: Date.now(),
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isLoadingError: false,
isPaused: false,
isPlaceholderData: false,
isPending: isLoading,
isRefetchError: false,
isRefetching: false,
isStale: false,
fetchStatus: 'idle',
promise: Promise.resolve(data as ErrorDocsResponse),
isFetchedAfterMount: true,
isInitialLoading: false,
}) as unknown as ReturnType<typeof useDatasetErrorDocs>
describe('RetryButton (IndexFailed)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockRefetch.mockResolvedValue({})
})
describe('Rendering', () => {
it('should render nothing when loading', () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult(undefined, true),
)
const { container } = render(<RetryButton datasetId="test-dataset" />)
expect(container.firstChild).toBeNull()
})
it('should render nothing when no error documents', () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({ total: 0, data: [] }, false),
)
const { container } = render(<RetryButton datasetId="test-dataset" />)
expect(container.firstChild).toBeNull()
})
it('should render StatusWithAction when error documents exist', () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({
total: 3,
data: [
{ id: 'doc1' },
{ id: 'doc2' },
{ id: 'doc3' },
] as ErrorDocsResponse['data'],
}, false),
)
render(<RetryButton datasetId="test-dataset" />)
expect(screen.getByText(/retry/i)).toBeInTheDocument()
})
it('should display error count in description', () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({
total: 5,
data: [{ id: 'doc1' }] as ErrorDocsResponse['data'],
}, false),
)
render(<RetryButton datasetId="test-dataset" />)
expect(screen.getByText(/5/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass datasetId to useDatasetErrorDocs', () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({ total: 0, data: [] }, false),
)
render(<RetryButton datasetId="my-dataset-id" />)
expect(mockUseDatasetErrorDocs).toHaveBeenCalledWith('my-dataset-id')
})
})
describe('User Interactions', () => {
it('should call retryErrorDocs when retry button is clicked', async () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({
total: 2,
data: [{ id: 'doc1' }, { id: 'doc2' }] as ErrorDocsResponse['data'],
}, false),
)
mockRetryErrorDocs.mockResolvedValue({ result: 'success' })
render(<RetryButton datasetId="test-dataset" />)
const retryButton = screen.getByText(/retry/i)
fireEvent.click(retryButton)
await waitFor(() => {
expect(mockRetryErrorDocs).toHaveBeenCalledWith({
datasetId: 'test-dataset',
document_ids: ['doc1', 'doc2'],
})
})
})
it('should refetch error docs after successful retry', async () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({
total: 1,
data: [{ id: 'doc1' }] as ErrorDocsResponse['data'],
}, false),
)
mockRetryErrorDocs.mockResolvedValue({ result: 'success' })
render(<RetryButton datasetId="test-dataset" />)
const retryButton = screen.getByText(/retry/i)
fireEvent.click(retryButton)
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
})
it('should disable button while retrying', async () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({
total: 1,
data: [{ id: 'doc1' }] as ErrorDocsResponse['data'],
}, false),
)
// Delay the response to test loading state
mockRetryErrorDocs.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve({ result: 'success' }), 100)))
render(<RetryButton datasetId="test-dataset" />)
const retryButton = screen.getByText(/retry/i)
fireEvent.click(retryButton)
// Button should show disabled styling during retry
await waitFor(() => {
const button = screen.getByText(/retry/i)
expect(button).toHaveClass('cursor-not-allowed')
expect(button).toHaveClass('text-text-disabled')
})
})
})
describe('State Management', () => {
it('should transition to error state when retry fails', async () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({
total: 1,
data: [{ id: 'doc1' }] as ErrorDocsResponse['data'],
}, false),
)
mockRetryErrorDocs.mockResolvedValue({ result: 'fail' })
render(<RetryButton datasetId="test-dataset" />)
const retryButton = screen.getByText(/retry/i)
fireEvent.click(retryButton)
await waitFor(() => {
// Button should still be visible after failed retry
expect(screen.getByText(/retry/i)).toBeInTheDocument()
})
})
it('should transition to success state when total becomes 0', async () => {
const { rerender } = render(<RetryButton datasetId="test-dataset" />)
// Initially has errors
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({
total: 1,
data: [{ id: 'doc1' }] as ErrorDocsResponse['data'],
}, false),
)
rerender(<RetryButton datasetId="test-dataset" />)
expect(screen.getByText(/retry/i)).toBeInTheDocument()
// Now no errors
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({ total: 0, data: [] }, false),
)
rerender(<RetryButton datasetId="test-dataset" />)
await waitFor(() => {
expect(screen.queryByText(/retry/i)).not.toBeInTheDocument()
})
})
})
describe('Edge Cases', () => {
it('should handle empty data array', () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({ total: 0, data: [] }, false),
)
const { container } = render(<RetryButton datasetId="test-dataset" />)
expect(container.firstChild).toBeNull()
})
it('should handle undefined data by showing error state', () => {
// When data is undefined but not loading, the component shows error state
// because errorDocs?.total is not strictly equal to 0
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult(undefined, false),
)
render(<RetryButton datasetId="test-dataset" />)
// Component renders with undefined count
expect(screen.getByText(/retry/i)).toBeInTheDocument()
})
it('should handle retry with empty document list', async () => {
mockUseDatasetErrorDocs.mockReturnValue(
createMockQueryResult({ total: 1, data: [] }, false),
)
mockRetryErrorDocs.mockResolvedValue({ result: 'success' })
render(<RetryButton datasetId="test-dataset" />)
const retryButton = screen.getByText(/retry/i)
fireEvent.click(retryButton)
await waitFor(() => {
expect(mockRetryErrorDocs).toHaveBeenCalledWith({
datasetId: 'test-dataset',
document_ids: [],
})
})
})
})
})

View File

@ -1,175 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import StatusWithAction from './status-with-action'
describe('StatusWithAction', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<StatusWithAction description="Test description" />)
expect(screen.getByText('Test description')).toBeInTheDocument()
})
it('should render description text', () => {
render(<StatusWithAction description="This is a test message" />)
expect(screen.getByText('This is a test message')).toBeInTheDocument()
})
it('should render icon based on type', () => {
const { container } = render(<StatusWithAction type="success" description="Success" />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should default to info type when type is not provided', () => {
const { container } = render(<StatusWithAction description="Default type" />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-accent')
})
it('should render success type with correct color', () => {
const { container } = render(<StatusWithAction type="success" description="Success" />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-success')
})
it('should render error type with correct color', () => {
const { container } = render(<StatusWithAction type="error" description="Error" />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-destructive')
})
it('should render warning type with correct color', () => {
const { container } = render(<StatusWithAction type="warning" description="Warning" />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-warning-secondary')
})
it('should render info type with correct color', () => {
const { container } = render(<StatusWithAction type="info" description="Info" />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('text-text-accent')
})
it('should render action button when actionText and onAction are provided', () => {
const onAction = vi.fn()
render(
<StatusWithAction
description="Test"
actionText="Click me"
onAction={onAction}
/>,
)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('should not render action button when onAction is not provided', () => {
render(<StatusWithAction description="Test" actionText="Click me" />)
expect(screen.queryByText('Click me')).not.toBeInTheDocument()
})
it('should render divider when action is present', () => {
const { container } = render(
<StatusWithAction
description="Test"
actionText="Click me"
onAction={() => {}}
/>,
)
// Divider component renders a div with specific classes
expect(container.querySelector('.bg-divider-regular')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onAction when action button is clicked', () => {
const onAction = vi.fn()
render(
<StatusWithAction
description="Test"
actionText="Click me"
onAction={onAction}
/>,
)
fireEvent.click(screen.getByText('Click me'))
expect(onAction).toHaveBeenCalledTimes(1)
})
it('should call onAction even when disabled (style only)', () => {
// Note: disabled prop only affects styling, not actual click behavior
const onAction = vi.fn()
render(
<StatusWithAction
description="Test"
actionText="Click me"
onAction={onAction}
disabled
/>,
)
fireEvent.click(screen.getByText('Click me'))
expect(onAction).toHaveBeenCalledTimes(1)
})
it('should apply disabled styles when disabled prop is true', () => {
render(
<StatusWithAction
description="Test"
actionText="Click me"
onAction={() => {}}
disabled
/>,
)
const actionButton = screen.getByText('Click me')
expect(actionButton).toHaveClass('cursor-not-allowed')
expect(actionButton).toHaveClass('text-text-disabled')
})
})
describe('Status Background Gradients', () => {
it('should apply success gradient background', () => {
const { container } = render(<StatusWithAction type="success" description="Success" />)
const gradientDiv = container.querySelector('.opacity-40')
expect(gradientDiv?.className).toContain('rgba(23,178,106,0.25)')
})
it('should apply warning gradient background', () => {
const { container } = render(<StatusWithAction type="warning" description="Warning" />)
const gradientDiv = container.querySelector('.opacity-40')
expect(gradientDiv?.className).toContain('rgba(247,144,9,0.25)')
})
it('should apply error gradient background', () => {
const { container } = render(<StatusWithAction type="error" description="Error" />)
const gradientDiv = container.querySelector('.opacity-40')
expect(gradientDiv?.className).toContain('rgba(240,68,56,0.25)')
})
it('should apply info gradient background', () => {
const { container } = render(<StatusWithAction type="info" description="Info" />)
const gradientDiv = container.querySelector('.opacity-40')
expect(gradientDiv?.className).toContain('rgba(11,165,236,0.25)')
})
})
describe('Edge Cases', () => {
it('should handle empty description', () => {
const { container } = render(<StatusWithAction description="" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle long description text', () => {
const longText = 'A'.repeat(500)
render(<StatusWithAction description={longText} />)
expect(screen.getByText(longText)).toBeInTheDocument()
})
it('should handle undefined actionText when onAction is provided', () => {
render(<StatusWithAction description="Test" onAction={() => {}} />)
// Should render without throwing
expect(screen.getByText('Test')).toBeInTheDocument()
})
})
})

View File

@ -1,252 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ImageList from './index'
// Track handleImageClick calls for testing
type FileEntity = {
sourceUrl: string
name: string
mimeType?: string
size?: number
extension?: string
}
let capturedOnClick: ((file: FileEntity) => void) | null = null
// Mock FileThumb to capture click handler
vi.mock('@/app/components/base/file-thumb', () => ({
default: ({ file, onClick }: { file: FileEntity, onClick?: (file: FileEntity) => void }) => {
// Capture the onClick for testing
capturedOnClick = onClick ?? null
return (
<div
data-testid={`file-thumb-${file.sourceUrl}`}
className="cursor-pointer"
onClick={() => onClick?.(file)}
>
{file.name}
</div>
)
},
}))
type ImagePreviewerProps = {
images: ImageInfo[]
initialIndex: number
onClose: () => void
}
type ImageInfo = {
url: string
name: string
size: number
}
// Mock ImagePreviewer since it uses createPortal
vi.mock('../image-previewer', () => ({
default: ({ images, initialIndex, onClose }: ImagePreviewerProps) => (
<div data-testid="image-previewer">
<span data-testid="preview-count">{images.length}</span>
<span data-testid="preview-index">{initialIndex}</span>
<button data-testid="close-preview" onClick={onClose}>Close</button>
</div>
),
}))
const createMockImages = (count: number) => {
return Array.from({ length: count }, (_, i) => ({
name: `image-${i + 1}.png`,
mimeType: 'image/png',
sourceUrl: `https://example.com/image-${i + 1}.png`,
size: 1024 * (i + 1),
extension: 'png',
}))
}
describe('ImageList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const images = createMockImages(3)
const { container } = render(<ImageList images={images} size="md" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render all images when count is below limit', () => {
const images = createMockImages(5)
render(<ImageList images={images} size="md" limit={9} />)
// Each image renders a FileThumb component
const thumbnails = document.querySelectorAll('[class*="cursor-pointer"]')
expect(thumbnails.length).toBeGreaterThanOrEqual(5)
})
it('should render limited images when count exceeds limit', () => {
const images = createMockImages(15)
render(<ImageList images={images} size="md" limit={9} />)
// More button should be visible
expect(screen.getByText(/\+6/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const images = createMockImages(3)
const { container } = render(
<ImageList images={images} size="md" className="custom-class" />,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should use default limit of 9', () => {
const images = createMockImages(12)
render(<ImageList images={images} size="md" />)
// Should show "+3" for remaining images
expect(screen.getByText(/\+3/)).toBeInTheDocument()
})
it('should respect custom limit', () => {
const images = createMockImages(10)
render(<ImageList images={images} size="md" limit={5} />)
// Should show "+5" for remaining images
expect(screen.getByText(/\+5/)).toBeInTheDocument()
})
it('should handle size prop sm', () => {
const images = createMockImages(2)
const { container } = render(<ImageList images={images} size="sm" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle size prop md', () => {
const images = createMockImages(2)
const { container } = render(<ImageList images={images} size="md" />)
expect(container.firstChild).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should show all images when More button is clicked', () => {
const images = createMockImages(15)
render(<ImageList images={images} size="md" limit={9} />)
// Click More button
const moreButton = screen.getByText(/\+6/)
fireEvent.click(moreButton)
// More button should disappear
expect(screen.queryByText(/\+6/)).not.toBeInTheDocument()
})
it('should open preview when image is clicked', () => {
const images = createMockImages(3)
render(<ImageList images={images} size="md" />)
// Find and click an image thumbnail
const thumbnails = document.querySelectorAll('[class*="cursor-pointer"]')
if (thumbnails.length > 0) {
fireEvent.click(thumbnails[0])
// Preview should open
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
}
})
it('should close preview when close button is clicked', () => {
const images = createMockImages(3)
render(<ImageList images={images} size="md" />)
// Open preview
const thumbnails = document.querySelectorAll('[class*="cursor-pointer"]')
if (thumbnails.length > 0) {
fireEvent.click(thumbnails[0])
// Close preview
const closeButton = screen.getByTestId('close-preview')
fireEvent.click(closeButton)
// Preview should be closed
expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument()
}
})
})
describe('Edge Cases', () => {
it('should handle empty images array', () => {
const { container } = render(<ImageList images={[]} size="md" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should not open preview when clicked image not found in list (index === -1)', () => {
const images = createMockImages(3)
const { rerender } = render(<ImageList images={images} size="md" />)
// Click first image to open preview
const firstThumb = screen.getByTestId('file-thumb-https://example.com/image-1.png')
fireEvent.click(firstThumb)
// Preview should open for valid image
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
// Close preview
fireEvent.click(screen.getByTestId('close-preview'))
expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument()
// Now render with images that don't include the previously clicked one
const newImages = createMockImages(2) // Only 2 images
rerender(<ImageList images={newImages} size="md" />)
// Click on a thumbnail that exists
const validThumb = screen.getByTestId('file-thumb-https://example.com/image-1.png')
fireEvent.click(validThumb)
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
})
it('should return early when file sourceUrl is not found in limitedImages (index === -1)', () => {
const images = createMockImages(3)
render(<ImageList images={images} size="md" />)
// Call the captured onClick with a file that has a non-matching sourceUrl
// This triggers the index === -1 branch (line 44-45)
if (capturedOnClick) {
capturedOnClick({
name: 'nonexistent.png',
mimeType: 'image/png',
sourceUrl: 'https://example.com/nonexistent.png', // Not in the list
size: 1024,
extension: 'png',
})
}
// Preview should NOT open because the file was not found in limitedImages
expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument()
})
it('should handle single image', () => {
const images = createMockImages(1)
const { container } = render(<ImageList images={images} size="md" />)
expect(container.firstChild).toBeInTheDocument()
})
it('should not show More button when images count equals limit', () => {
const images = createMockImages(9)
render(<ImageList images={images} size="md" limit={9} />)
expect(screen.queryByText(/\+/)).not.toBeInTheDocument()
})
it('should handle limit of 0', () => {
const images = createMockImages(5)
render(<ImageList images={images} size="md" limit={0} />)
// Should show "+5" for all images
expect(screen.getByText(/\+5/)).toBeInTheDocument()
})
it('should handle limit larger than images count', () => {
const images = createMockImages(5)
render(<ImageList images={images} size="md" limit={100} />)
// Should not show More button
expect(screen.queryByText(/\+/)).not.toBeInTheDocument()
})
})
})

View File

@ -1,144 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import More from './more'
describe('More', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<More count={5} />)
expect(screen.getByText('+5')).toBeInTheDocument()
})
it('should display count with plus sign', () => {
render(<More count={10} />)
expect(screen.getByText('+10')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should format count as-is when less than 1000', () => {
render(<More count={999} />)
expect(screen.getByText('+999')).toBeInTheDocument()
})
it('should format count with k suffix when 1000 or more', () => {
render(<More count={1500} />)
expect(screen.getByText('+1.5k')).toBeInTheDocument()
})
it('should format count with M suffix when 1000000 or more', () => {
render(<More count={2500000} />)
expect(screen.getByText('+2.5M')).toBeInTheDocument()
})
it('should format 1000 as 1.0k', () => {
render(<More count={1000} />)
expect(screen.getByText('+1.0k')).toBeInTheDocument()
})
it('should format 1000000 as 1.0M', () => {
render(<More count={1000000} />)
expect(screen.getByText('+1.0M')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick when clicked', () => {
const onClick = vi.fn()
render(<More count={5} onClick={onClick} />)
fireEvent.click(screen.getByText('+5'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('should not throw when clicked without onClick', () => {
render(<More count={5} />)
// Should not throw
expect(() => {
fireEvent.click(screen.getByText('+5'))
}).not.toThrow()
})
it('should stop event propagation on click', () => {
const parentClick = vi.fn()
const childClick = vi.fn()
render(
<div onClick={parentClick}>
<More count={5} onClick={childClick} />
</div>,
)
fireEvent.click(screen.getByText('+5'))
expect(childClick).toHaveBeenCalled()
expect(parentClick).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should display +0 when count is 0', () => {
render(<More count={0} />)
expect(screen.getByText('+0')).toBeInTheDocument()
})
it('should handle count of 1', () => {
render(<More count={1} />)
expect(screen.getByText('+1')).toBeInTheDocument()
})
it('should handle boundary value 999', () => {
render(<More count={999} />)
expect(screen.getByText('+999')).toBeInTheDocument()
})
it('should handle boundary value 999999', () => {
render(<More count={999999} />)
// 999999 / 1000 = 999.999 -> 1000.0k
expect(screen.getByText('+1000.0k')).toBeInTheDocument()
})
it('should apply cursor-pointer class', () => {
const { container } = render(<More count={5} />)
expect(container.firstChild).toHaveClass('cursor-pointer')
})
})
describe('formatNumber branches', () => {
it('should return "0" when num equals 0', () => {
// This covers line 11-12: if (num === 0) return '0'
render(<More count={0} />)
expect(screen.getByText('+0')).toBeInTheDocument()
})
it('should return num.toString() when num < 1000 and num > 0', () => {
// This covers line 13-14: if (num < 1000) return num.toString()
render(<More count={500} />)
expect(screen.getByText('+500')).toBeInTheDocument()
})
it('should return k format when 1000 <= num < 1000000', () => {
// This covers line 15-16
const { rerender } = render(<More count={5000} />)
expect(screen.getByText('+5.0k')).toBeInTheDocument()
rerender(<More count={999999} />)
expect(screen.getByText('+1000.0k')).toBeInTheDocument()
rerender(<More count={50000} />)
expect(screen.getByText('+50.0k')).toBeInTheDocument()
})
it('should return M format when num >= 1000000', () => {
// This covers line 17
const { rerender } = render(<More count={1000000} />)
expect(screen.getByText('+1.0M')).toBeInTheDocument()
rerender(<More count={5000000} />)
expect(screen.getByText('+5.0M')).toBeInTheDocument()
rerender(<More count={999999999} />)
expect(screen.getByText('+1000.0M')).toBeInTheDocument()
})
})
})

View File

@ -1,525 +0,0 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ImagePreviewer from './index'
// Mock fetch
const mockFetch = vi.fn()
globalThis.fetch = mockFetch
// Mock URL methods
const mockRevokeObjectURL = vi.fn()
const mockCreateObjectURL = vi.fn(() => 'blob:mock-url')
globalThis.URL.revokeObjectURL = mockRevokeObjectURL
globalThis.URL.createObjectURL = mockCreateObjectURL
// Mock Image
class MockImage {
onload: (() => void) | null = null
onerror: (() => void) | null = null
_src = ''
get src() {
return this._src
}
set src(value: string) {
this._src = value
// Trigger onload after a microtask
setTimeout(() => {
if (this.onload)
this.onload()
}, 0)
}
naturalWidth = 800
naturalHeight = 600
}
;(globalThis as unknown as { Image: typeof MockImage }).Image = MockImage
const createMockImages = () => [
{ url: 'https://example.com/image1.png', name: 'image1.png', size: 1024 },
{ url: 'https://example.com/image2.png', name: 'image2.png', size: 2048 },
{ url: 'https://example.com/image3.png', name: 'image3.png', size: 3072 },
]
describe('ImagePreviewer', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default successful fetch mock
mockFetch.mockResolvedValue({
ok: true,
blob: () => Promise.resolve(new Blob(['test'], { type: 'image/png' })),
})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
// Should render in portal
expect(document.body.querySelector('.image-previewer')).toBeInTheDocument()
})
it('should render close button', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
// Esc text should be visible
expect(screen.getByText('Esc')).toBeInTheDocument()
})
it('should show loading state initially', async () => {
const onClose = vi.fn()
const images = createMockImages()
// Delay fetch to see loading state
mockFetch.mockImplementation(() => new Promise(() => {}))
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
// Loading component should be visible
expect(document.body.querySelector('.image-previewer')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should start at initialIndex', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} initialIndex={1} onClose={onClose} />)
})
await waitFor(() => {
// Should start at second image
expect(screen.getByText('image2.png')).toBeInTheDocument()
})
})
it('should default initialIndex to 0', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
expect(screen.getByText('image1.png')).toBeInTheDocument()
})
})
})
describe('User Interactions', () => {
it('should call onClose when close button is clicked', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
// Find and click close button (the one with RiCloseLine icon)
const closeButton = document.querySelector('.absolute.right-6 button')
if (closeButton) {
fireEvent.click(closeButton)
expect(onClose).toHaveBeenCalledTimes(1)
}
})
it('should navigate to next image when next button is clicked', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
expect(screen.getByText('image1.png')).toBeInTheDocument()
})
// Find and click next button (right arrow)
const buttons = document.querySelectorAll('button')
const nextButton = Array.from(buttons).find(btn =>
btn.className.includes('right-8'),
)
if (nextButton) {
await act(async () => {
fireEvent.click(nextButton)
})
await waitFor(() => {
expect(screen.getByText('image2.png')).toBeInTheDocument()
})
}
})
it('should navigate to previous image when prev button is clicked', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} initialIndex={1} onClose={onClose} />)
})
await waitFor(() => {
expect(screen.getByText('image2.png')).toBeInTheDocument()
})
// Find and click prev button (left arrow)
const buttons = document.querySelectorAll('button')
const prevButton = Array.from(buttons).find(btn =>
btn.className.includes('left-8'),
)
if (prevButton) {
await act(async () => {
fireEvent.click(prevButton)
})
await waitFor(() => {
expect(screen.getByText('image1.png')).toBeInTheDocument()
})
}
})
it('should disable prev button at first image', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} initialIndex={0} onClose={onClose} />)
})
const buttons = document.querySelectorAll('button')
const prevButton = Array.from(buttons).find(btn =>
btn.className.includes('left-8'),
)
expect(prevButton).toBeDisabled()
})
it('should disable next button at last image', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} initialIndex={2} onClose={onClose} />)
})
const buttons = document.querySelectorAll('button')
const nextButton = Array.from(buttons).find(btn =>
btn.className.includes('right-8'),
)
expect(nextButton).toBeDisabled()
})
})
describe('Image Loading', () => {
it('should fetch images on mount', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled()
})
})
it('should show error state when fetch fails', async () => {
const onClose = vi.fn()
const images = createMockImages()
mockFetch.mockRejectedValue(new Error('Network error'))
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
expect(screen.getByText(/Failed to load image/)).toBeInTheDocument()
})
})
it('should show retry button on error', async () => {
const onClose = vi.fn()
const images = createMockImages()
mockFetch.mockRejectedValue(new Error('Network error'))
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
// Retry button should be visible
const retryButton = document.querySelector('button.rounded-full')
expect(retryButton).toBeInTheDocument()
})
})
})
describe('Navigation Boundary Cases', () => {
it('should not navigate past first image when prevImage is called at index 0', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} initialIndex={0} onClose={onClose} />)
})
await waitFor(() => {
expect(screen.getByText('image1.png')).toBeInTheDocument()
})
// Click prev button multiple times - should stay at first image
const buttons = document.querySelectorAll('button')
const prevButton = Array.from(buttons).find(btn =>
btn.className.includes('left-8'),
)
if (prevButton) {
await act(async () => {
fireEvent.click(prevButton)
fireEvent.click(prevButton)
})
// Should still be at first image
await waitFor(() => {
expect(screen.getByText('image1.png')).toBeInTheDocument()
})
}
})
it('should not navigate past last image when nextImage is called at last index', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} initialIndex={2} onClose={onClose} />)
})
await waitFor(() => {
expect(screen.getByText('image3.png')).toBeInTheDocument()
})
// Click next button multiple times - should stay at last image
const buttons = document.querySelectorAll('button')
const nextButton = Array.from(buttons).find(btn =>
btn.className.includes('right-8'),
)
if (nextButton) {
await act(async () => {
fireEvent.click(nextButton)
fireEvent.click(nextButton)
})
// Should still be at last image
await waitFor(() => {
expect(screen.getByText('image3.png')).toBeInTheDocument()
})
}
})
})
describe('Retry Functionality', () => {
it('should retry image load when retry button is clicked', async () => {
const onClose = vi.fn()
const images = createMockImages()
// First fail, then succeed
let callCount = 0
mockFetch.mockImplementation(() => {
callCount++
if (callCount === 1) {
return Promise.reject(new Error('Network error'))
}
return Promise.resolve({
ok: true,
blob: () => Promise.resolve(new Blob(['test'], { type: 'image/png' })),
})
})
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
// Wait for error state
await waitFor(() => {
expect(screen.getByText(/Failed to load image/)).toBeInTheDocument()
})
// Click retry button
const retryButton = document.querySelector('button.rounded-full')
if (retryButton) {
await act(async () => {
fireEvent.click(retryButton)
})
// Should refetch the image
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledTimes(4) // 3 initial + 1 retry
})
}
})
it('should show retry button and call retryImage when clicked', async () => {
const onClose = vi.fn()
const images = createMockImages()
mockFetch.mockRejectedValue(new Error('Network error'))
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
expect(screen.getByText(/Failed to load image/)).toBeInTheDocument()
})
// Find and click the retry button (not the nav buttons)
const allButtons = document.querySelectorAll('button')
const retryButton = Array.from(allButtons).find(btn =>
btn.className.includes('rounded-full') && !btn.className.includes('left-8') && !btn.className.includes('right-8'),
)
expect(retryButton).toBeInTheDocument()
if (retryButton) {
mockFetch.mockClear()
mockFetch.mockResolvedValue({
ok: true,
blob: () => Promise.resolve(new Blob(['test'], { type: 'image/png' })),
})
await act(async () => {
fireEvent.click(retryButton)
})
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled()
})
}
})
})
describe('Image Cache', () => {
it('should clean up blob URLs on unmount', async () => {
const onClose = vi.fn()
const images = createMockImages()
// First render to populate cache
const { unmount } = await act(async () => {
const result = render(<ImagePreviewer images={images} onClose={onClose} />)
return result
})
await waitFor(() => {
expect(mockFetch).toHaveBeenCalled()
})
// Store the call count for verification
const _firstCallCount = mockFetch.mock.calls.length
unmount()
// Note: The imageCache is cleared on unmount, so this test verifies
// the cleanup behavior rather than caching across mounts
expect(mockRevokeObjectURL).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle single image', async () => {
const onClose = vi.fn()
const images = [createMockImages()[0]]
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
// Both navigation buttons should be disabled
const buttons = document.querySelectorAll('button')
const prevButton = Array.from(buttons).find(btn =>
btn.className.includes('left-8'),
)
const nextButton = Array.from(buttons).find(btn =>
btn.className.includes('right-8'),
)
expect(prevButton).toBeDisabled()
expect(nextButton).toBeDisabled()
})
it('should stop event propagation on container click', async () => {
const onClose = vi.fn()
const parentClick = vi.fn()
const images = createMockImages()
await act(async () => {
render(
<div onClick={parentClick}>
<ImagePreviewer images={images} onClose={onClose} />
</div>,
)
})
const container = document.querySelector('.image-previewer')
if (container) {
fireEvent.click(container)
expect(parentClick).not.toHaveBeenCalled()
}
})
it('should display image dimensions when loaded', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
// Should display dimensions (800 × 600 from MockImage)
expect(screen.getByText(/800.*600/)).toBeInTheDocument()
})
})
it('should display file size', async () => {
const onClose = vi.fn()
const images = createMockImages()
await act(async () => {
render(<ImagePreviewer images={images} onClose={onClose} />)
})
await waitFor(() => {
// Should display formatted file size
expect(screen.getByText('image1.png')).toBeInTheDocument()
})
})
})
})

View File

@ -1,922 +0,0 @@
import type { PropsWithChildren } from 'react'
import type { FileEntity } from '../types'
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { FileContextProvider } from '../store'
import { useUpload } from './use-upload'
// Mock dependencies
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {
image_file_batch_limit: 10,
single_chunk_attachment_limit: 20,
attachment_image_file_size_limit: 15,
},
})),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
type FileUploadOptions = {
file: File
onProgressCallback?: (progress: number) => void
onSuccessCallback?: (res: { id: string, extension: string, mime_type: string, size: number }) => void
onErrorCallback?: (error?: Error) => void
}
const mockFileUpload = vi.fn<(options: FileUploadOptions) => void>()
const mockGetFileUploadErrorMessage = vi.fn(() => 'Upload error')
vi.mock('@/app/components/base/file-uploader/utils', () => ({
fileUpload: (options: FileUploadOptions) => mockFileUpload(options),
getFileUploadErrorMessage: () => mockGetFileUploadErrorMessage(),
}))
const createWrapper = () => {
return ({ children }: PropsWithChildren) => (
<FileContextProvider>
{children}
</FileContextProvider>
)
}
const createMockFile = (name = 'test.png', _size = 1024, type = 'image/png') => {
return new File(['test content'], name, { type })
}
// Mock FileReader
type EventCallback = () => void
class MockFileReader {
result: string | ArrayBuffer | null = null
onload: EventCallback | null = null
onerror: EventCallback | null = null
private listeners: Record<string, EventCallback[]> = {}
addEventListener(event: string, callback: EventCallback) {
if (!this.listeners[event])
this.listeners[event] = []
this.listeners[event].push(callback)
}
removeEventListener(event: string, callback: EventCallback) {
if (this.listeners[event])
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback)
}
readAsDataURL(_file: File) {
setTimeout(() => {
this.result = 'data:image/png;base64,mockBase64Data'
this.listeners.load?.forEach(cb => cb())
}, 0)
}
triggerError() {
this.listeners.error?.forEach(cb => cb())
}
}
describe('useUpload hook', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFileUpload.mockImplementation(({ onSuccessCallback }) => {
setTimeout(() => {
onSuccessCallback?.({ id: 'uploaded-id', extension: 'png', mime_type: 'image/png', size: 1024 })
}, 0)
})
// Mock FileReader globally
vi.stubGlobal('FileReader', MockFileReader)
})
describe('Initialization', () => {
it('should initialize with default state', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(result.current.dragging).toBe(false)
expect(result.current.uploaderRef).toBeDefined()
expect(result.current.dragRef).toBeDefined()
expect(result.current.dropRef).toBeDefined()
})
it('should return file upload config', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(result.current.fileUploadConfig).toBeDefined()
expect(result.current.fileUploadConfig.imageFileBatchLimit).toBe(10)
expect(result.current.fileUploadConfig.singleChunkAttachmentLimit).toBe(20)
expect(result.current.fileUploadConfig.imageFileSizeLimit).toBe(15)
})
})
describe('File Operations', () => {
it('should expose selectHandle function', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(typeof result.current.selectHandle).toBe('function')
})
it('should expose fileChangeHandle function', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(typeof result.current.fileChangeHandle).toBe('function')
})
it('should expose handleRemoveFile function', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(typeof result.current.handleRemoveFile).toBe('function')
})
it('should expose handleReUploadFile function', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(typeof result.current.handleReUploadFile).toBe('function')
})
it('should expose handleLocalFileUpload function', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(typeof result.current.handleLocalFileUpload).toBe('function')
})
})
describe('File Validation', () => {
it('should show error toast for invalid file type', async () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
const mockEvent = {
target: {
files: [createMockFile('test.exe', 1024, 'application/x-msdownload')],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(mockEvent)
})
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
})
it('should not reject valid image file types', async () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
const mockFile = createMockFile('test.png', 1024, 'image/png')
const mockEvent = {
target: {
files: [mockFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
// File type validation should pass for png files
// The actual upload will fail without proper FileReader mock,
// but we're testing that type validation doesn't reject valid files
act(() => {
result.current.fileChangeHandle(mockEvent)
})
// Should not show type error for valid image type
type ToastCall = [{ type: string, message: string }]
const mockNotify = vi.mocked(Toast.notify)
const calls = mockNotify.mock.calls as ToastCall[]
const typeErrorCalls = calls.filter(
(call: ToastCall) => call[0].type === 'error' && call[0].message.includes('Extension'),
)
expect(typeErrorCalls.length).toBe(0)
})
})
describe('Drag and Drop Refs', () => {
it('should provide dragRef', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(result.current.dragRef).toBeDefined()
expect(result.current.dragRef.current).toBeNull()
})
it('should provide dropRef', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(result.current.dropRef).toBeDefined()
expect(result.current.dropRef.current).toBeNull()
})
it('should provide uploaderRef', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(result.current.uploaderRef).toBeDefined()
expect(result.current.uploaderRef.current).toBeNull()
})
})
describe('Edge Cases', () => {
it('should handle empty file list', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
const mockEvent = {
target: {
files: [],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(mockEvent)
})
// Should not throw and not show error
expect(Toast.notify).not.toHaveBeenCalled()
})
it('should handle null files', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
const mockEvent = {
target: {
files: null,
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(mockEvent)
})
// Should not throw
expect(true).toBe(true)
})
it('should respect batch limit from config', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
// Config should have batch limit of 10
expect(result.current.fileUploadConfig.imageFileBatchLimit).toBe(10)
})
})
describe('File Size Validation', () => {
it('should show error for files exceeding size limit', async () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
// Create a file larger than 15MB limit (15 * 1024 * 1024 bytes)
const largeFile = new File(['x'.repeat(16 * 1024 * 1024)], 'large.png', { type: 'image/png' })
Object.defineProperty(largeFile, 'size', { value: 16 * 1024 * 1024 })
const mockEvent = {
target: {
files: [largeFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(mockEvent)
})
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
})
})
describe('handleRemoveFile', () => {
it('should remove file from store', async () => {
const onChange = vi.fn()
const initialFiles: Partial<FileEntity>[] = [
{ id: 'file1', name: 'test1.png', progress: 100 },
{ id: 'file2', name: 'test2.png', progress: 100 },
]
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider value={initialFiles as FileEntity[]} onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
act(() => {
result.current.handleRemoveFile('file1')
})
expect(onChange).toHaveBeenCalledWith([
{ id: 'file2', name: 'test2.png', progress: 100 },
])
})
})
describe('handleReUploadFile', () => {
it('should re-upload file when called with valid fileId', async () => {
const onChange = vi.fn()
const initialFiles: Partial<FileEntity>[] = [
{ id: 'file1', name: 'test1.png', progress: -1, originalFile: new File(['test'], 'test1.png') },
]
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider value={initialFiles as FileEntity[]} onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
act(() => {
result.current.handleReUploadFile('file1')
})
await waitFor(() => {
expect(mockFileUpload).toHaveBeenCalled()
})
})
it('should not re-upload when fileId is not found', () => {
const onChange = vi.fn()
const initialFiles: Partial<FileEntity>[] = [
{ id: 'file1', name: 'test1.png', progress: -1, originalFile: new File(['test'], 'test1.png') },
]
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider value={initialFiles as FileEntity[]} onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
act(() => {
result.current.handleReUploadFile('nonexistent')
})
// fileUpload should not be called for nonexistent file
expect(mockFileUpload).not.toHaveBeenCalled()
})
it('should handle upload error during re-upload', async () => {
mockFileUpload.mockImplementation(({ onErrorCallback }: FileUploadOptions) => {
setTimeout(() => {
onErrorCallback?.(new Error('Upload failed'))
}, 0)
})
const onChange = vi.fn()
const initialFiles: Partial<FileEntity>[] = [
{ id: 'file1', name: 'test1.png', progress: -1, originalFile: new File(['test'], 'test1.png') },
]
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider value={initialFiles as FileEntity[]} onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
act(() => {
result.current.handleReUploadFile('file1')
})
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Upload error',
})
})
})
})
describe('handleLocalFileUpload', () => {
it('should upload file and update progress', async () => {
mockFileUpload.mockImplementation(({ onProgressCallback, onSuccessCallback }: FileUploadOptions) => {
setTimeout(() => {
onProgressCallback?.(50)
setTimeout(() => {
onSuccessCallback?.({ id: 'uploaded-id', extension: 'png', mime_type: 'image/png', size: 1024 })
}, 10)
}, 0)
})
const onChange = vi.fn()
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
const mockFile = createMockFile('test.png', 1024, 'image/png')
await act(async () => {
result.current.handleLocalFileUpload(mockFile)
})
await waitFor(() => {
expect(mockFileUpload).toHaveBeenCalled()
})
})
it('should handle upload error', async () => {
mockFileUpload.mockImplementation(({ onErrorCallback }: FileUploadOptions) => {
setTimeout(() => {
onErrorCallback?.(new Error('Upload failed'))
}, 0)
})
const onChange = vi.fn()
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
const mockFile = createMockFile('test.png', 1024, 'image/png')
await act(async () => {
result.current.handleLocalFileUpload(mockFile)
})
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Upload error',
})
})
})
})
describe('Attachment Limit', () => {
it('should show error when exceeding single chunk attachment limit', async () => {
const onChange = vi.fn()
// Pre-populate with 19 files (limit is 20)
const initialFiles: Partial<FileEntity>[] = Array.from({ length: 19 }, (_, i) => ({
id: `file${i}`,
name: `test${i}.png`,
progress: 100,
}))
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider value={initialFiles as FileEntity[]} onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
// Try to add 2 more files (would exceed limit of 20)
const mockEvent = {
target: {
files: [
createMockFile('new1.png'),
createMockFile('new2.png'),
],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(mockEvent)
})
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
})
})
describe('selectHandle', () => {
it('should trigger click on uploader input when called', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
// Create a mock input element
const mockInput = document.createElement('input')
const clickSpy = vi.spyOn(mockInput, 'click')
// Manually set the ref
Object.defineProperty(result.current.uploaderRef, 'current', {
value: mockInput,
writable: true,
})
act(() => {
result.current.selectHandle()
})
expect(clickSpy).toHaveBeenCalled()
})
it('should not throw when uploaderRef is null', () => {
const { result } = renderHook(() => useUpload(), {
wrapper: createWrapper(),
})
expect(() => {
act(() => {
result.current.selectHandle()
})
}).not.toThrow()
})
})
describe('FileReader Error Handling', () => {
it('should show error toast when FileReader encounters an error', async () => {
// Create a custom MockFileReader that triggers error
class ErrorFileReader {
result: string | ArrayBuffer | null = null
private listeners: Record<string, EventCallback[]> = {}
addEventListener(event: string, callback: EventCallback) {
if (!this.listeners[event])
this.listeners[event] = []
this.listeners[event].push(callback)
}
removeEventListener(event: string, callback: EventCallback) {
if (this.listeners[event])
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback)
}
readAsDataURL(_file: File) {
// Trigger error instead of load
setTimeout(() => {
this.listeners.error?.forEach(cb => cb())
}, 0)
}
}
vi.stubGlobal('FileReader', ErrorFileReader)
const onChange = vi.fn()
const wrapper = ({ children }: PropsWithChildren) => (
<FileContextProvider onChange={onChange}>
{children}
</FileContextProvider>
)
const { result } = renderHook(() => useUpload(), { wrapper })
const mockFile = createMockFile('test.png', 1024, 'image/png')
await act(async () => {
result.current.handleLocalFileUpload(mockFile)
})
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
// Restore original MockFileReader
vi.stubGlobal('FileReader', MockFileReader)
})
})
describe('Drag and Drop Functionality', () => {
// Test component that renders the hook with actual DOM elements
const TestComponent = ({ onStateChange }: { onStateChange?: (dragging: boolean) => void }) => {
const { dragging, dragRef, dropRef } = useUpload()
// Report dragging state changes to parent
React.useEffect(() => {
onStateChange?.(dragging)
}, [dragging, onStateChange])
return (
<div ref={dropRef} data-testid="drop-zone">
<div ref={dragRef} data-testid="drag-boundary">
<span data-testid="dragging-state">{dragging ? 'dragging' : 'not-dragging'}</span>
</div>
</div>
)
}
it('should set dragging to true on dragEnter when target is not dragRef', async () => {
const onStateChange = vi.fn()
render(
<FileContextProvider>
<TestComponent onStateChange={onStateChange} />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
// Fire dragenter event on dropZone (not dragRef)
await act(async () => {
fireEvent.dragEnter(dropZone, {
dataTransfer: { items: [] },
})
})
// Verify dragging state changed to true
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
})
it('should set dragging to false on dragLeave when target matches dragRef', async () => {
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
const dragBoundary = screen.getByTestId('drag-boundary')
// First trigger dragenter to set dragging to true
await act(async () => {
fireEvent.dragEnter(dropZone, {
dataTransfer: { items: [] },
})
})
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
// Then trigger dragleave on dragBoundary to set dragging to false
await act(async () => {
fireEvent.dragLeave(dragBoundary, {
dataTransfer: { items: [] },
})
})
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
})
it('should handle drop event with files and reset dragging state', async () => {
const onChange = vi.fn()
render(
<FileContextProvider onChange={onChange}>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
const mockFile = new File(['test content'], 'test.png', { type: 'image/png' })
// First trigger dragenter
await act(async () => {
fireEvent.dragEnter(dropZone, {
dataTransfer: { items: [] },
})
})
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
// Then trigger drop with files
await act(async () => {
fireEvent.drop(dropZone, {
dataTransfer: {
items: [{
webkitGetAsEntry: () => null,
getAsFile: () => mockFile,
}],
},
})
})
// Dragging should be reset to false after drop
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
})
it('should return early when dataTransfer is null on drop', async () => {
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
// Fire dragenter first
await act(async () => {
fireEvent.dragEnter(dropZone)
})
// Fire drop without dataTransfer
await act(async () => {
fireEvent.drop(dropZone)
})
// Should still reset dragging state
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
})
it('should not trigger file upload for invalid file types on drop', async () => {
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
const invalidFile = new File(['test'], 'test.exe', { type: 'application/x-msdownload' })
await act(async () => {
fireEvent.drop(dropZone, {
dataTransfer: {
items: [{
webkitGetAsEntry: () => null,
getAsFile: () => invalidFile,
}],
},
})
})
// Should show error toast for invalid file type
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
})
it('should handle drop with webkitGetAsEntry for file entries', async () => {
const onChange = vi.fn()
const mockFile = new File(['test'], 'test.png', { type: 'image/png' })
render(
<FileContextProvider onChange={onChange}>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
// Create a mock file entry that simulates webkitGetAsEntry behavior
const mockFileEntry = {
isFile: true,
isDirectory: false,
file: (callback: (file: File) => void) => callback(mockFile),
}
await act(async () => {
fireEvent.drop(dropZone, {
dataTransfer: {
items: [{
webkitGetAsEntry: () => mockFileEntry,
getAsFile: () => mockFile,
}],
},
})
})
// Dragging should be reset
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
})
})
describe('Drag Events', () => {
const TestComponent = () => {
const { dragging, dragRef, dropRef } = useUpload()
return (
<div ref={dropRef} data-testid="drop-zone">
<div ref={dragRef} data-testid="drag-boundary">
<span data-testid="dragging-state">{dragging ? 'dragging' : 'not-dragging'}</span>
</div>
</div>
)
}
it('should handle dragEnter event and update dragging state', async () => {
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
// Initially not dragging
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
// Fire dragEnter
await act(async () => {
fireEvent.dragEnter(dropZone, {
dataTransfer: { items: [] },
})
})
// Should be dragging now
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
})
it('should handle dragOver event without changing state', async () => {
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
// First trigger dragenter to set dragging
await act(async () => {
fireEvent.dragEnter(dropZone)
})
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
// dragOver should not change the dragging state
await act(async () => {
fireEvent.dragOver(dropZone)
})
// Should still be dragging
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
})
it('should not set dragging to true when dragEnter target is dragRef', async () => {
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
const dragBoundary = screen.getByTestId('drag-boundary')
// Fire dragEnter directly on dragRef
await act(async () => {
fireEvent.dragEnter(dragBoundary)
})
// Should not be dragging when target is dragRef itself
expect(screen.getByTestId('dragging-state')).toHaveTextContent('not-dragging')
})
it('should not set dragging to false when dragLeave target is not dragRef', async () => {
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
const dropZone = screen.getByTestId('drop-zone')
// First trigger dragenter on dropZone to set dragging
await act(async () => {
fireEvent.dragEnter(dropZone)
})
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
// dragLeave on dropZone (not dragRef) should not change dragging state
await act(async () => {
fireEvent.dragLeave(dropZone)
})
// Should still be dragging (only dragLeave on dragRef resets)
expect(screen.getByTestId('dragging-state')).toHaveTextContent('dragging')
})
})
})

View File

@ -1,107 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FileContextProvider } from '../store'
import ImageInput from './image-input'
// Mock dependencies
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {
image_file_batch_limit: 10,
single_chunk_attachment_limit: 20,
attachment_image_file_size_limit: 15,
},
})),
}))
const renderWithProvider = (ui: React.ReactElement) => {
return render(
<FileContextProvider>
{ui}
</FileContextProvider>,
)
}
describe('ImageInput (image-uploader-in-chunk)', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = renderWithProvider(<ImageInput />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render file input element', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toBeInTheDocument()
})
it('should have hidden file input', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toHaveClass('hidden')
})
it('should render upload icon', () => {
const { container } = renderWithProvider(<ImageInput />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
it('should render browse text', () => {
renderWithProvider(<ImageInput />)
expect(screen.getByText(/browse/i)).toBeInTheDocument()
})
})
describe('File Input Props', () => {
it('should accept multiple files', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toHaveAttribute('multiple')
})
it('should have accept attribute for images', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toHaveAttribute('accept')
})
})
describe('User Interactions', () => {
it('should open file dialog when browse is clicked', () => {
renderWithProvider(<ImageInput />)
const browseText = screen.getByText(/browse/i)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click')
fireEvent.click(browseText)
expect(clickSpy).toHaveBeenCalled()
})
})
describe('Drag and Drop', () => {
it('should have drop zone area', () => {
const { container } = renderWithProvider(<ImageInput />)
// The drop zone has dashed border styling
expect(container.querySelector('.border-dashed')).toBeInTheDocument()
})
it('should apply accent styles when dragging', () => {
// This would require simulating drag events
// Just verify the base structure exists
const { container } = renderWithProvider(<ImageInput />)
expect(container.querySelector('.border-components-dropzone-border')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should display file size limit from config', () => {
renderWithProvider(<ImageInput />)
// The tip text should contain the size limit (15 from mock)
const tipText = document.querySelector('.system-xs-regular')
expect(tipText).toBeInTheDocument()
})
})
})

View File

@ -1,198 +0,0 @@
import type { FileEntity } from '../types'
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ImageItem from './image-item'
const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'test-id',
name: 'test.png',
progress: 100,
base64Url: 'data:image/png;base64,test',
sourceUrl: 'https://example.com/test.png',
size: 1024,
...overrides,
} as FileEntity)
describe('ImageItem (image-uploader-in-chunk)', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render image preview', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} />)
// FileImageRender component should be present
expect(container.querySelector('.group\\/file-image')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should show delete button when showDeleteAction is true', () => {
const file = createMockFile()
const { container } = render(
<ImageItem file={file} showDeleteAction onRemove={() => {}} />,
)
// Delete button has RiCloseLine icon
const deleteButton = container.querySelector('button')
expect(deleteButton).toBeInTheDocument()
})
it('should not show delete button when showDeleteAction is false', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} showDeleteAction={false} />)
const deleteButton = container.querySelector('button')
expect(deleteButton).not.toBeInTheDocument()
})
it('should use base64Url for image when available', () => {
const file = createMockFile({ base64Url: 'data:image/png;base64,custom' })
const { container } = render(<ImageItem file={file} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should fallback to sourceUrl when base64Url is not available', () => {
const file = createMockFile({ base64Url: undefined })
const { container } = render(<ImageItem file={file} />)
expect(container.firstChild).toBeInTheDocument()
})
})
describe('Progress States', () => {
it('should show progress indicator when progress is between 0 and 99', () => {
const file = createMockFile({ progress: 50, uploadedId: undefined })
const { container } = render(<ImageItem file={file} />)
// Progress circle should be visible
expect(container.querySelector('.bg-background-overlay-alt')).toBeInTheDocument()
})
it('should not show progress indicator when upload is complete', () => {
const file = createMockFile({ progress: 100, uploadedId: 'uploaded-123' })
const { container } = render(<ImageItem file={file} />)
expect(container.querySelector('.bg-background-overlay-alt')).not.toBeInTheDocument()
})
it('should show retry button when progress is -1 (error)', () => {
const file = createMockFile({ progress: -1 })
const { container } = render(<ImageItem file={file} />)
// Error state shows destructive overlay
expect(container.querySelector('.bg-background-overlay-destructive')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onPreview when image is clicked', () => {
const onPreview = vi.fn()
const file = createMockFile()
const { container } = render(<ImageItem file={file} onPreview={onPreview} />)
const imageContainer = container.querySelector('.group\\/file-image')
if (imageContainer) {
fireEvent.click(imageContainer)
expect(onPreview).toHaveBeenCalledWith('test-id')
}
})
it('should call onRemove when delete button is clicked', () => {
const onRemove = vi.fn()
const file = createMockFile()
const { container } = render(
<ImageItem file={file} showDeleteAction onRemove={onRemove} />,
)
const deleteButton = container.querySelector('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(onRemove).toHaveBeenCalledWith('test-id')
}
})
it('should call onReUpload when error overlay is clicked', () => {
const onReUpload = vi.fn()
const file = createMockFile({ progress: -1 })
const { container } = render(<ImageItem file={file} onReUpload={onReUpload} />)
const errorOverlay = container.querySelector('.bg-background-overlay-destructive')
if (errorOverlay) {
fireEvent.click(errorOverlay)
expect(onReUpload).toHaveBeenCalledWith('test-id')
}
})
it('should stop event propagation on delete button click', () => {
const onRemove = vi.fn()
const onPreview = vi.fn()
const file = createMockFile()
const { container } = render(
<ImageItem file={file} showDeleteAction onRemove={onRemove} onPreview={onPreview} />,
)
const deleteButton = container.querySelector('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(onRemove).toHaveBeenCalled()
expect(onPreview).not.toHaveBeenCalled()
}
})
it('should stop event propagation on retry click', () => {
const onReUpload = vi.fn()
const onPreview = vi.fn()
const file = createMockFile({ progress: -1 })
const { container } = render(
<ImageItem file={file} onReUpload={onReUpload} onPreview={onPreview} />,
)
const errorOverlay = container.querySelector('.bg-background-overlay-destructive')
if (errorOverlay) {
fireEvent.click(errorOverlay)
expect(onReUpload).toHaveBeenCalled()
// onPreview should not be called due to stopPropagation
}
})
})
describe('Edge Cases', () => {
it('should handle missing onPreview callback', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} />)
const imageContainer = container.querySelector('.group\\/file-image')
expect(() => {
if (imageContainer)
fireEvent.click(imageContainer)
}).not.toThrow()
})
it('should handle missing onRemove callback', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} showDeleteAction />)
const deleteButton = container.querySelector('button')
expect(() => {
if (deleteButton)
fireEvent.click(deleteButton)
}).not.toThrow()
})
it('should handle missing onReUpload callback', () => {
const file = createMockFile({ progress: -1 })
const { container } = render(<ImageItem file={file} />)
const errorOverlay = container.querySelector('.bg-background-overlay-destructive')
expect(() => {
if (errorOverlay)
fireEvent.click(errorOverlay)
}).not.toThrow()
})
it('should handle progress of 0', () => {
const file = createMockFile({ progress: 0 })
const { container } = render(<ImageItem file={file} />)
// Progress overlay should be visible at 0%
expect(container.querySelector('.bg-background-overlay-alt')).toBeInTheDocument()
})
})
})

View File

@ -1,167 +0,0 @@
import type { FileEntity } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ImageUploaderInChunkWrapper from './index'
// Mock dependencies
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {
image_file_batch_limit: 10,
single_chunk_attachment_limit: 20,
attachment_image_file_size_limit: 15,
},
})),
}))
vi.mock('@/app/components/datasets/common/image-previewer', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="image-previewer">
<button data-testid="close-preview" onClick={onClose}>Close</button>
</div>
),
}))
describe('ImageUploaderInChunk', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const onChange = vi.fn()
const { container } = render(
<ImageUploaderInChunkWrapper value={[]} onChange={onChange} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render ImageInput when not disabled', () => {
const onChange = vi.fn()
render(<ImageUploaderInChunkWrapper value={[]} onChange={onChange} />)
// ImageInput renders an input element
expect(document.querySelector('input[type="file"]')).toBeInTheDocument()
})
it('should not render ImageInput when disabled', () => {
const onChange = vi.fn()
render(<ImageUploaderInChunkWrapper value={[]} onChange={onChange} disabled />)
// ImageInput should not be present
expect(document.querySelector('input[type="file"]')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const onChange = vi.fn()
const { container } = render(
<ImageUploaderInChunkWrapper
value={[]}
onChange={onChange}
className="custom-class"
/>,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should render files when value is provided', () => {
const onChange = vi.fn()
const files: FileEntity[] = [
{
id: 'file1',
name: 'test1.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
base64Url: 'data:image/png;base64,test1',
size: 1024,
},
{
id: 'file2',
name: 'test2.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
base64Url: 'data:image/png;base64,test2',
size: 2048,
},
]
render(<ImageUploaderInChunkWrapper value={files} onChange={onChange} />)
// Each file renders an ImageItem
const fileItems = document.querySelectorAll('.group\\/file-image')
expect(fileItems.length).toBeGreaterThanOrEqual(2)
})
})
describe('User Interactions', () => {
it('should show preview when image is clicked', () => {
const onChange = vi.fn()
const files: FileEntity[] = [
{
id: 'file1',
name: 'test.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: 'uploaded-1',
base64Url: 'data:image/png;base64,test',
size: 1024,
},
]
render(<ImageUploaderInChunkWrapper value={files} onChange={onChange} />)
// Find and click the file item
const fileItem = document.querySelector('.group\\/file-image')
if (fileItem) {
fireEvent.click(fileItem)
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
}
})
it('should close preview when close button is clicked', () => {
const onChange = vi.fn()
const files: FileEntity[] = [
{
id: 'file1',
name: 'test.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: 'uploaded-1',
base64Url: 'data:image/png;base64,test',
size: 1024,
},
]
render(<ImageUploaderInChunkWrapper value={files} onChange={onChange} />)
// Open preview
const fileItem = document.querySelector('.group\\/file-image')
if (fileItem) {
fireEvent.click(fileItem)
// Close preview
const closeButton = screen.getByTestId('close-preview')
fireEvent.click(closeButton)
expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument()
}
})
})
describe('Edge Cases', () => {
it('should handle empty files array', () => {
const onChange = vi.fn()
const { container } = render(
<ImageUploaderInChunkWrapper value={[]} onChange={onChange} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle undefined value', () => {
const onChange = vi.fn()
const { container } = render(
<ImageUploaderInChunkWrapper value={undefined} onChange={onChange} />,
)
expect(container.firstChild).toBeInTheDocument()
})
})
})

View File

@ -1,125 +0,0 @@
import type { FileEntity } from '../types'
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FileContextProvider } from '../store'
import ImageInput from './image-input'
// Mock dependencies
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {
image_file_batch_limit: 10,
single_chunk_attachment_limit: 20,
attachment_image_file_size_limit: 15,
},
})),
}))
const renderWithProvider = (ui: React.ReactElement, initialFiles: FileEntity[] = []) => {
return render(
<FileContextProvider value={initialFiles}>
{ui}
</FileContextProvider>,
)
}
describe('ImageInput (image-uploader-in-retrieval-testing)', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = renderWithProvider(<ImageInput />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render file input element', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toBeInTheDocument()
})
it('should have hidden file input', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toHaveClass('hidden')
})
it('should render add image icon', () => {
const { container } = renderWithProvider(<ImageInput />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
it('should show tip text when no files are uploaded', () => {
renderWithProvider(<ImageInput />)
// Tip text should be visible
expect(document.querySelector('.system-sm-regular')).toBeInTheDocument()
})
it('should hide tip text when files exist', () => {
const files: FileEntity[] = [
{
id: 'file1',
name: 'test.png',
extension: 'png',
mimeType: 'image/png',
size: 1024,
progress: 100,
uploadedId: 'uploaded-1',
},
]
renderWithProvider(<ImageInput />, files)
// Tip text should not be visible
expect(document.querySelector('.text-text-quaternary')).not.toBeInTheDocument()
})
})
describe('File Input Props', () => {
it('should accept multiple files', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toHaveAttribute('multiple')
})
it('should have accept attribute', () => {
renderWithProvider(<ImageInput />)
const input = document.querySelector('input[type="file"]')
expect(input).toHaveAttribute('accept')
})
})
describe('User Interactions', () => {
it('should open file dialog when icon is clicked', () => {
renderWithProvider(<ImageInput />)
const clickableArea = document.querySelector('.cursor-pointer')
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click')
if (clickableArea)
fireEvent.click(clickableArea)
expect(clickSpy).toHaveBeenCalled()
})
})
describe('Tooltip', () => {
it('should have tooltip component', () => {
const { container } = renderWithProvider(<ImageInput />)
// Tooltip wrapper should exist
expect(container.firstChild).toBeInTheDocument()
})
it('should disable tooltip when no files exist', () => {
// When files.length === 0, tooltip should be disabled
renderWithProvider(<ImageInput />)
// Component renders with tip text visible instead of tooltip
expect(document.querySelector('.system-sm-regular')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should render icon container with correct styling', () => {
const { container } = renderWithProvider(<ImageInput />)
expect(container.querySelector('.border-dashed')).toBeInTheDocument()
})
})
})

View File

@ -1,149 +0,0 @@
import type { FileEntity } from '../types'
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ImageItem from './image-item'
const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'test-id',
name: 'test.png',
progress: 100,
base64Url: 'data:image/png;base64,test',
sourceUrl: 'https://example.com/test.png',
size: 1024,
...overrides,
} as FileEntity)
describe('ImageItem (image-uploader-in-retrieval-testing)', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render with size-20 class', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} />)
expect(container.querySelector('.size-20')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should show delete button when showDeleteAction is true', () => {
const file = createMockFile()
const { container } = render(
<ImageItem file={file} showDeleteAction onRemove={() => {}} />,
)
const deleteButton = container.querySelector('button')
expect(deleteButton).toBeInTheDocument()
})
it('should not show delete button when showDeleteAction is false', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} showDeleteAction={false} />)
const deleteButton = container.querySelector('button')
expect(deleteButton).not.toBeInTheDocument()
})
})
describe('Progress States', () => {
it('should show progress indicator when uploading', () => {
const file = createMockFile({ progress: 50, uploadedId: undefined })
const { container } = render(<ImageItem file={file} />)
expect(container.querySelector('.bg-background-overlay-alt')).toBeInTheDocument()
})
it('should not show progress indicator when upload is complete', () => {
const file = createMockFile({ progress: 100, uploadedId: 'uploaded-123' })
const { container } = render(<ImageItem file={file} />)
expect(container.querySelector('.bg-background-overlay-alt')).not.toBeInTheDocument()
})
it('should show error overlay when progress is -1', () => {
const file = createMockFile({ progress: -1 })
const { container } = render(<ImageItem file={file} />)
expect(container.querySelector('.bg-background-overlay-destructive')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onPreview when clicked', () => {
const onPreview = vi.fn()
const file = createMockFile()
const { container } = render(<ImageItem file={file} onPreview={onPreview} />)
const imageContainer = container.querySelector('.group\\/file-image')
if (imageContainer) {
fireEvent.click(imageContainer)
expect(onPreview).toHaveBeenCalledWith('test-id')
}
})
it('should call onRemove when delete button is clicked', () => {
const onRemove = vi.fn()
const file = createMockFile()
const { container } = render(
<ImageItem file={file} showDeleteAction onRemove={onRemove} />,
)
const deleteButton = container.querySelector('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(onRemove).toHaveBeenCalledWith('test-id')
}
})
it('should call onReUpload when error overlay is clicked', () => {
const onReUpload = vi.fn()
const file = createMockFile({ progress: -1 })
const { container } = render(<ImageItem file={file} onReUpload={onReUpload} />)
const errorOverlay = container.querySelector('.bg-background-overlay-destructive')
if (errorOverlay) {
fireEvent.click(errorOverlay)
expect(onReUpload).toHaveBeenCalledWith('test-id')
}
})
it('should stop propagation on delete click', () => {
const onRemove = vi.fn()
const onPreview = vi.fn()
const file = createMockFile()
const { container } = render(
<ImageItem file={file} showDeleteAction onRemove={onRemove} onPreview={onPreview} />,
)
const deleteButton = container.querySelector('button')
if (deleteButton) {
fireEvent.click(deleteButton)
expect(onRemove).toHaveBeenCalled()
expect(onPreview).not.toHaveBeenCalled()
}
})
})
describe('Edge Cases', () => {
it('should handle missing callbacks', () => {
const file = createMockFile()
const { container } = render(<ImageItem file={file} />)
expect(() => {
const imageContainer = container.querySelector('.group\\/file-image')
if (imageContainer)
fireEvent.click(imageContainer)
}).not.toThrow()
})
it('should use base64Url when available', () => {
const file = createMockFile({ base64Url: 'data:custom' })
const { container } = render(<ImageItem file={file} />)
expect(container.firstChild).toBeInTheDocument()
})
it('should fallback to sourceUrl', () => {
const file = createMockFile({ base64Url: undefined })
const { container } = render(<ImageItem file={file} />)
expect(container.firstChild).toBeInTheDocument()
})
})
})

View File

@ -1,238 +0,0 @@
import type { FileEntity } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import ImageUploaderInRetrievalTestingWrapper from './index'
// Mock dependencies
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {
image_file_batch_limit: 10,
single_chunk_attachment_limit: 20,
attachment_image_file_size_limit: 15,
},
})),
}))
vi.mock('@/app/components/datasets/common/image-previewer', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="image-previewer">
<button data-testid="close-preview" onClick={onClose}>Close</button>
</div>
),
}))
describe('ImageUploaderInRetrievalTesting', () => {
const defaultProps = {
textArea: <textarea data-testid="text-area" />,
actionButton: <button data-testid="action-button">Submit</button>,
onChange: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should render textArea prop', () => {
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />)
expect(screen.getByTestId('text-area')).toBeInTheDocument()
})
it('should render actionButton prop', () => {
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />)
expect(screen.getByTestId('action-button')).toBeInTheDocument()
})
it('should render ImageInput when showUploader is true (default)', () => {
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />)
expect(document.querySelector('input[type="file"]')).toBeInTheDocument()
})
it('should not render ImageInput when showUploader is false', () => {
render(
<ImageUploaderInRetrievalTestingWrapper
{...defaultProps}
value={[]}
showUploader={false}
/>,
)
expect(document.querySelector('input[type="file"]')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper
{...defaultProps}
value={[]}
className="custom-class"
/>,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should apply actionAreaClassName', () => {
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper
{...defaultProps}
value={[]}
actionAreaClassName="action-area-class"
/>,
)
// The action area should have the custom class
expect(container.querySelector('.action-area-class')).toBeInTheDocument()
})
it('should render file list when files are provided', () => {
const files: FileEntity[] = [
{
id: 'file1',
name: 'test1.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: 'uploaded-1',
base64Url: 'data:image/png;base64,test1',
size: 1024,
},
]
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={files} />)
const fileItems = document.querySelectorAll('.group\\/file-image')
expect(fileItems.length).toBeGreaterThanOrEqual(1)
})
it('should not render file list when files are empty', () => {
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />,
)
// File list container should not be present
expect(container.querySelector('.bg-background-default')).not.toBeInTheDocument()
})
it('should not render file list when showUploader is false', () => {
const files: FileEntity[] = [
{
id: 'file1',
name: 'test1.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: 'uploaded-1',
base64Url: 'data:image/png;base64,test1',
size: 1024,
},
]
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper
{...defaultProps}
value={files}
showUploader={false}
/>,
)
expect(container.querySelector('.bg-background-default')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should show preview when image is clicked', () => {
const files: FileEntity[] = [
{
id: 'file1',
name: 'test.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: 'uploaded-1',
base64Url: 'data:image/png;base64,test',
size: 1024,
},
]
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={files} />)
const fileItem = document.querySelector('.group\\/file-image')
if (fileItem) {
fireEvent.click(fileItem)
expect(screen.getByTestId('image-previewer')).toBeInTheDocument()
}
})
it('should close preview when close button is clicked', () => {
const files: FileEntity[] = [
{
id: 'file1',
name: 'test.png',
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: 'uploaded-1',
base64Url: 'data:image/png;base64,test',
size: 1024,
},
]
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={files} />)
const fileItem = document.querySelector('.group\\/file-image')
if (fileItem) {
fireEvent.click(fileItem)
const closeButton = screen.getByTestId('close-preview')
fireEvent.click(closeButton)
expect(screen.queryByTestId('image-previewer')).not.toBeInTheDocument()
}
})
})
describe('Layout', () => {
it('should use justify-between when showUploader is true', () => {
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={[]} />,
)
expect(container.querySelector('.justify-between')).toBeInTheDocument()
})
it('should use justify-end when showUploader is false', () => {
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper
{...defaultProps}
value={[]}
showUploader={false}
/>,
)
expect(container.querySelector('.justify-end')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle undefined value', () => {
const { container } = render(
<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={undefined} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should handle multiple files', () => {
const files: FileEntity[] = Array.from({ length: 5 }, (_, i) => ({
id: `file${i}`,
name: `test${i}.png`,
extension: 'png',
mimeType: 'image/png',
progress: 100,
uploadedId: `uploaded-${i}`,
base64Url: `data:image/png;base64,test${i}`,
size: 1024 * (i + 1),
}))
render(<ImageUploaderInRetrievalTestingWrapper {...defaultProps} value={files} />)
const fileItems = document.querySelectorAll('.group\\/file-image')
expect(fileItems.length).toBe(5)
})
})
})

View File

@ -1,305 +0,0 @@
import type { FileEntity } from './types'
import { act, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
createFileStore,
FileContextProvider,
useFileStore,
useFileStoreWithSelector,
} from './store'
const createMockFile = (id: string): FileEntity => ({
id,
name: `file-${id}.png`,
size: 1024,
extension: 'png',
mimeType: 'image/png',
progress: 0,
})
describe('image-uploader store', () => {
describe('createFileStore', () => {
it('should create store with empty array by default', () => {
const store = createFileStore()
expect(store.getState().files).toEqual([])
})
it('should create store with initial value', () => {
const initialFiles = [createMockFile('1'), createMockFile('2')]
const store = createFileStore(initialFiles)
expect(store.getState().files).toHaveLength(2)
})
it('should create copy of initial value', () => {
const initialFiles = [createMockFile('1')]
const store = createFileStore(initialFiles)
store.getState().files.push(createMockFile('2'))
expect(initialFiles).toHaveLength(1)
})
it('should update files with setFiles', () => {
const store = createFileStore()
const newFiles = [createMockFile('1'), createMockFile('2')]
act(() => {
store.getState().setFiles(newFiles)
})
expect(store.getState().files).toEqual(newFiles)
})
it('should call onChange when setFiles is called', () => {
const onChange = vi.fn()
const store = createFileStore([], onChange)
const newFiles = [createMockFile('1')]
act(() => {
store.getState().setFiles(newFiles)
})
expect(onChange).toHaveBeenCalledWith(newFiles)
})
it('should not throw when onChange is not provided', () => {
const store = createFileStore([])
const newFiles = [createMockFile('1')]
expect(() => {
act(() => {
store.getState().setFiles(newFiles)
})
}).not.toThrow()
})
it('should handle undefined initial value', () => {
const store = createFileStore(undefined)
expect(store.getState().files).toEqual([])
})
it('should handle null-like falsy value with empty array fallback', () => {
// Test the ternary: value ? [...value] : []
const store = createFileStore(null as unknown as FileEntity[])
expect(store.getState().files).toEqual([])
})
it('should handle empty array as initial value', () => {
const store = createFileStore([])
expect(store.getState().files).toEqual([])
})
})
describe('FileContextProvider', () => {
it('should render children', () => {
render(
<FileContextProvider>
<div>Test Child</div>
</FileContextProvider>,
)
expect(screen.getByText('Test Child')).toBeInTheDocument()
})
it('should provide store to children', () => {
const TestComponent = () => {
const store = useFileStore()
// useFileStore returns a store that's truthy by design
return <div data-testid="store-exists">{store !== null ? 'yes' : 'no'}</div>
}
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
expect(screen.getByTestId('store-exists')).toHaveTextContent('yes')
})
it('should initialize store with value prop', () => {
const initialFiles = [createMockFile('1')]
const TestComponent = () => {
const store = useFileStore()
return <div data-testid="file-count">{store.getState().files.length}</div>
}
render(
<FileContextProvider value={initialFiles}>
<TestComponent />
</FileContextProvider>,
)
expect(screen.getByTestId('file-count')).toHaveTextContent('1')
})
it('should call onChange when files change', () => {
const onChange = vi.fn()
const newFiles = [createMockFile('1')]
const TestComponent = () => {
const store = useFileStore()
return (
<button onClick={() => store.getState().setFiles(newFiles)}>
Set Files
</button>
)
}
render(
<FileContextProvider onChange={onChange}>
<TestComponent />
</FileContextProvider>,
)
act(() => {
screen.getByRole('button').click()
})
expect(onChange).toHaveBeenCalledWith(newFiles)
})
it('should reuse existing store on re-render (storeRef.current already exists)', () => {
const initialFiles = [createMockFile('1')]
let renderCount = 0
const TestComponent = () => {
const store = useFileStore()
renderCount++
return (
<div>
<span data-testid="file-count">{store.getState().files.length}</span>
<span data-testid="render-count">{renderCount}</span>
</div>
)
}
const { rerender } = render(
<FileContextProvider value={initialFiles}>
<TestComponent />
</FileContextProvider>,
)
expect(screen.getByTestId('file-count')).toHaveTextContent('1')
// Re-render the provider - should reuse the same store
rerender(
<FileContextProvider value={initialFiles}>
<TestComponent />
</FileContextProvider>,
)
// Store should still have the same files (store was reused)
expect(screen.getByTestId('file-count')).toHaveTextContent('1')
expect(renderCount).toBeGreaterThan(1)
})
})
describe('useFileStore', () => {
it('should return store from context', () => {
const TestComponent = () => {
const store = useFileStore()
// useFileStore returns a store that's truthy by design
return <div data-testid="result">{store !== null ? 'has store' : 'no store'}</div>
}
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
expect(screen.getByTestId('result')).toHaveTextContent('has store')
})
})
describe('useFileStoreWithSelector', () => {
it('should throw error when used outside provider', () => {
const TestComponent = () => {
try {
useFileStoreWithSelector(state => state.files)
return <div>No Error</div>
}
catch {
return <div>Error</div>
}
}
render(<TestComponent />)
expect(screen.getByText('Error')).toBeInTheDocument()
})
it('should select files from store', () => {
const initialFiles = [createMockFile('1'), createMockFile('2')]
const TestComponent = () => {
const files = useFileStoreWithSelector(state => state.files)
return <div data-testid="files-count">{files.length}</div>
}
render(
<FileContextProvider value={initialFiles}>
<TestComponent />
</FileContextProvider>,
)
expect(screen.getByTestId('files-count')).toHaveTextContent('2')
})
it('should select setFiles function from store', () => {
const onChange = vi.fn()
const TestComponent = () => {
const setFiles = useFileStoreWithSelector(state => state.setFiles)
return (
<button onClick={() => setFiles([createMockFile('new')])}>
Update
</button>
)
}
render(
<FileContextProvider onChange={onChange}>
<TestComponent />
</FileContextProvider>,
)
act(() => {
screen.getByRole('button').click()
})
expect(onChange).toHaveBeenCalled()
})
it('should re-render when selected state changes', () => {
const renderCount = { current: 0 }
const TestComponent = () => {
const files = useFileStoreWithSelector(state => state.files)
const setFiles = useFileStoreWithSelector(state => state.setFiles)
renderCount.current++
return (
<div>
<span data-testid="count">{files.length}</span>
<button onClick={() => setFiles([...files, createMockFile('new')])}>
Add
</button>
</div>
)
}
render(
<FileContextProvider>
<TestComponent />
</FileContextProvider>,
)
expect(screen.getByTestId('count')).toHaveTextContent('0')
act(() => {
screen.getByRole('button').click()
})
expect(screen.getByTestId('count')).toHaveTextContent('1')
})
})
})

View File

@ -1,310 +0,0 @@
import type { FileEntity } from './types'
import type { FileUploadConfigResponse } from '@/models/common'
import { describe, expect, it } from 'vitest'
import {
DEFAULT_IMAGE_FILE_BATCH_LIMIT,
DEFAULT_IMAGE_FILE_SIZE_LIMIT,
DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
} from './constants'
import { fileIsUploaded, getFileType, getFileUploadConfig, traverseFileEntry } from './utils'
describe('image-uploader utils', () => {
describe('getFileType', () => {
it('should return file extension for a simple filename', () => {
const file = { name: 'image.png' } as File
expect(getFileType(file)).toBe('png')
})
it('should return file extension for filename with multiple dots', () => {
const file = { name: 'my.photo.image.jpg' } as File
expect(getFileType(file)).toBe('jpg')
})
it('should return empty string for null/undefined file', () => {
expect(getFileType(null as unknown as File)).toBe('')
expect(getFileType(undefined as unknown as File)).toBe('')
})
it('should return filename for file without extension', () => {
const file = { name: 'README' } as File
expect(getFileType(file)).toBe('README')
})
it('should handle various file extensions', () => {
expect(getFileType({ name: 'doc.pdf' } as File)).toBe('pdf')
expect(getFileType({ name: 'image.jpeg' } as File)).toBe('jpeg')
expect(getFileType({ name: 'video.mp4' } as File)).toBe('mp4')
expect(getFileType({ name: 'archive.tar.gz' } as File)).toBe('gz')
})
})
describe('fileIsUploaded', () => {
it('should return true when uploadedId is set', () => {
const file = { uploadedId: 'some-id', progress: 50 } as Partial<FileEntity>
expect(fileIsUploaded(file as FileEntity)).toBe(true)
})
it('should return true when progress is 100', () => {
const file = { progress: 100 } as Partial<FileEntity>
expect(fileIsUploaded(file as FileEntity)).toBe(true)
})
it('should return undefined when neither uploadedId nor 100 progress', () => {
const file = { progress: 50 } as Partial<FileEntity>
expect(fileIsUploaded(file as FileEntity)).toBeUndefined()
})
it('should return undefined when progress is 0', () => {
const file = { progress: 0 } as Partial<FileEntity>
expect(fileIsUploaded(file as FileEntity)).toBeUndefined()
})
it('should return true when uploadedId is empty string and progress is 100', () => {
const file = { uploadedId: '', progress: 100 } as Partial<FileEntity>
expect(fileIsUploaded(file as FileEntity)).toBe(true)
})
})
describe('getFileUploadConfig', () => {
it('should return default values when response is undefined', () => {
const result = getFileUploadConfig(undefined)
expect(result).toEqual({
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
})
})
it('should return values from response when valid', () => {
const response: Partial<FileUploadConfigResponse> = {
image_file_batch_limit: 20,
single_chunk_attachment_limit: 10,
attachment_image_file_size_limit: 5,
}
const result = getFileUploadConfig(response as FileUploadConfigResponse)
expect(result).toEqual({
imageFileSizeLimit: 5,
imageFileBatchLimit: 20,
singleChunkAttachmentLimit: 10,
})
})
it('should use default values when response values are 0', () => {
const response: Partial<FileUploadConfigResponse> = {
image_file_batch_limit: 0,
single_chunk_attachment_limit: 0,
attachment_image_file_size_limit: 0,
}
const result = getFileUploadConfig(response as FileUploadConfigResponse)
expect(result).toEqual({
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
})
})
it('should use default values when response values are negative', () => {
const response: Partial<FileUploadConfigResponse> = {
image_file_batch_limit: -5,
single_chunk_attachment_limit: -10,
attachment_image_file_size_limit: -1,
}
const result = getFileUploadConfig(response as FileUploadConfigResponse)
expect(result).toEqual({
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
})
})
it('should handle string values in response', () => {
const response = {
image_file_batch_limit: '15',
single_chunk_attachment_limit: '8',
attachment_image_file_size_limit: '3',
} as unknown as FileUploadConfigResponse
const result = getFileUploadConfig(response)
expect(result).toEqual({
imageFileSizeLimit: 3,
imageFileBatchLimit: 15,
singleChunkAttachmentLimit: 8,
})
})
it('should handle null values in response', () => {
const response = {
image_file_batch_limit: null,
single_chunk_attachment_limit: null,
attachment_image_file_size_limit: null,
} as unknown as FileUploadConfigResponse
const result = getFileUploadConfig(response)
expect(result).toEqual({
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
})
})
it('should handle undefined values in response', () => {
const response = {
image_file_batch_limit: undefined,
single_chunk_attachment_limit: undefined,
attachment_image_file_size_limit: undefined,
} as unknown as FileUploadConfigResponse
const result = getFileUploadConfig(response)
expect(result).toEqual({
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
})
})
it('should handle partial response', () => {
const response: Partial<FileUploadConfigResponse> = {
image_file_batch_limit: 25,
}
const result = getFileUploadConfig(response as FileUploadConfigResponse)
expect(result.imageFileBatchLimit).toBe(25)
expect(result.imageFileSizeLimit).toBe(DEFAULT_IMAGE_FILE_SIZE_LIMIT)
expect(result.singleChunkAttachmentLimit).toBe(DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT)
})
it('should handle non-number non-string values (object, boolean, etc) with default fallback', () => {
// This tests the getNumberValue function's final return 0 case
// When value is neither number nor string (e.g., object, boolean, array)
const response = {
image_file_batch_limit: { invalid: 'object' }, // Object - not number or string
single_chunk_attachment_limit: true, // Boolean - not number or string
attachment_image_file_size_limit: ['array'], // Array - not number or string
} as unknown as FileUploadConfigResponse
const result = getFileUploadConfig(response)
// All should fall back to defaults since getNumberValue returns 0 for these types
expect(result).toEqual({
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
})
})
it('should handle NaN string values', () => {
const response = {
image_file_batch_limit: 'not-a-number',
single_chunk_attachment_limit: '',
attachment_image_file_size_limit: 'abc',
} as unknown as FileUploadConfigResponse
const result = getFileUploadConfig(response)
// NaN values should result in defaults (since NaN > 0 is false)
expect(result).toEqual({
imageFileSizeLimit: DEFAULT_IMAGE_FILE_SIZE_LIMIT,
imageFileBatchLimit: DEFAULT_IMAGE_FILE_BATCH_LIMIT,
singleChunkAttachmentLimit: DEFAULT_SINGLE_CHUNK_ATTACHMENT_LIMIT,
})
})
})
describe('traverseFileEntry', () => {
type MockFile = { name: string, relativePath?: string }
type FileCallback = (file: MockFile) => void
type EntriesCallback = (entries: FileSystemEntry[]) => void
it('should resolve with file array for file entry', async () => {
const mockFile: MockFile = { name: 'test.png' }
const mockEntry = {
isFile: true,
isDirectory: false,
file: (callback: FileCallback) => callback(mockFile),
}
const result = await traverseFileEntry(mockEntry)
expect(result).toHaveLength(1)
expect(result[0].name).toBe('test.png')
expect(result[0].relativePath).toBe('test.png')
})
it('should resolve with file array with prefix for nested file', async () => {
const mockFile: MockFile = { name: 'test.png' }
const mockEntry = {
isFile: true,
isDirectory: false,
file: (callback: FileCallback) => callback(mockFile),
}
const result = await traverseFileEntry(mockEntry, 'folder/')
expect(result).toHaveLength(1)
expect(result[0].relativePath).toBe('folder/test.png')
})
it('should resolve empty array for unknown entry type', async () => {
const mockEntry = {
isFile: false,
isDirectory: false,
}
const result = await traverseFileEntry(mockEntry)
expect(result).toEqual([])
})
it('should handle directory with no files', async () => {
const mockEntry = {
isFile: false,
isDirectory: true,
name: 'empty-folder',
createReader: () => ({
readEntries: (callback: EntriesCallback) => callback([]),
}),
}
const result = await traverseFileEntry(mockEntry)
expect(result).toEqual([])
})
it('should handle directory with files', async () => {
const mockFile1: MockFile = { name: 'file1.png' }
const mockFile2: MockFile = { name: 'file2.png' }
const mockFileEntry1 = {
isFile: true,
isDirectory: false,
file: (callback: FileCallback) => callback(mockFile1),
}
const mockFileEntry2 = {
isFile: true,
isDirectory: false,
file: (callback: FileCallback) => callback(mockFile2),
}
let readCount = 0
const mockEntry = {
isFile: false,
isDirectory: true,
name: 'folder',
createReader: () => ({
readEntries: (callback: EntriesCallback) => {
if (readCount === 0) {
readCount++
callback([mockFileEntry1, mockFileEntry2] as unknown as FileSystemEntry[])
}
else {
callback([])
}
},
}),
}
const result = await traverseFileEntry(mockEntry)
expect(result).toHaveLength(2)
expect(result[0].relativePath).toBe('folder/file1.png')
expect(result[1].relativePath).toBe('folder/file2.png')
})
})
})

View File

@ -1,154 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DSLConfirmModal from './dsl-confirm-modal'
// ============================================================================
// DSLConfirmModal Component Tests
// ============================================================================
describe('DSLConfirmModal', () => {
const defaultProps = {
onCancel: vi.fn(),
onConfirm: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DSLConfirmModal {...defaultProps} />)
expect(screen.getByText(/appCreateDSLErrorTitle/i)).toBeInTheDocument()
})
it('should render title', () => {
render(<DSLConfirmModal {...defaultProps} />)
expect(screen.getByText(/appCreateDSLErrorTitle/i)).toBeInTheDocument()
})
it('should render error message parts', () => {
render(<DSLConfirmModal {...defaultProps} />)
expect(screen.getByText(/appCreateDSLErrorPart1/i)).toBeInTheDocument()
expect(screen.getByText(/appCreateDSLErrorPart2/i)).toBeInTheDocument()
expect(screen.getByText(/appCreateDSLErrorPart3/i)).toBeInTheDocument()
expect(screen.getByText(/appCreateDSLErrorPart4/i)).toBeInTheDocument()
})
it('should render cancel button', () => {
render(<DSLConfirmModal {...defaultProps} />)
expect(screen.getByText(/Cancel/i)).toBeInTheDocument()
})
it('should render confirm button', () => {
render(<DSLConfirmModal {...defaultProps} />)
expect(screen.getByText(/Confirm/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Versions Display Tests
// --------------------------------------------------------------------------
describe('Versions Display', () => {
it('should display imported version when provided', () => {
render(
<DSLConfirmModal
{...defaultProps}
versions={{ importedVersion: '1.0.0', systemVersion: '2.0.0' }}
/>,
)
expect(screen.getByText('1.0.0')).toBeInTheDocument()
})
it('should display system version when provided', () => {
render(
<DSLConfirmModal
{...defaultProps}
versions={{ importedVersion: '1.0.0', systemVersion: '2.0.0' }}
/>,
)
expect(screen.getByText('2.0.0')).toBeInTheDocument()
})
it('should use default empty versions when not provided', () => {
render(<DSLConfirmModal {...defaultProps} />)
// Should render without errors
expect(screen.getByText(/appCreateDSLErrorTitle/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onCancel when cancel button is clicked', () => {
render(<DSLConfirmModal {...defaultProps} />)
const cancelButton = screen.getByText(/Cancel/i)
fireEvent.click(cancelButton)
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
})
it('should call onConfirm when confirm button is clicked', () => {
render(<DSLConfirmModal {...defaultProps} />)
const confirmButton = screen.getByText(/Confirm/i)
fireEvent.click(confirmButton)
expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1)
})
it('should call onCancel when modal is closed', () => {
render(<DSLConfirmModal {...defaultProps} />)
// Modal close is triggered by clicking backdrop or close button
// The onClose prop is mapped to onCancel
const cancelButton = screen.getByText(/Cancel/i)
fireEvent.click(cancelButton)
expect(defaultProps.onCancel).toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Button State Tests
// --------------------------------------------------------------------------
describe('Button State', () => {
it('should enable confirm button by default', () => {
render(<DSLConfirmModal {...defaultProps} />)
const confirmButton = screen.getByText(/Confirm/i)
expect(confirmButton).not.toBeDisabled()
})
it('should disable confirm button when confirmDisabled is true', () => {
render(<DSLConfirmModal {...defaultProps} confirmDisabled={true} />)
const confirmButton = screen.getByText(/Confirm/i)
expect(confirmButton).toBeDisabled()
})
it('should enable confirm button when confirmDisabled is false', () => {
render(<DSLConfirmModal {...defaultProps} confirmDisabled={false} />)
const confirmButton = screen.getByText(/Confirm/i)
expect(confirmButton).not.toBeDisabled()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have button container with proper styling', () => {
render(<DSLConfirmModal {...defaultProps} />)
const cancelButton = screen.getByText(/Cancel/i)
const buttonContainer = cancelButton.parentElement
expect(buttonContainer).toHaveClass('flex', 'items-start', 'justify-end', 'gap-2')
})
})
})

View File

@ -1,93 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Header from './header'
// ============================================================================
// Header Component Tests
// ============================================================================
describe('Header', () => {
const defaultProps = {
onClose: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText(/importFromDSL/i)).toBeInTheDocument()
})
it('should render title', () => {
render(<Header {...defaultProps} />)
expect(screen.getByText(/importFromDSL/i)).toBeInTheDocument()
})
it('should render close button', () => {
const { container } = render(<Header {...defaultProps} />)
const closeButton = container.querySelector('[class*="cursor-pointer"]')
expect(closeButton).toBeInTheDocument()
})
it('should render close icon', () => {
const { container } = render(<Header {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onClose when close button is clicked', () => {
const { container } = render(<Header {...defaultProps} />)
const closeButton = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(closeButton!)
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
const { container } = render(<Header {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('title-2xl-semi-bold', 'relative', 'flex', 'items-center')
})
it('should have close button positioned absolutely', () => {
const { container } = render(<Header {...defaultProps} />)
const closeButton = container.querySelector('[class*="absolute"]')
expect(closeButton).toHaveClass('right-5', 'top-5')
})
it('should have padding classes', () => {
const { container } = render(<Header {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('pb-3', 'pl-6', 'pr-14', 'pt-6')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Header {...defaultProps} />)
rerender(<Header {...defaultProps} />)
expect(screen.getByText(/importFromDSL/i)).toBeInTheDocument()
})
})
})

View File

@ -1,121 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
import Tab from './index'
// ============================================================================
// Tab Component Tests
// ============================================================================
describe('Tab', () => {
const defaultProps = {
currentTab: CreateFromDSLModalTab.FROM_FILE,
setCurrentTab: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Tab {...defaultProps} />)
expect(screen.getByText(/importFromDSLFile/i)).toBeInTheDocument()
})
it('should render file tab', () => {
render(<Tab {...defaultProps} />)
expect(screen.getByText(/importFromDSLFile/i)).toBeInTheDocument()
})
it('should render URL tab', () => {
render(<Tab {...defaultProps} />)
expect(screen.getByText(/importFromDSLUrl/i)).toBeInTheDocument()
})
it('should render both tabs', () => {
render(<Tab {...defaultProps} />)
const tabs = screen.getAllByText(/importFromDSL/i)
expect(tabs.length).toBe(2)
})
})
// --------------------------------------------------------------------------
// Active State Tests
// --------------------------------------------------------------------------
describe('Active State', () => {
it('should mark file tab as active when currentTab is FROM_FILE', () => {
const { container } = render(
<Tab {...defaultProps} currentTab={CreateFromDSLModalTab.FROM_FILE} />,
)
const activeIndicators = container.querySelectorAll('[class*="bg-util-colors-blue-brand"]')
expect(activeIndicators.length).toBe(1)
})
it('should mark URL tab as active when currentTab is FROM_URL', () => {
const { container } = render(
<Tab {...defaultProps} currentTab={CreateFromDSLModalTab.FROM_URL} />,
)
const activeIndicators = container.querySelectorAll('[class*="bg-util-colors-blue-brand"]')
expect(activeIndicators.length).toBe(1)
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call setCurrentTab with FROM_FILE when file tab is clicked', () => {
render(<Tab {...defaultProps} currentTab={CreateFromDSLModalTab.FROM_URL} />)
const fileTab = screen.getByText(/importFromDSLFile/i)
fireEvent.click(fileTab)
// bind() prepends the bound argument, so setCurrentTab is called with (FROM_FILE, event)
expect(defaultProps.setCurrentTab).toHaveBeenCalledWith(
CreateFromDSLModalTab.FROM_FILE,
expect.anything(),
)
})
it('should call setCurrentTab with FROM_URL when URL tab is clicked', () => {
render(<Tab {...defaultProps} currentTab={CreateFromDSLModalTab.FROM_FILE} />)
const urlTab = screen.getByText(/importFromDSLUrl/i)
fireEvent.click(urlTab)
// bind() prepends the bound argument, so setCurrentTab is called with (FROM_URL, event)
expect(defaultProps.setCurrentTab).toHaveBeenCalledWith(
CreateFromDSLModalTab.FROM_URL,
expect.anything(),
)
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
const { container } = render(<Tab {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('system-md-semibold', 'flex', 'h-9', 'items-center', 'gap-x-6')
})
it('should have border bottom', () => {
const { container } = render(<Tab {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('border-b', 'border-divider-subtle')
})
it('should have padding', () => {
const { container } = render(<Tab {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('px-6')
})
})
})

View File

@ -1,112 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Item from './item'
// ============================================================================
// Item Component Tests
// ============================================================================
describe('Item', () => {
const defaultProps = {
isActive: false,
label: 'Tab Label',
onClick: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Item {...defaultProps} />)
expect(screen.getByText('Tab Label')).toBeInTheDocument()
})
it('should render label', () => {
render(<Item {...defaultProps} label="Custom Label" />)
expect(screen.getByText('Custom Label')).toBeInTheDocument()
})
it('should not render indicator when inactive', () => {
const { container } = render(<Item {...defaultProps} isActive={false} />)
const indicator = container.querySelector('[class*="bg-util-colors-blue-brand"]')
expect(indicator).not.toBeInTheDocument()
})
it('should render indicator when active', () => {
const { container } = render(<Item {...defaultProps} isActive={true} />)
const indicator = container.querySelector('[class*="bg-util-colors-blue-brand"]')
expect(indicator).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Active State Tests
// --------------------------------------------------------------------------
describe('Active State', () => {
it('should have tertiary text color when inactive', () => {
const { container } = render(<Item {...defaultProps} isActive={false} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('text-text-tertiary')
})
it('should have primary text color when active', () => {
const { container } = render(<Item {...defaultProps} isActive={true} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('text-text-primary')
})
it('should show active indicator bar when active', () => {
const { container } = render(<Item {...defaultProps} isActive={true} />)
const indicator = container.querySelector('[class*="absolute"]')
expect(indicator).toHaveClass('bottom-0', 'h-0.5', 'w-full')
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onClick when clicked', () => {
render(<Item {...defaultProps} />)
const item = screen.getByText('Tab Label')
fireEvent.click(item)
expect(defaultProps.onClick).toHaveBeenCalledTimes(1)
})
it('should have cursor pointer', () => {
const { container } = render(<Item {...defaultProps} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('cursor-pointer')
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
const { container } = render(<Item {...defaultProps} />)
const item = container.firstChild as HTMLElement
expect(item).toHaveClass('system-md-semibold', 'relative', 'flex', 'h-full', 'items-center')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Item {...defaultProps} />)
rerender(<Item {...defaultProps} />)
expect(screen.getByText('Tab Label')).toBeInTheDocument()
})
})
})

View File

@ -1,205 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Uploader from './uploader'
// Mock ToastContext
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
ToastContext: {
Provider: ({ children }: { children: React.ReactNode }) => children,
Consumer: ({ children }: { children: (value: { notify: typeof mockNotify }) => React.ReactNode }) => children({ notify: mockNotify }),
},
}))
// Mock use-context-selector
vi.mock('use-context-selector', () => ({
useContext: () => ({ notify: mockNotify }),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createMockFile = (name = 'test.pipeline', _size = 1024): File => {
return new File(['test content'], name, { type: 'application/octet-stream' })
}
// ============================================================================
// Uploader Component Tests
// ============================================================================
describe('Uploader', () => {
const defaultProps = {
file: undefined,
updateFile: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests - No File
// --------------------------------------------------------------------------
describe('Rendering - No File', () => {
it('should render without crashing', () => {
render(<Uploader {...defaultProps} />)
expect(screen.getByText(/dslUploader\.button/i)).toBeInTheDocument()
})
it('should render upload prompt when no file', () => {
render(<Uploader {...defaultProps} />)
expect(screen.getByText(/dslUploader\.button/i)).toBeInTheDocument()
})
it('should render browse link when no file', () => {
render(<Uploader {...defaultProps} />)
expect(screen.getByText(/dslUploader\.browse/i)).toBeInTheDocument()
})
it('should render upload icon when no file', () => {
const { container } = render(<Uploader {...defaultProps} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
it('should have hidden file input', () => {
render(<Uploader {...defaultProps} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
expect(input).toBeInTheDocument()
expect(input.style.display).toBe('none')
})
it('should accept .pipeline files', () => {
render(<Uploader {...defaultProps} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
expect(input.accept).toBe('.pipeline')
})
})
// --------------------------------------------------------------------------
// Rendering Tests - With File
// --------------------------------------------------------------------------
describe('Rendering - With File', () => {
it('should render file name when file is provided', () => {
const file = createMockFile('my-pipeline.pipeline')
render(<Uploader {...defaultProps} file={file} />)
expect(screen.getByText('my-pipeline.pipeline')).toBeInTheDocument()
})
it('should render PIPELINE label when file is provided', () => {
const file = createMockFile()
render(<Uploader {...defaultProps} file={file} />)
expect(screen.getByText('PIPELINE')).toBeInTheDocument()
})
it('should render delete button when file is provided', () => {
const file = createMockFile()
const { container } = render(<Uploader {...defaultProps} file={file} />)
const deleteButton = container.querySelector('[class*="group-hover:flex"]')
expect(deleteButton).toBeInTheDocument()
})
it('should render node tree icon when file is provided', () => {
const file = createMockFile()
const { container } = render(<Uploader {...defaultProps} file={file} />)
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should open file dialog when browse is clicked', () => {
render(<Uploader {...defaultProps} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
const clickSpy = vi.spyOn(input, 'click')
const browseLink = screen.getByText(/dslUploader\.browse/i)
fireEvent.click(browseLink)
expect(clickSpy).toHaveBeenCalled()
})
it('should call updateFile when file input changes', () => {
render(<Uploader {...defaultProps} />)
const input = document.getElementById('fileUploader') as HTMLInputElement
const file = createMockFile()
Object.defineProperty(input, 'files', {
value: [file],
writable: true,
})
fireEvent.change(input)
expect(defaultProps.updateFile).toHaveBeenCalledWith(file)
})
it('should call updateFile with undefined when delete is clicked', () => {
const file = createMockFile()
const { container } = render(<Uploader {...defaultProps} file={file} />)
const deleteButton = container.querySelector('[class*="group-hover:flex"] button')
if (deleteButton)
fireEvent.click(deleteButton)
expect(defaultProps.updateFile).toHaveBeenCalledWith()
})
})
// --------------------------------------------------------------------------
// Custom className Tests
// --------------------------------------------------------------------------
describe('Custom className', () => {
it('should apply custom className', () => {
const { container } = render(<Uploader {...defaultProps} className="custom-class" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
it('should merge custom className with default', () => {
const { container } = render(<Uploader {...defaultProps} className="custom-class" />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('mt-6', 'custom-class')
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
const { container } = render(<Uploader {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('mt-6')
})
it('should have dropzone styling when no file', () => {
const { container } = render(<Uploader {...defaultProps} />)
const dropzone = container.querySelector('[class*="border-dashed"]')
expect(dropzone).toBeInTheDocument()
})
it('should have file card styling when file is provided', () => {
const file = createMockFile()
const { container } = render(<Uploader {...defaultProps} file={file} />)
const fileCard = container.querySelector('[class*="rounded-lg"]')
expect(fileCard).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Uploader {...defaultProps} />)
rerender(<Uploader {...defaultProps} />)
expect(screen.getByText(/dslUploader\.button/i)).toBeInTheDocument()
})
})
})

View File

@ -1,224 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Footer from './footer'
// Configurable mock for search params
let mockSearchParams = new URLSearchParams()
const mockReplace = vi.fn()
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({ replace: mockReplace }),
useSearchParams: () => mockSearchParams,
}))
// Mock service hook
const mockInvalidDatasetList = vi.fn()
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
// Mock CreateFromDSLModal to capture props
let capturedActiveTab: string | undefined
let capturedDslUrl: string | undefined
vi.mock('./create-options/create-from-dsl-modal', () => ({
default: ({ show, onClose, onSuccess, activeTab, dslUrl }: {
show: boolean
onClose: () => void
onSuccess: () => void
activeTab?: string
dslUrl?: string
}) => {
capturedActiveTab = activeTab
capturedDslUrl = dslUrl
return show
? (
<div data-testid="dsl-modal">
<button data-testid="close-modal" onClick={onClose}>Close</button>
<button data-testid="success-modal" onClick={onSuccess}>Success</button>
</div>
)
: null
},
CreateFromDSLModalTab: {
FROM_URL: 'FROM_URL',
FROM_FILE: 'FROM_FILE',
},
}))
// ============================================================================
// Footer Component Tests
// ============================================================================
describe('Footer', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSearchParams = new URLSearchParams()
capturedActiveTab = undefined
capturedDslUrl = undefined
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Footer />)
expect(screen.getByText(/importDSL/i)).toBeInTheDocument()
})
it('should render import button with icon', () => {
const { container } = render(<Footer />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should not show modal initially', () => {
render(<Footer />)
expect(screen.queryByTestId('dsl-modal')).not.toBeInTheDocument()
})
it('should render divider', () => {
const { container } = render(<Footer />)
const divider = container.querySelector('[class*="w-8"]')
expect(divider).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should open modal when import button is clicked', () => {
render(<Footer />)
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
})
it('should close modal when onClose is called', () => {
render(<Footer />)
// Open modal
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
// Close modal
const closeButton = screen.getByTestId('close-modal')
fireEvent.click(closeButton)
expect(screen.queryByTestId('dsl-modal')).not.toBeInTheDocument()
})
it('should call invalidDatasetList on success', () => {
render(<Footer />)
// Open modal
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
// Trigger success
const successButton = screen.getByTestId('success-modal')
fireEvent.click(successButton)
expect(mockInvalidDatasetList).toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container classes', () => {
const { container } = render(<Footer />)
const footerDiv = container.firstChild as HTMLElement
expect(footerDiv).toHaveClass('absolute', 'bottom-0', 'left-0', 'right-0', 'z-10')
})
it('should have backdrop blur effect', () => {
const { container } = render(<Footer />)
const footerDiv = container.firstChild as HTMLElement
expect(footerDiv).toHaveClass('backdrop-blur-[6px]')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Footer />)
rerender(<Footer />)
expect(screen.getByText(/importDSL/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// URL Parameter Tests (Branch Coverage)
// --------------------------------------------------------------------------
describe('URL Parameter Handling', () => {
it('should set activeTab to FROM_URL when dslUrl is present', () => {
mockSearchParams = new URLSearchParams('remoteInstallUrl=https://example.com/dsl')
render(<Footer />)
// Open modal to trigger prop capture
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
expect(capturedActiveTab).toBe('FROM_URL')
expect(capturedDslUrl).toBe('https://example.com/dsl')
})
it('should set activeTab to undefined when dslUrl is not present', () => {
mockSearchParams = new URLSearchParams()
render(<Footer />)
// Open modal to trigger prop capture
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
expect(capturedActiveTab).toBeUndefined()
expect(capturedDslUrl).toBeUndefined()
})
it('should call replace when closing modal with dslUrl present', () => {
mockSearchParams = new URLSearchParams('remoteInstallUrl=https://example.com/dsl')
render(<Footer />)
// Open modal
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
expect(screen.getByTestId('dsl-modal')).toBeInTheDocument()
// Close modal
const closeButton = screen.getByTestId('close-modal')
fireEvent.click(closeButton)
expect(mockReplace).toHaveBeenCalledWith('/datasets/create-from-pipeline')
})
it('should not call replace when closing modal without dslUrl', () => {
mockSearchParams = new URLSearchParams()
render(<Footer />)
// Open modal
const importButton = screen.getByText(/importDSL/i)
fireEvent.click(importButton)
// Close modal
const closeButton = screen.getByTestId('close-modal')
fireEvent.click(closeButton)
expect(mockReplace).not.toHaveBeenCalled()
})
})
})

View File

@ -1,71 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Header from './header'
// ============================================================================
// Header Component Tests
// ============================================================================
describe('Header', () => {
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Header />)
expect(screen.getByText(/backToKnowledge/i)).toBeInTheDocument()
})
it('should render back button with link to datasets', () => {
render(<Header />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/datasets')
})
it('should render arrow icon in button', () => {
const { container } = render(<Header />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
it('should render button with correct styling', () => {
render(<Header />)
const button = screen.getByRole('button')
expect(button).toHaveClass('rounded-full')
})
it('should have replace attribute on link', () => {
const { container } = render(<Header />)
const link = container.querySelector('a[href="/datasets"]')
expect(link).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container classes', () => {
const { container } = render(<Header />)
const headerDiv = container.firstChild as HTMLElement
expect(headerDiv).toHaveClass('relative', 'flex', 'px-16', 'pb-2', 'pt-5')
})
it('should position link absolutely at bottom left', () => {
const { container } = render(<Header />)
const link = container.querySelector('a')
expect(link).toHaveClass('absolute', 'bottom-0', 'left-5')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Header />)
rerender(<Header />)
expect(screen.getByText(/backToKnowledge/i)).toBeInTheDocument()
})
})
})

View File

@ -1,101 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import CreateFromPipeline from './index'
// Mock child components to isolate testing
vi.mock('./header', () => ({
default: () => <div data-testid="mock-header">Header</div>,
}))
vi.mock('./list', () => ({
default: () => <div data-testid="mock-list">List</div>,
}))
vi.mock('./footer', () => ({
default: () => <div data-testid="mock-footer">Footer</div>,
}))
vi.mock('../../base/effect', () => ({
default: ({ className }: { className?: string }) => (
<div data-testid="mock-effect" className={className}>Effect</div>
),
}))
// ============================================================================
// CreateFromPipeline Component Tests
// ============================================================================
describe('CreateFromPipeline', () => {
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CreateFromPipeline />)
expect(screen.getByTestId('mock-header')).toBeInTheDocument()
})
it('should render Header component', () => {
render(<CreateFromPipeline />)
expect(screen.getByTestId('mock-header')).toBeInTheDocument()
})
it('should render List component', () => {
render(<CreateFromPipeline />)
expect(screen.getByTestId('mock-list')).toBeInTheDocument()
})
it('should render Footer component', () => {
render(<CreateFromPipeline />)
expect(screen.getByTestId('mock-footer')).toBeInTheDocument()
})
it('should render Effect component', () => {
render(<CreateFromPipeline />)
expect(screen.getByTestId('mock-effect')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container classes', () => {
const { container } = render(<CreateFromPipeline />)
const mainDiv = container.firstChild as HTMLElement
expect(mainDiv).toHaveClass('relative', 'flex', 'flex-col', 'overflow-hidden', 'rounded-t-2xl')
})
it('should have correct height calculation', () => {
const { container } = render(<CreateFromPipeline />)
const mainDiv = container.firstChild as HTMLElement
expect(mainDiv).toHaveClass('h-[calc(100vh-56px)]')
})
it('should have border and background styling', () => {
const { container } = render(<CreateFromPipeline />)
const mainDiv = container.firstChild as HTMLElement
expect(mainDiv).toHaveClass('border-t', 'border-effects-highlight', 'bg-background-default-subtle')
})
it('should position Effect component correctly', () => {
render(<CreateFromPipeline />)
const effect = screen.getByTestId('mock-effect')
expect(effect).toHaveClass('left-8', 'top-[-34px]', 'opacity-20')
})
})
// --------------------------------------------------------------------------
// Component Order Tests
// --------------------------------------------------------------------------
describe('Component Order', () => {
it('should render components in correct order', () => {
const { container } = render(<CreateFromPipeline />)
const children = Array.from(container.firstChild?.childNodes || [])
// Effect, Header, List, Footer
expect(children.length).toBe(4)
})
})
})

View File

@ -1,276 +0,0 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BuiltInPipelineList from './built-in-pipeline-list'
// Mock child components
vi.mock('./create-card', () => ({
default: () => <div data-testid="create-card">CreateCard</div>,
}))
vi.mock('./template-card', () => ({
default: ({ type, pipeline, showMoreOperations }: { type: string, pipeline: { name: string }, showMoreOperations?: boolean }) => (
<div data-testid="template-card" data-type={type} data-show-more={String(showMoreOperations)}>
{pipeline.name}
</div>
),
}))
// Configurable locale mock
let mockLocale = 'en-US'
// Mock hooks
vi.mock('@/context/i18n', () => ({
useLocale: () => mockLocale,
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn((selector) => {
const state = { systemFeatures: { enable_marketplace: true } }
return selector(state)
}),
}))
const mockUsePipelineTemplateList = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
usePipelineTemplateList: (...args: unknown[]) => mockUsePipelineTemplateList(...args),
}))
// ============================================================================
// BuiltInPipelineList Component Tests
// ============================================================================
describe('BuiltInPipelineList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLocale = 'en-US'
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
render(<BuiltInPipelineList />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
})
it('should always render CreateCard', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: null,
isLoading: true,
})
render(<BuiltInPipelineList />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Loading State Tests
// --------------------------------------------------------------------------
describe('Loading State', () => {
it('should not render TemplateCards when loading', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: {
pipeline_templates: [{ name: 'Pipeline 1' }],
},
isLoading: true,
})
render(<BuiltInPipelineList />)
expect(screen.queryByTestId('template-card')).not.toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Rendering with Data Tests
// --------------------------------------------------------------------------
describe('Rendering with Data', () => {
it('should render TemplateCard for each pipeline when not loading', () => {
const mockPipelines = [
{ name: 'Pipeline 1' },
{ name: 'Pipeline 2' },
]
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: mockPipelines },
isLoading: false,
})
render(<BuiltInPipelineList />)
const cards = screen.getAllByTestId('template-card')
expect(cards).toHaveLength(2)
})
it('should pass correct props to TemplateCard', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: {
pipeline_templates: [{ name: 'Test Pipeline' }],
},
isLoading: false,
})
render(<BuiltInPipelineList />)
const card = screen.getByTestId('template-card')
expect(card).toHaveAttribute('data-type', 'built-in')
expect(card).toHaveAttribute('data-show-more', 'false')
})
it('should render CreateCard as first element', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: {
pipeline_templates: [{ name: 'Pipeline 1' }],
},
isLoading: false,
})
const { container } = render(<BuiltInPipelineList />)
const grid = container.querySelector('.grid')
const firstChild = grid?.firstChild as HTMLElement
expect(firstChild).toHaveAttribute('data-testid', 'create-card')
})
})
// --------------------------------------------------------------------------
// API Call Tests
// --------------------------------------------------------------------------
describe('API Call', () => {
it('should call usePipelineTemplateList with type built-in', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: null,
isLoading: true,
})
render(<BuiltInPipelineList />)
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith(
expect.objectContaining({ type: 'built-in' }),
expect.any(Boolean),
)
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have grid layout', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
const { container } = render(<BuiltInPipelineList />)
const grid = container.querySelector('.grid')
expect(grid).toHaveClass('grid-cols-1', 'gap-3', 'py-2')
})
it('should have responsive grid columns', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
const { container } = render(<BuiltInPipelineList />)
const grid = container.querySelector('.grid')
expect(grid).toHaveClass('sm:grid-cols-2', 'md:grid-cols-3', 'lg:grid-cols-4')
})
})
// --------------------------------------------------------------------------
// Locale Handling Tests (Branch Coverage)
// --------------------------------------------------------------------------
describe('Locale Handling', () => {
it('should use zh-Hans locale when set', () => {
mockLocale = 'zh-Hans'
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
render(<BuiltInPipelineList />)
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith(
expect.objectContaining({ language: 'zh-Hans' }),
expect.any(Boolean),
)
})
it('should use ja-JP locale when set', () => {
mockLocale = 'ja-JP'
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
render(<BuiltInPipelineList />)
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith(
expect.objectContaining({ language: 'ja-JP' }),
expect.any(Boolean),
)
})
it('should fallback to default language for unsupported locales', () => {
mockLocale = 'fr-FR'
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
render(<BuiltInPipelineList />)
// Should fall back to LanguagesSupported[0] which is 'en-US'
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith(
expect.objectContaining({ language: 'en-US' }),
expect.any(Boolean),
)
})
it('should fallback to default language for en-US locale', () => {
mockLocale = 'en-US'
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
render(<BuiltInPipelineList />)
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith(
expect.objectContaining({ language: 'en-US' }),
expect.any(Boolean),
)
})
})
// --------------------------------------------------------------------------
// Empty Data Tests
// --------------------------------------------------------------------------
describe('Empty Data', () => {
it('should handle null pipeline_templates', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: null },
isLoading: false,
})
render(<BuiltInPipelineList />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
expect(screen.queryByTestId('template-card')).not.toBeInTheDocument()
})
it('should handle undefined data', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: undefined,
isLoading: false,
})
render(<BuiltInPipelineList />)
expect(screen.getByTestId('create-card')).toBeInTheDocument()
expect(screen.queryByTestId('template-card')).not.toBeInTheDocument()
})
})
})

View File

@ -1,190 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CreateCard from './create-card'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
// Mock amplitude tracking
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock service hooks
const mockCreateEmptyDataset = vi.fn()
const mockInvalidDatasetList = vi.fn()
vi.mock('@/service/knowledge/use-create-dataset', () => ({
useCreatePipelineDataset: () => ({
mutateAsync: mockCreateEmptyDataset,
}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
// ============================================================================
// CreateCard Component Tests
// ============================================================================
describe('CreateCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<CreateCard />)
expect(screen.getByText(/createFromScratch\.title/i)).toBeInTheDocument()
})
it('should render title and description', () => {
render(<CreateCard />)
expect(screen.getByText(/createFromScratch\.title/i)).toBeInTheDocument()
expect(screen.getByText(/createFromScratch\.description/i)).toBeInTheDocument()
})
it('should render add icon', () => {
const { container } = render(<CreateCard />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call createEmptyDataset when clicked', async () => {
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ id: 'new-dataset-id' })
return Promise.resolve()
})
render(<CreateCard />)
const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(card!)
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalled()
})
})
it('should navigate to pipeline page on success', async () => {
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ id: 'test-dataset-123' })
return Promise.resolve()
})
render(<CreateCard />)
const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(card!)
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-123/pipeline')
})
})
it('should invalidate dataset list on success', async () => {
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ id: 'test-id' })
return Promise.resolve()
})
render(<CreateCard />)
const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(card!)
await waitFor(() => {
expect(mockInvalidDatasetList).toHaveBeenCalled()
})
})
it('should handle error callback', async () => {
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
callbacks.onError(new Error('Create failed'))
return Promise.resolve()
})
render(<CreateCard />)
const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(card!)
// Should not throw and should handle error gracefully
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalled()
})
})
it('should not navigate when data is undefined', async () => {
mockCreateEmptyDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess(undefined)
return Promise.resolve()
})
render(<CreateCard />)
const card = screen.getByText(/createFromScratch\.title/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(card!)
await waitFor(() => {
expect(mockCreateEmptyDataset).toHaveBeenCalled()
})
expect(mockPush).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper card styling', () => {
const { container } = render(<CreateCard />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('relative', 'flex', 'cursor-pointer', 'flex-col', 'rounded-xl')
})
it('should have fixed height', () => {
const { container } = render(<CreateCard />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('h-[132px]')
})
it('should have shadow and border', () => {
const { container } = render(<CreateCard />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('border-[0.5px]', 'shadow-xs')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<CreateCard />)
rerender(<CreateCard />)
expect(screen.getByText(/createFromScratch\.title/i)).toBeInTheDocument()
})
})
})

View File

@ -1,151 +0,0 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CustomizedList from './customized-list'
// Mock TemplateCard
vi.mock('./template-card', () => ({
default: ({ type, pipeline }: { type: string, pipeline: { name: string } }) => (
<div data-testid="template-card" data-type={type}>
{pipeline.name}
</div>
),
}))
// Mock usePipelineTemplateList hook
const mockUsePipelineTemplateList = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
usePipelineTemplateList: (...args: unknown[]) => mockUsePipelineTemplateList(...args),
}))
// ============================================================================
// CustomizedList Component Tests
// ============================================================================
describe('CustomizedList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Loading State Tests
// --------------------------------------------------------------------------
describe('Loading State', () => {
it('should return null when loading', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: null,
isLoading: true,
})
const { container } = render(<CustomizedList />)
expect(container.firstChild).toBeNull()
})
})
// --------------------------------------------------------------------------
// Empty State Tests
// --------------------------------------------------------------------------
describe('Empty State', () => {
it('should return null when list is empty', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: [] },
isLoading: false,
})
const { container } = render(<CustomizedList />)
expect(container.firstChild).toBeNull()
})
it('should return null when data is undefined', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: undefined,
isLoading: false,
})
const { container } = render(<CustomizedList />)
expect(container.firstChild).toBeNull()
})
})
// --------------------------------------------------------------------------
// Rendering with Data Tests
// --------------------------------------------------------------------------
describe('Rendering with Data', () => {
it('should render title when list has items', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: {
pipeline_templates: [
{ name: 'Pipeline 1' },
],
},
isLoading: false,
})
render(<CustomizedList />)
expect(screen.getByText(/customized/i)).toBeInTheDocument()
})
it('should render TemplateCard for each pipeline', () => {
const mockPipelines = [
{ name: 'Pipeline 1' },
{ name: 'Pipeline 2' },
{ name: 'Pipeline 3' },
]
mockUsePipelineTemplateList.mockReturnValue({
data: { pipeline_templates: mockPipelines },
isLoading: false,
})
render(<CustomizedList />)
const cards = screen.getAllByTestId('template-card')
expect(cards).toHaveLength(3)
})
it('should pass correct props to TemplateCard', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: {
pipeline_templates: [{ name: 'Test Pipeline' }],
},
isLoading: false,
})
render(<CustomizedList />)
const card = screen.getByTestId('template-card')
expect(card).toHaveAttribute('data-type', 'customized')
expect(card).toHaveTextContent('Test Pipeline')
})
})
// --------------------------------------------------------------------------
// API Call Tests
// --------------------------------------------------------------------------
describe('API Call', () => {
it('should call usePipelineTemplateList with type customized', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: null,
isLoading: true,
})
render(<CustomizedList />)
expect(mockUsePipelineTemplateList).toHaveBeenCalledWith({ type: 'customized' })
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have grid layout for cards', () => {
mockUsePipelineTemplateList.mockReturnValue({
data: {
pipeline_templates: [{ name: 'Pipeline 1' }],
},
isLoading: false,
})
const { container } = render(<CustomizedList />)
const grid = container.querySelector('.grid')
expect(grid).toHaveClass('grid-cols-1', 'gap-3', 'py-2')
})
})
})

View File

@ -1,70 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import List from './index'
// Mock child components
vi.mock('./built-in-pipeline-list', () => ({
default: () => <div data-testid="built-in-list">BuiltInPipelineList</div>,
}))
vi.mock('./customized-list', () => ({
default: () => <div data-testid="customized-list">CustomizedList</div>,
}))
// ============================================================================
// List Component Tests
// ============================================================================
describe('List', () => {
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<List />)
expect(screen.getByTestId('built-in-list')).toBeInTheDocument()
})
it('should render BuiltInPipelineList component', () => {
render(<List />)
expect(screen.getByTestId('built-in-list')).toBeInTheDocument()
})
it('should render CustomizedList component', () => {
render(<List />)
expect(screen.getByTestId('customized-list')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container classes', () => {
const { container } = render(<List />)
const listDiv = container.firstChild as HTMLElement
expect(listDiv).toHaveClass('grow', 'overflow-y-auto', 'px-16', 'pb-[60px]', 'pt-1')
})
it('should have gap between items', () => {
const { container } = render(<List />)
const listDiv = container.firstChild as HTMLElement
expect(listDiv).toHaveClass('gap-y-1')
})
})
// --------------------------------------------------------------------------
// Component Order Tests
// --------------------------------------------------------------------------
describe('Component Order', () => {
it('should render BuiltInPipelineList before CustomizedList', () => {
const { container } = render(<List />)
const children = Array.from(container.firstChild?.childNodes || [])
expect(children.length).toBe(2)
expect((children[0] as HTMLElement).getAttribute('data-testid')).toBe('built-in-list')
expect((children[1] as HTMLElement).getAttribute('data-testid')).toBe('customized-list')
})
})
})

View File

@ -1,154 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Actions from './actions'
// ============================================================================
// Actions Component Tests
// ============================================================================
describe('Actions', () => {
const defaultProps = {
onApplyTemplate: vi.fn(),
handleShowTemplateDetails: vi.fn(),
showMoreOperations: true,
openEditModal: vi.fn(),
handleExportDSL: vi.fn(),
handleDelete: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Actions {...defaultProps} />)
expect(screen.getByText(/operations\.choose/i)).toBeInTheDocument()
})
it('should render choose button', () => {
render(<Actions {...defaultProps} />)
expect(screen.getByText(/operations\.choose/i)).toBeInTheDocument()
})
it('should render details button', () => {
render(<Actions {...defaultProps} />)
expect(screen.getByText(/operations\.details/i)).toBeInTheDocument()
})
it('should render add icon', () => {
const { container } = render(<Actions {...defaultProps} />)
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThan(0)
})
it('should render arrow icon for details', () => {
const { container } = render(<Actions {...defaultProps} />)
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThan(1)
})
})
// --------------------------------------------------------------------------
// More Operations Tests
// --------------------------------------------------------------------------
describe('More Operations', () => {
it('should render more operations button when showMoreOperations is true', () => {
const { container } = render(<Actions {...defaultProps} showMoreOperations={true} />)
// CustomPopover should be rendered with more button
const moreButton = container.querySelector('[class*="rounded-lg"]')
expect(moreButton).toBeInTheDocument()
})
it('should not render more operations button when showMoreOperations is false', () => {
render(<Actions {...defaultProps} showMoreOperations={false} />)
// Should only have choose and details buttons
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(2)
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onApplyTemplate when choose button is clicked', () => {
render(<Actions {...defaultProps} />)
const chooseButton = screen.getByText(/operations\.choose/i).closest('button')
fireEvent.click(chooseButton!)
expect(defaultProps.onApplyTemplate).toHaveBeenCalledTimes(1)
})
it('should call handleShowTemplateDetails when details button is clicked', () => {
render(<Actions {...defaultProps} />)
const detailsButton = screen.getByText(/operations\.details/i).closest('button')
fireEvent.click(detailsButton!)
expect(defaultProps.handleShowTemplateDetails).toHaveBeenCalledTimes(1)
})
})
// --------------------------------------------------------------------------
// Button Variants Tests
// --------------------------------------------------------------------------
describe('Button Variants', () => {
it('should have primary variant for choose button', () => {
render(<Actions {...defaultProps} />)
const chooseButton = screen.getByText(/operations\.choose/i).closest('button')
expect(chooseButton).toHaveClass('btn-primary')
})
it('should have secondary variant for details button', () => {
render(<Actions {...defaultProps} />)
const detailsButton = screen.getByText(/operations\.details/i).closest('button')
expect(detailsButton).toHaveClass('btn-secondary')
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have absolute positioning', () => {
const { container } = render(<Actions {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('absolute', 'bottom-0', 'left-0')
})
it('should be hidden by default', () => {
const { container } = render(<Actions {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('hidden')
})
it('should show on group hover', () => {
const { container } = render(<Actions {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('group-hover:flex')
})
it('should have proper z-index', () => {
const { container } = render(<Actions {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('z-10')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Actions {...defaultProps} />)
rerender(<Actions {...defaultProps} />)
expect(screen.getByText(/operations\.choose/i)).toBeInTheDocument()
})
})
})

View File

@ -1,199 +0,0 @@
import type { IconInfo } from '@/models/datasets'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import Content from './content'
// ============================================================================
// Test Data Factories
// ============================================================================
const createIconInfo = (overrides: Partial<IconInfo> = {}): IconInfo => ({
icon_type: 'emoji',
icon: '📊',
icon_background: '#FFF4ED',
icon_url: '',
...overrides,
})
const createImageIconInfo = (overrides: Partial<IconInfo> = {}): IconInfo => ({
icon_type: 'image',
icon: 'file-id-123',
icon_background: '',
icon_url: 'https://example.com/icon.png',
...overrides,
})
// ============================================================================
// Content Component Tests
// ============================================================================
describe('Content', () => {
const defaultProps = {
name: 'Test Pipeline',
description: 'This is a test pipeline description',
iconInfo: createIconInfo(),
chunkStructure: 'text' as ChunkingMode,
}
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Content {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should render name', () => {
render(<Content {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should render description', () => {
render(<Content {...defaultProps} />)
expect(screen.getByText('This is a test pipeline description')).toBeInTheDocument()
})
it('should render chunking mode text', () => {
render(<Content {...defaultProps} />)
// The translation key should be rendered
expect(screen.getByText(/chunkingMode/i)).toBeInTheDocument()
})
it('should have title attribute for truncation', () => {
render(<Content {...defaultProps} />)
const nameElement = screen.getByText('Test Pipeline')
expect(nameElement).toHaveAttribute('title', 'Test Pipeline')
})
it('should have title attribute on description', () => {
render(<Content {...defaultProps} />)
const descElement = screen.getByText('This is a test pipeline description')
expect(descElement).toHaveAttribute('title', 'This is a test pipeline description')
})
})
// --------------------------------------------------------------------------
// Icon Rendering Tests
// --------------------------------------------------------------------------
describe('Icon Rendering', () => {
it('should render emoji icon correctly', () => {
const { container } = render(<Content {...defaultProps} />)
// AppIcon component should be rendered
const iconContainer = container.querySelector('[class*="shrink-0"]')
expect(iconContainer).toBeInTheDocument()
})
it('should render image icon correctly', () => {
const props = {
...defaultProps,
iconInfo: createImageIconInfo(),
}
const { container } = render(<Content {...props} />)
const iconContainer = container.querySelector('[class*="shrink-0"]')
expect(iconContainer).toBeInTheDocument()
})
it('should render chunk structure icon', () => {
const { container } = render(<Content {...defaultProps} />)
// Icon should be rendered in the corner
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// Chunk Structure Tests
// --------------------------------------------------------------------------
describe('Chunk Structure', () => {
it('should handle text chunk structure', () => {
render(<Content {...defaultProps} chunkStructure={ChunkingMode.text} />)
expect(screen.getByText(/chunkingMode/i)).toBeInTheDocument()
})
it('should handle parent-child chunk structure', () => {
render(<Content {...defaultProps} chunkStructure={ChunkingMode.parentChild} />)
expect(screen.getByText(/chunkingMode/i)).toBeInTheDocument()
})
it('should handle qa chunk structure', () => {
render(<Content {...defaultProps} chunkStructure={ChunkingMode.qa} />)
expect(screen.getByText(/chunkingMode/i)).toBeInTheDocument()
})
it('should fallback to General icon for unknown chunk structure', () => {
const { container } = render(
<Content {...defaultProps} chunkStructure={'unknown' as ChunkingMode} />,
)
const icons = container.querySelectorAll('svg')
expect(icons.length).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper header layout', () => {
const { container } = render(<Content {...defaultProps} />)
const header = container.querySelector('[class*="gap-x-3"]')
expect(header).toBeInTheDocument()
})
it('should have truncate class on name', () => {
render(<Content {...defaultProps} />)
const nameElement = screen.getByText('Test Pipeline')
expect(nameElement).toHaveClass('truncate')
})
it('should have line-clamp on description', () => {
render(<Content {...defaultProps} />)
const descElement = screen.getByText('This is a test pipeline description')
expect(descElement).toHaveClass('line-clamp-3')
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle empty name', () => {
render(<Content {...defaultProps} name="" />)
const { container } = render(<Content {...defaultProps} name="" />)
expect(container).toBeInTheDocument()
})
it('should handle empty description', () => {
render(<Content {...defaultProps} description="" />)
const { container } = render(<Content {...defaultProps} description="" />)
expect(container).toBeInTheDocument()
})
it('should handle long name', () => {
const longName = 'A'.repeat(100)
render(<Content {...defaultProps} name={longName} />)
const nameElement = screen.getByText(longName)
expect(nameElement).toHaveClass('truncate')
})
it('should handle long description', () => {
const longDesc = 'A'.repeat(500)
render(<Content {...defaultProps} description={longDesc} />)
const descElement = screen.getByText(longDesc)
expect(descElement).toHaveClass('line-clamp-3')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Content {...defaultProps} />)
rerender(<Content {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
})
})

View File

@ -1,182 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ChunkStructureCard from './chunk-structure-card'
import { EffectColor } from './types'
// ============================================================================
// ChunkStructureCard Component Tests
// ============================================================================
describe('ChunkStructureCard', () => {
const defaultProps = {
icon: <span data-testid="test-icon">Icon</span>,
title: 'General',
description: 'General chunk structure description',
effectColor: EffectColor.indigo,
}
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ChunkStructureCard {...defaultProps} />)
expect(screen.getByText('General')).toBeInTheDocument()
})
it('should render title', () => {
render(<ChunkStructureCard {...defaultProps} />)
expect(screen.getByText('General')).toBeInTheDocument()
})
it('should render description', () => {
render(<ChunkStructureCard {...defaultProps} />)
expect(screen.getByText('General chunk structure description')).toBeInTheDocument()
})
it('should render icon', () => {
render(<ChunkStructureCard {...defaultProps} />)
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
})
it('should not render description when empty', () => {
render(<ChunkStructureCard {...defaultProps} description="" />)
expect(screen.getByText('General')).toBeInTheDocument()
expect(screen.queryByText('General chunk structure description')).not.toBeInTheDocument()
})
it('should not render description when undefined', () => {
const { description: _, ...propsWithoutDesc } = defaultProps
render(<ChunkStructureCard {...propsWithoutDesc} />)
expect(screen.getByText('General')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Effect Colors Tests
// --------------------------------------------------------------------------
describe('Effect Colors', () => {
it('should apply indigo effect color', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.indigo} />,
)
const effectElement = container.querySelector('[class*="blur-"]')
expect(effectElement).toHaveClass('bg-util-colors-indigo-indigo-600')
})
it('should apply blueLight effect color', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.blueLight} />,
)
const effectElement = container.querySelector('[class*="blur-"]')
expect(effectElement).toHaveClass('bg-util-colors-blue-light-blue-light-500')
})
it('should apply green effect color', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.green} />,
)
const effectElement = container.querySelector('[class*="blur-"]')
expect(effectElement).toHaveClass('bg-util-colors-teal-teal-600')
})
it('should handle none effect color', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.none} />,
)
const effectElement = container.querySelector('[class*="blur-"]')
expect(effectElement).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Icon Background Tests
// --------------------------------------------------------------------------
describe('Icon Background', () => {
it('should apply indigo icon background', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.indigo} />,
)
const iconBg = container.querySelector('[class*="bg-components-icon-bg"]')
expect(iconBg).toHaveClass('bg-components-icon-bg-indigo-solid')
})
it('should apply blue light icon background', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.blueLight} />,
)
const iconBg = container.querySelector('[class*="bg-components-icon-bg"]')
expect(iconBg).toHaveClass('bg-components-icon-bg-blue-light-solid')
})
it('should apply green icon background', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} effectColor={EffectColor.green} />,
)
const iconBg = container.querySelector('[class*="bg-components-icon-bg"]')
expect(iconBg).toHaveClass('bg-components-icon-bg-teal-solid')
})
})
// --------------------------------------------------------------------------
// Custom className Tests
// --------------------------------------------------------------------------
describe('Custom className', () => {
it('should apply custom className', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} className="custom-class" />,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
it('should merge custom className with default classes', () => {
const { container } = render(
<ChunkStructureCard {...defaultProps} className="custom-class" />,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative', 'flex', 'custom-class')
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper card styling', () => {
const { container } = render(<ChunkStructureCard {...defaultProps} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('relative', 'flex', 'overflow-hidden', 'rounded-xl')
})
it('should have border styling', () => {
const { container } = render(<ChunkStructureCard {...defaultProps} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('border-[0.5px]', 'border-components-panel-border-subtle')
})
it('should have shadow styling', () => {
const { container } = render(<ChunkStructureCard {...defaultProps} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('shadow-xs')
})
it('should have blur effect element', () => {
const { container } = render(<ChunkStructureCard {...defaultProps} />)
const blurElement = container.querySelector('[class*="blur-"]')
expect(blurElement).toHaveClass('absolute', '-left-1', '-top-1', 'size-14', 'rounded-full')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<ChunkStructureCard {...defaultProps} />)
rerender(<ChunkStructureCard {...defaultProps} />)
expect(screen.getByText('General')).toBeInTheDocument()
})
})
})

View File

@ -1,138 +0,0 @@
import { renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { useChunkStructureConfig } from './hooks'
import { EffectColor } from './types'
// ============================================================================
// useChunkStructureConfig Hook Tests
// ============================================================================
describe('useChunkStructureConfig', () => {
// --------------------------------------------------------------------------
// Return Value Tests
// --------------------------------------------------------------------------
describe('Return Value', () => {
it('should return config object', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current).toBeDefined()
expect(typeof result.current).toBe('object')
})
it('should have config for text chunking mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.text]).toBeDefined()
})
it('should have config for parent-child chunking mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.parentChild]).toBeDefined()
})
it('should have config for qa chunking mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.qa]).toBeDefined()
})
})
// --------------------------------------------------------------------------
// Text/General Config Tests
// --------------------------------------------------------------------------
describe('Text/General Config', () => {
it('should have title for text mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.text].title).toBe('General')
})
it('should have description for text mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.text].description).toBeDefined()
})
it('should have icon for text mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.text].icon).toBeDefined()
})
it('should have indigo effect color for text mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.text].effectColor).toBe(EffectColor.indigo)
})
})
// --------------------------------------------------------------------------
// Parent-Child Config Tests
// --------------------------------------------------------------------------
describe('Parent-Child Config', () => {
it('should have title for parent-child mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.parentChild].title).toBe('Parent-Child')
})
it('should have description for parent-child mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.parentChild].description).toBeDefined()
})
it('should have icon for parent-child mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.parentChild].icon).toBeDefined()
})
it('should have blueLight effect color for parent-child mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.parentChild].effectColor).toBe(EffectColor.blueLight)
})
})
// --------------------------------------------------------------------------
// Q&A Config Tests
// --------------------------------------------------------------------------
describe('Q&A Config', () => {
it('should have title for qa mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.qa].title).toBe('Q&A')
})
it('should have description for qa mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.qa].description).toBeDefined()
})
it('should have icon for qa mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.qa].icon).toBeDefined()
})
it('should have green effect color for qa mode', () => {
const { result } = renderHook(() => useChunkStructureConfig())
expect(result.current[ChunkingMode.qa].effectColor).toBe(EffectColor.green)
})
})
// --------------------------------------------------------------------------
// Option Structure Tests
// --------------------------------------------------------------------------
describe('Option Structure', () => {
it('should have all required fields in each option', () => {
const { result } = renderHook(() => useChunkStructureConfig())
Object.values(result.current).forEach((option) => {
expect(option).toHaveProperty('icon')
expect(option).toHaveProperty('title')
expect(option).toHaveProperty('description')
expect(option).toHaveProperty('effectColor')
})
})
it('should cover all ChunkingMode values', () => {
const { result } = renderHook(() => useChunkStructureConfig())
const modes = Object.values(ChunkingMode)
modes.forEach((mode) => {
expect(result.current[mode]).toBeDefined()
})
})
})
})

View File

@ -1,360 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Details from './index'
// Mock WorkflowPreview
vi.mock('@/app/components/workflow/workflow-preview', () => ({
default: ({ className }: { className?: string }) => (
<div data-testid="workflow-preview" className={className}>
WorkflowPreview
</div>
),
}))
// Mock service hook
const mockUsePipelineTemplateById = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
usePipelineTemplateById: (...args: unknown[]) => mockUsePipelineTemplateById(...args),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createPipelineTemplateInfo = (overrides = {}) => ({
name: 'Test Pipeline',
description: 'This is a test pipeline',
icon_info: {
icon_type: 'emoji',
icon: '📊',
icon_background: '#FFF4ED',
icon_url: '',
},
created_by: 'Test User',
chunk_structure: 'text',
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
export_data: '',
...overrides,
})
const createImageIconPipelineInfo = () => ({
...createPipelineTemplateInfo(),
icon_info: {
icon_type: 'image',
icon: 'file-id-123',
icon_background: '',
icon_url: 'https://example.com/icon.png',
},
})
// ============================================================================
// Details Component Tests
// ============================================================================
describe('Details', () => {
const defaultProps = {
id: 'pipeline-1',
type: 'customized' as const,
onApplyTemplate: vi.fn(),
onClose: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Loading State Tests
// --------------------------------------------------------------------------
describe('Loading State', () => {
it('should show loading when data is not available', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: null,
})
render(<Details {...defaultProps} />)
// Loading component should be rendered
expect(screen.queryByText('Test Pipeline')).not.toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing when data is available', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should render pipeline name', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should render pipeline description', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText('This is a test pipeline')).toBeInTheDocument()
})
it('should render created by when available', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText(/details\.createdBy/i)).toBeInTheDocument()
})
it('should not render created by when not available', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo({ created_by: '' }),
})
render(<Details {...defaultProps} />)
expect(screen.queryByText(/details\.createdBy/i)).not.toBeInTheDocument()
})
it('should render use template button', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText(/operations\.useTemplate/i)).toBeInTheDocument()
})
it('should render structure section', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText(/details\.structure/i)).toBeInTheDocument()
})
it('should render close button', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
const { container } = render(<Details {...defaultProps} />)
const closeButton = container.querySelector('button[type="button"]')
expect(closeButton).toBeInTheDocument()
})
it('should render workflow preview', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByTestId('workflow-preview')).toBeInTheDocument()
})
it('should render tooltip for structure', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
// Tooltip component should be present
expect(screen.getByText(/details\.structure/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onClose when close button is clicked', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
const { container } = render(<Details {...defaultProps} />)
const closeButton = container.querySelector('button[type="button"]')
fireEvent.click(closeButton!)
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
it('should call onApplyTemplate when use template button is clicked', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
const useButton = screen.getByText(/operations\.useTemplate/i).closest('button')
fireEvent.click(useButton!)
expect(defaultProps.onApplyTemplate).toHaveBeenCalledTimes(1)
})
})
// --------------------------------------------------------------------------
// Icon Types Tests
// --------------------------------------------------------------------------
describe('Icon Types', () => {
it('should handle emoji icon type', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should handle image icon type', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createImageIconPipelineInfo(),
})
render(<Details {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should have default icon when data is null', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: null,
})
// When data is null, component shows loading state
// The default icon is only used in useMemo when pipelineTemplateInfo is null
render(<Details {...defaultProps} />)
// Should not crash and should render (loading state)
expect(screen.queryByText('Test Pipeline')).not.toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// API Call Tests
// --------------------------------------------------------------------------
describe('API Call', () => {
it('should call usePipelineTemplateById with correct params', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} />)
expect(mockUsePipelineTemplateById).toHaveBeenCalledWith(
{ template_id: 'pipeline-1', type: 'customized' },
true,
)
})
it('should call usePipelineTemplateById with built-in type', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
render(<Details {...defaultProps} type="built-in" />)
expect(mockUsePipelineTemplateById).toHaveBeenCalledWith(
{ template_id: 'pipeline-1', type: 'built-in' },
true,
)
})
})
// --------------------------------------------------------------------------
// Chunk Structure Tests
// --------------------------------------------------------------------------
describe('Chunk Structure', () => {
it('should render chunk structure card for text mode', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo({ chunk_structure: 'text' }),
})
render(<Details {...defaultProps} />)
expect(screen.getByText(/details\.structure/i)).toBeInTheDocument()
})
it('should render chunk structure card for parent-child mode', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo({ chunk_structure: 'hierarchical' }),
})
render(<Details {...defaultProps} />)
expect(screen.getByText(/details\.structure/i)).toBeInTheDocument()
})
it('should render chunk structure card for qa mode', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo({ chunk_structure: 'qa' }),
})
render(<Details {...defaultProps} />)
expect(screen.getByText(/details\.structure/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
const { container } = render(<Details {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex', 'h-full')
})
it('should have fixed width sidebar', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
const { container } = render(<Details {...defaultProps} />)
const sidebar = container.querySelector('[class*="w-[360px]"]')
expect(sidebar).toBeInTheDocument()
})
it('should have workflow preview container with grow class', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
const { container } = render(<Details {...defaultProps} />)
const previewContainer = container.querySelector('[class*="grow"]')
expect(previewContainer).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
mockUsePipelineTemplateById.mockReturnValue({
data: createPipelineTemplateInfo(),
})
const { rerender } = render(<Details {...defaultProps} />)
rerender(<Details {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
})
})

View File

@ -1,665 +0,0 @@
import type { PipelineTemplate } from '@/models/pipeline'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { ChunkingMode } from '@/models/datasets'
import EditPipelineInfo from './edit-pipeline-info'
// Mock service hooks
const mockUpdatePipeline = vi.fn()
const mockInvalidCustomizedTemplateList = vi.fn()
vi.mock('@/service/use-pipeline', () => ({
useUpdateTemplateInfo: () => ({
mutateAsync: mockUpdatePipeline,
}),
useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock AppIconPicker to capture interactions
let _mockOnSelect: ((icon: { type: 'emoji' | 'image', icon?: string, background?: string, fileId?: string, url?: string }) => void) | undefined
let _mockOnClose: (() => void) | undefined
vi.mock('@/app/components/base/app-icon-picker', () => ({
default: ({ onSelect, onClose }: {
onSelect: (icon: { type: 'emoji' | 'image', icon?: string, background?: string, fileId?: string, url?: string }) => void
onClose: () => void
}) => {
_mockOnSelect = onSelect
_mockOnClose = onClose
return (
<div data-testid="app-icon-picker">
<button data-testid="select-emoji" onClick={() => onSelect({ type: 'emoji', icon: '🎯', background: '#FFEAD5' })}>
Select Emoji
</button>
<button data-testid="select-image" onClick={() => onSelect({ type: 'image', fileId: 'new-file-id', url: 'https://new-icon.com/icon.png' })}>
Select Image
</button>
<button data-testid="close-picker" onClick={onClose}>
Close Picker
</button>
</div>
)
},
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createPipelineTemplate = (overrides: Partial<PipelineTemplate> = {}): PipelineTemplate => ({
id: 'pipeline-1',
name: 'Test Pipeline',
description: 'Test pipeline description',
icon: {
icon_type: 'emoji',
icon: '📊',
icon_background: '#FFF4ED',
icon_url: '',
},
chunk_structure: ChunkingMode.text,
position: 0,
...overrides,
})
const createImagePipelineTemplate = (): PipelineTemplate => ({
id: 'pipeline-2',
name: 'Image Pipeline',
description: 'Pipeline with image icon',
icon: {
icon_type: 'image',
icon: 'file-id-123',
icon_background: '',
icon_url: 'https://example.com/icon.png',
},
chunk_structure: ChunkingMode.text,
position: 1,
})
// ============================================================================
// EditPipelineInfo Component Tests
// ============================================================================
describe('EditPipelineInfo', () => {
const defaultProps = {
onClose: vi.fn(),
pipeline: createPipelineTemplate(),
}
beforeEach(() => {
vi.clearAllMocks()
_mockOnSelect = undefined
_mockOnClose = undefined
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<EditPipelineInfo {...defaultProps} />)
expect(screen.getByText(/editPipelineInfo/i)).toBeInTheDocument()
})
it('should render title', () => {
render(<EditPipelineInfo {...defaultProps} />)
expect(screen.getByText(/editPipelineInfo/i)).toBeInTheDocument()
})
it('should render close button', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const closeButton = container.querySelector('button[type="button"]')
expect(closeButton).toBeInTheDocument()
})
it('should render name input with initial value', () => {
render(<EditPipelineInfo {...defaultProps} />)
const input = screen.getByDisplayValue('Test Pipeline')
expect(input).toBeInTheDocument()
})
it('should render description textarea with initial value', () => {
render(<EditPipelineInfo {...defaultProps} />)
const textarea = screen.getByDisplayValue('Test pipeline description')
expect(textarea).toBeInTheDocument()
})
it('should render save and cancel buttons', () => {
render(<EditPipelineInfo {...defaultProps} />)
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
})
it('should render name and icon label', () => {
render(<EditPipelineInfo {...defaultProps} />)
expect(screen.getByText(/pipelineNameAndIcon/i)).toBeInTheDocument()
})
it('should render description label', () => {
render(<EditPipelineInfo {...defaultProps} />)
expect(screen.getByText(/knowledgeDescription/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onClose when close button is clicked', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const closeButton = container.querySelector('button[type="button"]')
fireEvent.click(closeButton!)
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
it('should call onClose when cancel button is clicked', () => {
render(<EditPipelineInfo {...defaultProps} />)
const cancelButton = screen.getByText(/operation\.cancel/i)
fireEvent.click(cancelButton)
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
})
it('should update name when input changes', () => {
render(<EditPipelineInfo {...defaultProps} />)
const input = screen.getByDisplayValue('Test Pipeline')
fireEvent.change(input, { target: { value: 'New Pipeline Name' } })
expect(screen.getByDisplayValue('New Pipeline Name')).toBeInTheDocument()
})
it('should update description when textarea changes', () => {
render(<EditPipelineInfo {...defaultProps} />)
const textarea = screen.getByDisplayValue('Test pipeline description')
fireEvent.change(textarea, { target: { value: 'New description' } })
expect(screen.getByDisplayValue('New description')).toBeInTheDocument()
})
it('should call updatePipeline when save is clicked with valid name', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<EditPipelineInfo {...defaultProps} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalled()
})
})
it('should invalidate template list on successful save', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<EditPipelineInfo {...defaultProps} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockInvalidCustomizedTemplateList).toHaveBeenCalled()
})
})
it('should call onClose on successful save', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<EditPipelineInfo {...defaultProps} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(defaultProps.onClose).toHaveBeenCalled()
})
})
})
// --------------------------------------------------------------------------
// Validation Tests
// --------------------------------------------------------------------------
describe('Validation', () => {
it('should show error toast when name is empty', async () => {
render(<EditPipelineInfo {...defaultProps} />)
const input = screen.getByDisplayValue('Test Pipeline')
fireEvent.change(input, { target: { value: '' } })
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: 'Please enter a name for the Knowledge Base.',
})
})
})
it('should not call updatePipeline when name is empty', async () => {
render(<EditPipelineInfo {...defaultProps} />)
const input = screen.getByDisplayValue('Test Pipeline')
fireEvent.change(input, { target: { value: '' } })
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).not.toHaveBeenCalled()
})
})
})
// --------------------------------------------------------------------------
// Icon Types Tests (Branch Coverage for lines 29-30, 36-37)
// --------------------------------------------------------------------------
describe('Icon Types', () => {
it('should initialize with emoji icon type when pipeline has emoji icon', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
// Should render component with emoji icon
expect(container.querySelector('[class*="cursor-pointer"]')).toBeInTheDocument()
expect(screen.getByDisplayValue('Test Pipeline')).toBeInTheDocument()
})
it('should initialize with image icon type when pipeline has image icon', async () => {
const imagePipeline = createImagePipelineTemplate()
// Verify test data has image icon type - this ensures the factory returns correct data
expect(imagePipeline.icon.icon_type).toBe('image')
expect(imagePipeline.icon.icon).toBe('file-id-123')
expect(imagePipeline.icon.icon_url).toBe('https://example.com/icon.png')
const props = {
onClose: vi.fn(),
pipeline: imagePipeline,
}
const { container } = render(<EditPipelineInfo {...props} />)
// Component should initialize with image icon state
expect(screen.getByDisplayValue('Image Pipeline')).toBeInTheDocument()
expect(container.querySelector('[class*="cursor-pointer"]')).toBeInTheDocument()
})
it('should render correctly with image icon and then update', () => {
// This test exercises both the initialization and update paths for image icon
const imagePipeline = createImagePipelineTemplate()
const props = {
...defaultProps,
pipeline: imagePipeline,
}
const { container } = render(<EditPipelineInfo {...props} />)
// Verify component rendered with image pipeline
expect(screen.getByDisplayValue('Image Pipeline')).toBeInTheDocument()
// Open icon picker
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
})
it('should save correct icon_info when starting with image icon type', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
const props = {
...defaultProps,
pipeline: createImagePipelineTemplate(),
}
render(<EditPipelineInfo {...props} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'image',
icon: 'file-id-123',
}),
}),
expect.any(Object),
)
})
})
it('should save correct icon_info when starting with emoji icon type', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<EditPipelineInfo {...defaultProps} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'emoji',
icon: '📊',
}),
}),
expect.any(Object),
)
})
})
it('should revert to initial image icon when picker is closed without selection', () => {
const props = {
...defaultProps,
pipeline: createImagePipelineTemplate(),
}
const { container } = render(<EditPipelineInfo {...props} />)
// Open picker
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
// Close without selection - should revert to original image icon
const closeButton = screen.getByTestId('close-picker')
fireEvent.click(closeButton)
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
it('should switch from image icon to emoji icon when selected', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
const props = {
...defaultProps,
pipeline: createImagePipelineTemplate(),
}
const { container } = render(<EditPipelineInfo {...props} />)
// Open picker and select emoji
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectEmojiButton = screen.getByTestId('select-emoji')
fireEvent.click(selectEmojiButton)
// Save
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'emoji',
icon: '🎯',
}),
}),
expect.any(Object),
)
})
})
it('should switch from emoji icon to image icon when selected', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
const { container } = render(<EditPipelineInfo {...defaultProps} />)
// Open picker and select image
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectImageButton = screen.getByTestId('select-image')
fireEvent.click(selectImageButton)
// Save
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'image',
icon: 'new-file-id',
}),
}),
expect.any(Object),
)
})
})
})
// --------------------------------------------------------------------------
// AppIconPicker Tests (Branch Coverage)
// --------------------------------------------------------------------------
describe('AppIconPicker', () => {
it('should not show picker initially', () => {
render(<EditPipelineInfo {...defaultProps} />)
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
it('should open picker when icon is clicked', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
expect(screen.getByTestId('app-icon-picker')).toBeInTheDocument()
})
it('should close picker and update icon when emoji is selected', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectEmojiButton = screen.getByTestId('select-emoji')
fireEvent.click(selectEmojiButton)
// Picker should close
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
it('should close picker and update icon when image is selected', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectImageButton = screen.getByTestId('select-image')
fireEvent.click(selectImageButton)
// Picker should close
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
it('should revert icon when picker is closed without selection', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const closeButton = screen.getByTestId('close-picker')
fireEvent.click(closeButton)
// Picker should close
expect(screen.queryByTestId('app-icon-picker')).not.toBeInTheDocument()
})
it('should save with new emoji icon selection', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
const { container } = render(<EditPipelineInfo {...defaultProps} />)
// Open picker and select new emoji
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectEmojiButton = screen.getByTestId('select-emoji')
fireEvent.click(selectEmojiButton)
// Save
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'emoji',
icon: '🎯',
icon_background: '#FFEAD5',
}),
}),
expect.any(Object),
)
})
})
it('should save with new image icon selection', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
const { container } = render(<EditPipelineInfo {...defaultProps} />)
// Open picker and select new image
const appIcon = container.querySelector('[class*="cursor-pointer"]')
fireEvent.click(appIcon!)
const selectImageButton = screen.getByTestId('select-image')
fireEvent.click(selectImageButton)
// Save
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
icon_info: expect.objectContaining({
icon_type: 'image',
icon: 'new-file-id',
icon_url: 'https://new-icon.com/icon.png',
}),
}),
expect.any(Object),
)
})
})
})
// --------------------------------------------------------------------------
// Save Request Tests
// --------------------------------------------------------------------------
describe('Save Request', () => {
it('should send correct request with emoji icon', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<EditPipelineInfo {...defaultProps} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
template_id: 'pipeline-1',
name: 'Test Pipeline',
description: 'Test pipeline description',
icon_info: expect.objectContaining({
icon_type: 'emoji',
}),
}),
expect.any(Object),
)
})
})
it('should send correct request with image icon', async () => {
mockUpdatePipeline.mockImplementation((_data, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
const props = {
...defaultProps,
pipeline: createImagePipelineTemplate(),
}
render(<EditPipelineInfo {...props} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockUpdatePipeline).toHaveBeenCalledWith(
expect.objectContaining({
template_id: 'pipeline-2',
icon_info: expect.objectContaining({
icon_type: 'image',
}),
}),
expect.any(Object),
)
})
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper container styling', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative', 'flex', 'flex-col')
})
it('should have close button in header', () => {
const { container } = render(<EditPipelineInfo {...defaultProps} />)
const closeButton = container.querySelector('button.absolute')
expect(closeButton).toHaveClass('right-5', 'top-5')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<EditPipelineInfo {...defaultProps} />)
rerender(<EditPipelineInfo {...defaultProps} />)
expect(screen.getByText(/editPipelineInfo/i)).toBeInTheDocument()
})
})
})

View File

@ -1,722 +0,0 @@
import type { PipelineTemplate } from '@/models/pipeline'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { ChunkingMode } from '@/models/datasets'
import TemplateCard from './index'
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
// Mock amplitude tracking
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
// Mock Toast
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
}))
// Mock downloadFile utility
vi.mock('@/utils/format', () => ({
downloadFile: vi.fn(),
}))
// Capture Confirm callbacks
let _capturedOnConfirm: (() => void) | undefined
let _capturedOnCancel: (() => void) | undefined
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onConfirm, onCancel, title, content }: {
isShow: boolean
onConfirm: () => void
onCancel: () => void
title: string
content: string
}) => {
_capturedOnConfirm = onConfirm
_capturedOnCancel = onCancel
return isShow
? (
<div data-testid="confirm-dialog">
<div data-testid="confirm-title">{title}</div>
<div data-testid="confirm-content">{content}</div>
<button data-testid="confirm-cancel" onClick={onCancel}>Cancel</button>
<button data-testid="confirm-submit" onClick={onConfirm}>Confirm</button>
</div>
)
: null
},
}))
// Capture Actions callbacks
let _capturedHandleDelete: (() => void) | undefined
let _capturedHandleExportDSL: (() => void) | undefined
let _capturedOpenEditModal: (() => void) | undefined
vi.mock('./actions', () => ({
default: ({ onApplyTemplate, handleShowTemplateDetails, showMoreOperations, openEditModal, handleExportDSL, handleDelete }: {
onApplyTemplate: () => void
handleShowTemplateDetails: () => void
showMoreOperations: boolean
openEditModal: () => void
handleExportDSL: () => void
handleDelete: () => void
}) => {
_capturedHandleDelete = handleDelete
_capturedHandleExportDSL = handleExportDSL
_capturedOpenEditModal = openEditModal
return (
<div data-testid="actions">
<button data-testid="action-choose" onClick={onApplyTemplate}>operations.choose</button>
<button data-testid="action-details" onClick={handleShowTemplateDetails}>operations.details</button>
{showMoreOperations && (
<>
<button data-testid="action-edit" onClick={openEditModal}>Edit</button>
<button data-testid="action-export" onClick={handleExportDSL}>Export</button>
<button data-testid="action-delete" onClick={handleDelete}>Delete</button>
</>
)}
</div>
)
},
}))
// Mock EditPipelineInfo component
vi.mock('./edit-pipeline-info', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="edit-pipeline-info">
<button data-testid="edit-close" onClick={onClose}>Close</button>
</div>
),
}))
// Mock Details component
vi.mock('./details', () => ({
default: ({ onClose, onApplyTemplate }: { onClose: () => void, onApplyTemplate: () => void }) => (
<div data-testid="details-component">
<button data-testid="details-close" onClick={onClose}>Close</button>
<button data-testid="details-apply" onClick={onApplyTemplate}>Apply</button>
</div>
),
}))
// Mock service hooks
const mockCreateDataset = vi.fn()
const mockInvalidDatasetList = vi.fn()
const mockGetPipelineTemplateInfo = vi.fn()
const mockDeletePipeline = vi.fn()
const mockExportPipelineDSL = vi.fn()
const mockInvalidCustomizedTemplateList = vi.fn()
const mockHandleCheckPluginDependencies = vi.fn()
// Configurable isPending for export
let mockIsExporting = false
vi.mock('@/service/knowledge/use-create-dataset', () => ({
useCreatePipelineDatasetFromCustomized: () => ({
mutateAsync: mockCreateDataset,
}),
}))
vi.mock('@/service/knowledge/use-dataset', () => ({
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
vi.mock('@/service/use-pipeline', () => ({
usePipelineTemplateById: () => ({
refetch: mockGetPipelineTemplateInfo,
}),
useDeleteTemplate: () => ({
mutateAsync: mockDeletePipeline,
}),
useExportTemplateDSL: () => ({
mutateAsync: mockExportPipelineDSL,
get isPending() { return mockIsExporting },
}),
useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
}))
// Mock plugin dependencies hook
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
usePluginDependencies: () => ({
handleCheckPluginDependencies: mockHandleCheckPluginDependencies,
}),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createPipelineTemplate = (overrides: Partial<PipelineTemplate> = {}): PipelineTemplate => ({
id: 'pipeline-1',
name: 'Test Pipeline',
description: 'Test pipeline description',
icon: {
icon_type: 'emoji',
icon: '📊',
icon_background: '#FFF4ED',
icon_url: '',
},
chunk_structure: ChunkingMode.text,
position: 1,
...overrides,
})
// ============================================================================
// TemplateCard Component Tests
// ============================================================================
describe('TemplateCard', () => {
const defaultProps = {
pipeline: createPipelineTemplate(),
showMoreOperations: true,
type: 'customized' as const,
}
beforeEach(() => {
vi.clearAllMocks()
mockIsExporting = false
_capturedOnConfirm = undefined
_capturedOnCancel = undefined
_capturedHandleDelete = undefined
_capturedHandleExportDSL = undefined
_capturedOpenEditModal = undefined
mockHandleCheckPluginDependencies.mockResolvedValue(undefined)
mockGetPipelineTemplateInfo.mockResolvedValue({
data: {
export_data: 'yaml_content_here',
},
})
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<TemplateCard {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should render pipeline name', () => {
render(<TemplateCard {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should render pipeline description', () => {
render(<TemplateCard {...defaultProps} />)
expect(screen.getByText('Test pipeline description')).toBeInTheDocument()
})
it('should render Content component', () => {
render(<TemplateCard {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
expect(screen.getByText('Test pipeline description')).toBeInTheDocument()
})
it('should render Actions component', () => {
render(<TemplateCard {...defaultProps} />)
expect(screen.getByTestId('actions')).toBeInTheDocument()
expect(screen.getByTestId('action-choose')).toBeInTheDocument()
expect(screen.getByTestId('action-details')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Use Template Flow Tests
// --------------------------------------------------------------------------
describe('Use Template Flow', () => {
it('should show error when template info fetch fails', async () => {
mockGetPipelineTemplateInfo.mockResolvedValue({ data: null })
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
})
it('should create dataset when template is applied', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(mockCreateDataset).toHaveBeenCalled()
})
})
it('should navigate to pipeline page on successful creation', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/datasets/new-dataset-123/pipeline')
})
})
it('should invalidate dataset list on successful creation', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(mockInvalidDatasetList).toHaveBeenCalled()
})
})
it('should show success toast on successful creation', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
})
})
})
it('should show error toast on creation failure', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onError(new Error('Creation failed'))
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
})
})
// --------------------------------------------------------------------------
// Details Modal Tests
// --------------------------------------------------------------------------
describe('Details Modal', () => {
it('should open details modal when details button is clicked', async () => {
render(<TemplateCard {...defaultProps} />)
const detailsButton = screen.getByTestId('action-details')
fireEvent.click(detailsButton)
await waitFor(() => {
expect(screen.getByTestId('details-component')).toBeInTheDocument()
})
})
it('should close details modal when close is triggered', async () => {
render(<TemplateCard {...defaultProps} />)
const detailsButton = screen.getByTestId('action-details')
fireEvent.click(detailsButton)
await waitFor(() => {
expect(screen.getByTestId('details-component')).toBeInTheDocument()
})
const closeButton = screen.getByTestId('details-close')
fireEvent.click(closeButton)
await waitFor(() => {
expect(screen.queryByTestId('details-component')).not.toBeInTheDocument()
})
})
it('should trigger use template from details modal', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const detailsButton = screen.getByTestId('action-details')
fireEvent.click(detailsButton)
await waitFor(() => {
expect(screen.getByTestId('details-component')).toBeInTheDocument()
})
const applyButton = screen.getByTestId('details-apply')
fireEvent.click(applyButton)
await waitFor(() => {
expect(mockCreateDataset).toHaveBeenCalled()
})
})
})
// --------------------------------------------------------------------------
// Pipeline ID Branch Tests
// --------------------------------------------------------------------------
describe('Pipeline ID Branch', () => {
it('should call handleCheckPluginDependencies when pipeline_id is present', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: 'pipe-123' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('pipe-123', true)
})
})
it('should not call handleCheckPluginDependencies when pipeline_id is falsy', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: '' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/datasets/new-dataset-123/pipeline')
})
expect(mockHandleCheckPluginDependencies).not.toHaveBeenCalled()
})
it('should not call handleCheckPluginDependencies when pipeline_id is null', async () => {
mockCreateDataset.mockImplementation((_data, callbacks) => {
callbacks.onSuccess({ dataset_id: 'new-dataset-123', pipeline_id: null })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const chooseButton = screen.getByTestId('action-choose')
fireEvent.click(chooseButton)
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/datasets/new-dataset-123/pipeline')
})
expect(mockHandleCheckPluginDependencies).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Export DSL Tests (Branch Coverage)
// --------------------------------------------------------------------------
describe('Export DSL', () => {
it('should not export when already exporting', async () => {
mockIsExporting = true
render(<TemplateCard {...defaultProps} />)
const exportButton = screen.getByTestId('action-export')
fireEvent.click(exportButton)
// Export should not be called due to isExporting check
expect(mockExportPipelineDSL).not.toHaveBeenCalled()
})
it('should call exportPipelineDSL on export action', async () => {
mockExportPipelineDSL.mockImplementation((_id, callbacks) => {
callbacks.onSuccess({ data: 'yaml_content' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const exportButton = screen.getByTestId('action-export')
fireEvent.click(exportButton)
await waitFor(() => {
expect(mockExportPipelineDSL).toHaveBeenCalledWith('pipeline-1', expect.any(Object))
})
})
it('should show success toast on export success', async () => {
mockExportPipelineDSL.mockImplementation((_id, callbacks) => {
callbacks.onSuccess({ data: 'yaml_content' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const exportButton = screen.getByTestId('action-export')
fireEvent.click(exportButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
})
})
})
it('should show error toast on export failure', async () => {
mockExportPipelineDSL.mockImplementation((_id, callbacks) => {
callbacks.onError(new Error('Export failed'))
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const exportButton = screen.getByTestId('action-export')
fireEvent.click(exportButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
})
})
})
it('should call downloadFile on successful export', async () => {
const { downloadFile } = await import('@/utils/format')
mockExportPipelineDSL.mockImplementation((_id, callbacks) => {
callbacks.onSuccess({ data: 'yaml_content' })
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const exportButton = screen.getByTestId('action-export')
fireEvent.click(exportButton)
await waitFor(() => {
expect(downloadFile).toHaveBeenCalledWith(expect.objectContaining({
fileName: 'Test Pipeline.pipeline',
}))
})
})
})
// --------------------------------------------------------------------------
// Delete Flow Tests
// --------------------------------------------------------------------------
describe('Delete Flow', () => {
it('should show confirm dialog when delete is clicked', async () => {
render(<TemplateCard {...defaultProps} />)
const deleteButton = screen.getByTestId('action-delete')
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
})
it('should close confirm dialog when cancel is clicked (onCancelDelete)', async () => {
render(<TemplateCard {...defaultProps} />)
const deleteButton = screen.getByTestId('action-delete')
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
const cancelButton = screen.getByTestId('confirm-cancel')
fireEvent.click(cancelButton)
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
it('should call deletePipeline when confirm is clicked (onConfirmDelete)', async () => {
mockDeletePipeline.mockImplementation((_id, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const deleteButton = screen.getByTestId('action-delete')
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
const confirmButton = screen.getByTestId('confirm-submit')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(mockDeletePipeline).toHaveBeenCalledWith('pipeline-1', expect.any(Object))
})
})
it('should invalidate template list on successful delete', async () => {
mockDeletePipeline.mockImplementation((_id, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const deleteButton = screen.getByTestId('action-delete')
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
const confirmButton = screen.getByTestId('confirm-submit')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(mockInvalidCustomizedTemplateList).toHaveBeenCalled()
})
})
it('should close confirm dialog after successful delete', async () => {
mockDeletePipeline.mockImplementation((_id, callbacks) => {
callbacks.onSuccess()
return Promise.resolve()
})
render(<TemplateCard {...defaultProps} />)
const deleteButton = screen.getByTestId('action-delete')
fireEvent.click(deleteButton)
await waitFor(() => {
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
const confirmButton = screen.getByTestId('confirm-submit')
fireEvent.click(confirmButton)
await waitFor(() => {
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Edit Modal Tests
// --------------------------------------------------------------------------
describe('Edit Modal', () => {
it('should open edit modal when edit button is clicked', async () => {
render(<TemplateCard {...defaultProps} />)
const editButton = screen.getByTestId('action-edit')
fireEvent.click(editButton)
await waitFor(() => {
expect(screen.getByTestId('edit-pipeline-info')).toBeInTheDocument()
})
})
it('should close edit modal when close is triggered', async () => {
render(<TemplateCard {...defaultProps} />)
const editButton = screen.getByTestId('action-edit')
fireEvent.click(editButton)
await waitFor(() => {
expect(screen.getByTestId('edit-pipeline-info')).toBeInTheDocument()
})
const closeButton = screen.getByTestId('edit-close')
fireEvent.click(closeButton)
await waitFor(() => {
expect(screen.queryByTestId('edit-pipeline-info')).not.toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Props Tests
// --------------------------------------------------------------------------
describe('Props', () => {
it('should show more operations when showMoreOperations is true', () => {
render(<TemplateCard {...defaultProps} showMoreOperations={true} />)
expect(screen.getByTestId('action-edit')).toBeInTheDocument()
expect(screen.getByTestId('action-export')).toBeInTheDocument()
expect(screen.getByTestId('action-delete')).toBeInTheDocument()
})
it('should hide more operations when showMoreOperations is false', () => {
render(<TemplateCard {...defaultProps} showMoreOperations={false} />)
expect(screen.queryByTestId('action-edit')).not.toBeInTheDocument()
expect(screen.queryByTestId('action-export')).not.toBeInTheDocument()
expect(screen.queryByTestId('action-delete')).not.toBeInTheDocument()
})
it('should default showMoreOperations to true', () => {
const { pipeline, type } = defaultProps
render(<TemplateCard pipeline={pipeline} type={type} />)
expect(screen.getByTestId('action-edit')).toBeInTheDocument()
})
it('should handle built-in type', () => {
render(<TemplateCard {...defaultProps} type="built-in" />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
it('should handle customized type', () => {
render(<TemplateCard {...defaultProps} type="customized" />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have proper card styling', () => {
const { container } = render(<TemplateCard {...defaultProps} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('group', 'relative', 'flex', 'cursor-pointer', 'flex-col', 'rounded-xl')
})
it('should have fixed height', () => {
const { container } = render(<TemplateCard {...defaultProps} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('h-[132px]')
})
it('should have shadow and border', () => {
const { container } = render(<TemplateCard {...defaultProps} />)
const card = container.firstChild as HTMLElement
expect(card).toHaveClass('border-[0.5px]', 'shadow-xs')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<TemplateCard {...defaultProps} />)
rerender(<TemplateCard {...defaultProps} />)
expect(screen.getByText('Test Pipeline')).toBeInTheDocument()
})
})
})

View File

@ -1,144 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Operations from './operations'
// ============================================================================
// Operations Component Tests
// ============================================================================
describe('Operations', () => {
const defaultProps = {
openEditModal: vi.fn(),
onDelete: vi.fn(),
onExport: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Operations {...defaultProps} />)
expect(screen.getByText(/editInfo/i)).toBeInTheDocument()
})
it('should render all operation buttons', () => {
render(<Operations {...defaultProps} />)
expect(screen.getByText(/editInfo/i)).toBeInTheDocument()
expect(screen.getByText(/exportPipeline/i)).toBeInTheDocument()
expect(screen.getByText(/delete/i)).toBeInTheDocument()
})
it('should have proper container styling', () => {
const { container } = render(<Operations {...defaultProps} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative', 'flex', 'flex-col', 'rounded-xl')
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call openEditModal when edit is clicked', () => {
render(<Operations {...defaultProps} />)
const editButton = screen.getByText(/editInfo/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(editButton!)
expect(defaultProps.openEditModal).toHaveBeenCalledTimes(1)
})
it('should call onExport when export is clicked', () => {
render(<Operations {...defaultProps} />)
const exportButton = screen.getByText(/exportPipeline/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(exportButton!)
expect(defaultProps.onExport).toHaveBeenCalledTimes(1)
})
it('should call onDelete when delete is clicked', () => {
render(<Operations {...defaultProps} />)
const deleteButton = screen.getByText(/delete/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(deleteButton!)
expect(defaultProps.onDelete).toHaveBeenCalledTimes(1)
})
it('should stop propagation on edit click', () => {
const stopPropagation = vi.fn()
const preventDefault = vi.fn()
render(<Operations {...defaultProps} />)
const editButton = screen.getByText(/editInfo/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(editButton!, {
stopPropagation,
preventDefault,
})
expect(defaultProps.openEditModal).toHaveBeenCalled()
})
it('should stop propagation on export click', () => {
render(<Operations {...defaultProps} />)
const exportButton = screen.getByText(/exportPipeline/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(exportButton!)
expect(defaultProps.onExport).toHaveBeenCalled()
})
it('should stop propagation on delete click', () => {
render(<Operations {...defaultProps} />)
const deleteButton = screen.getByText(/delete/i).closest('div[class*="cursor-pointer"]')
fireEvent.click(deleteButton!)
expect(defaultProps.onDelete).toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Layout Tests
// --------------------------------------------------------------------------
describe('Layout', () => {
it('should have divider between sections', () => {
const { container } = render(<Operations {...defaultProps} />)
const divider = container.querySelector('[class*="bg-divider"]')
expect(divider).toBeInTheDocument()
})
it('should have hover states on buttons', () => {
render(<Operations {...defaultProps} />)
const editButton = screen.getByText(/editInfo/i).closest('div[class*="cursor-pointer"]')
expect(editButton).toHaveClass('hover:bg-state-base-hover')
})
it('should have destructive hover state on delete button', () => {
render(<Operations {...defaultProps} />)
const deleteButton = screen.getByText(/delete/i).closest('div[class*="cursor-pointer"]')
expect(deleteButton).toHaveClass('hover:bg-state-destructive-hover')
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Operations {...defaultProps} />)
rerender(<Operations {...defaultProps} />)
expect(screen.getByText(/editInfo/i)).toBeInTheDocument()
})
})
})

View File

@ -1,407 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Component Imports (after mocks)
// ============================================================================
import UrlInput from './url-input'
// ============================================================================
// Mock Setup
// ============================================================================
// Mock useDocLink hook
vi.mock('@/context/i18n', () => ({
useDocLink: vi.fn(() => () => 'https://docs.example.com'),
}))
// ============================================================================
// UrlInput Component Tests
// ============================================================================
describe('UrlInput', () => {
const mockOnRun = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render input with placeholder from docLink', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
expect(input).toHaveAttribute('placeholder', 'https://docs.example.com')
})
it('should render button with run text when not running', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).toHaveTextContent(/run/i)
})
it('should render button without run text when running', () => {
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const button = screen.getByRole('button')
// Button should not have "run" text when running (shows loading state instead)
expect(button).not.toHaveTextContent(/run/i)
})
it('should show loading state on button when running', () => {
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
// Button should show loading text when running
const button = screen.getByRole('button')
expect(button).toHaveTextContent(/loading/i)
})
it('should not show loading state on button when not running', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).not.toHaveTextContent(/loading/i)
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should update input value when user types', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://example.com')
expect(input).toHaveValue('https://example.com')
})
it('should call onRun with url when button is clicked and not running', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://example.com')
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith('https://example.com')
expect(mockOnRun).toHaveBeenCalledTimes(1)
})
it('should NOT call onRun when button is clicked and isRunning is true', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
// Use fireEvent since userEvent might not work well with disabled-like states
fireEvent.change(input, { target: { value: 'https://example.com' } })
const button = screen.getByRole('button')
await user.click(button)
// onRun should NOT be called because isRunning is true
expect(mockOnRun).not.toHaveBeenCalled()
})
it('should call onRun with empty string when button clicked with empty input', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith('')
expect(mockOnRun).toHaveBeenCalledTimes(1)
})
it('should handle multiple button clicks when not running', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://test.com')
const button = screen.getByRole('button')
await user.click(button)
await user.click(button)
expect(mockOnRun).toHaveBeenCalledTimes(2)
expect(mockOnRun).toHaveBeenCalledWith('https://test.com')
})
})
// --------------------------------------------------------------------------
// Props Variations Tests
// --------------------------------------------------------------------------
describe('Props Variations', () => {
it('should update button state when isRunning changes from false to true', () => {
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
expect(button).toHaveTextContent(/run/i)
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
// When running, button shows loading state instead of "run" text
expect(button).not.toHaveTextContent(/run/i)
})
it('should update button state when isRunning changes from true to false', () => {
const { rerender } = render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const button = screen.getByRole('button')
// When running, button shows loading state instead of "run" text
expect(button).not.toHaveTextContent(/run/i)
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(button).toHaveTextContent(/run/i)
})
it('should preserve input value when isRunning prop changes', async () => {
const user = userEvent.setup()
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'https://preserved.com')
expect(input).toHaveValue('https://preserved.com')
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
expect(input).toHaveValue('https://preserved.com')
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle special characters in url', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
const specialUrl = 'https://example.com/path?query=test&param=value#anchor'
await user.type(input, specialUrl)
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith(specialUrl)
})
it('should handle unicode characters in url', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
const unicodeUrl = 'https://example.com/路径/文件'
await user.type(input, unicodeUrl)
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith(unicodeUrl)
})
it('should handle very long url', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
const longUrl = `https://example.com/${'a'.repeat(500)}`
// Use fireEvent for long text to avoid timeout
fireEvent.change(input, { target: { value: longUrl } })
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledWith(longUrl)
})
it('should handle whitespace in url', async () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: ' https://example.com ' } })
const button = screen.getByRole('button')
fireEvent.click(button)
expect(mockOnRun).toHaveBeenCalledWith(' https://example.com ')
})
it('should handle rapid input changes', async () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'a' } })
fireEvent.change(input, { target: { value: 'ab' } })
fireEvent.change(input, { target: { value: 'abc' } })
fireEvent.change(input, { target: { value: 'https://final.com' } })
expect(input).toHaveValue('https://final.com')
const button = screen.getByRole('button')
fireEvent.click(button)
expect(mockOnRun).toHaveBeenCalledWith('https://final.com')
})
})
// --------------------------------------------------------------------------
// handleOnRun Branch Coverage Tests
// --------------------------------------------------------------------------
describe('handleOnRun Branch Coverage', () => {
it('should return early when isRunning is true (branch: isRunning = true)', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'https://test.com' } })
const button = screen.getByRole('button')
await user.click(button)
// The early return should prevent onRun from being called
expect(mockOnRun).not.toHaveBeenCalled()
})
it('should call onRun when isRunning is false (branch: isRunning = false)', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'https://test.com' } })
const button = screen.getByRole('button')
await user.click(button)
// onRun should be called when isRunning is false
expect(mockOnRun).toHaveBeenCalledWith('https://test.com')
})
})
// --------------------------------------------------------------------------
// Button Text Branch Coverage Tests
// --------------------------------------------------------------------------
describe('Button Text Branch Coverage', () => {
it('should display run text when isRunning is false (branch: !isRunning = true)', () => {
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
// When !isRunning is true, button shows the translated "run" text
expect(button).toHaveTextContent(/run/i)
})
it('should not display run text when isRunning is true (branch: !isRunning = false)', () => {
render(<UrlInput isRunning={true} onRun={mockOnRun} />)
const button = screen.getByRole('button')
// When !isRunning is false, button shows empty string '' (loading state shows spinner)
expect(button).not.toHaveTextContent(/run/i)
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should use useCallback for handleUrlChange', async () => {
const user = userEvent.setup()
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
const input = screen.getByRole('textbox')
await user.type(input, 'test')
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
// Input should maintain value after rerender
expect(input).toHaveValue('test')
})
it('should use useCallback for handleOnRun', async () => {
const user = userEvent.setup()
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
const button = screen.getByRole('button')
await user.click(button)
expect(mockOnRun).toHaveBeenCalledTimes(1)
})
})
// --------------------------------------------------------------------------
// Integration Tests
// --------------------------------------------------------------------------
describe('Integration', () => {
it('should complete full workflow: type url -> click run -> verify callback', async () => {
const user = userEvent.setup()
render(<UrlInput isRunning={false} onRun={mockOnRun} />)
// Type URL
const input = screen.getByRole('textbox')
await user.type(input, 'https://mywebsite.com')
// Click run
const button = screen.getByRole('button')
await user.click(button)
// Verify callback
expect(mockOnRun).toHaveBeenCalledWith('https://mywebsite.com')
})
it('should show correct states during running workflow', () => {
const { rerender } = render(<UrlInput isRunning={false} onRun={mockOnRun} />)
// Initial state: not running
expect(screen.getByRole('button')).toHaveTextContent(/run/i)
// Simulate running state
rerender(<UrlInput isRunning={true} onRun={mockOnRun} />)
expect(screen.getByRole('button')).not.toHaveTextContent(/run/i)
// Simulate finished state
rerender(<UrlInput isRunning={false} onRun={mockOnRun} />)
expect(screen.getByRole('button')).toHaveTextContent(/run/i)
})
})
})

View File

@ -1,701 +0,0 @@
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Component Import (after mocks)
// ============================================================================
import FireCrawl from './index'
// ============================================================================
// Mock Setup - Only mock API calls and context
// ============================================================================
// Mock API service
const mockCreateFirecrawlTask = vi.fn()
const mockCheckFirecrawlTaskStatus = vi.fn()
vi.mock('@/service/datasets', () => ({
createFirecrawlTask: (...args: unknown[]) => mockCreateFirecrawlTask(...args),
checkFirecrawlTaskStatus: (...args: unknown[]) => mockCheckFirecrawlTaskStatus(...args),
}))
// Mock modal context
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: vi.fn(() => mockSetShowAccountSettingModal),
}))
// Mock sleep utility to speed up tests
vi.mock('@/utils', () => ({
sleep: vi.fn(() => Promise.resolve()),
}))
// Mock useDocLink hook for UrlInput placeholder
vi.mock('@/context/i18n', () => ({
useDocLink: vi.fn(() => () => 'https://docs.example.com'),
}))
// ============================================================================
// Test Data Factory
// ============================================================================
const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
excludes: '',
includes: '',
only_main_content: false,
use_sitemap: false,
...overrides,
})
const createMockCrawlResultItem = (overrides: Partial<CrawlResultItem> = {}): CrawlResultItem => ({
title: 'Test Page',
markdown: '# Test Content',
description: 'Test page description',
source_url: 'https://example.com/page',
...overrides,
})
// ============================================================================
// FireCrawl Component Tests
// ============================================================================
describe('FireCrawl', () => {
const mockOnPreview = vi.fn()
const mockOnCheckedCrawlResultChange = vi.fn()
const mockOnJobIdChange = vi.fn()
const mockOnCrawlOptionsChange = vi.fn()
const defaultProps = {
onPreview: mockOnPreview,
checkedCrawlResult: [] as CrawlResultItem[],
onCheckedCrawlResultChange: mockOnCheckedCrawlResultChange,
onJobIdChange: mockOnJobIdChange,
crawlOptions: createMockCrawlOptions(),
onCrawlOptionsChange: mockOnCrawlOptionsChange,
}
beforeEach(() => {
vi.clearAllMocks()
mockCreateFirecrawlTask.mockReset()
mockCheckFirecrawlTaskStatus.mockReset()
})
// Helper to get URL input (first textbox with specific placeholder)
const getUrlInput = () => {
return screen.getByPlaceholderText('https://docs.example.com')
}
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<FireCrawl {...defaultProps} />)
expect(screen.getByText(/firecrawlTitle/i)).toBeInTheDocument()
})
it('should render Header component with correct props', () => {
render(<FireCrawl {...defaultProps} />)
expect(screen.getByText(/firecrawlTitle/i)).toBeInTheDocument()
expect(screen.getByText(/configureFirecrawl/i)).toBeInTheDocument()
expect(screen.getByText(/firecrawlDoc/i)).toBeInTheDocument()
})
it('should render UrlInput component', () => {
render(<FireCrawl {...defaultProps} />)
expect(getUrlInput()).toBeInTheDocument()
expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument()
})
it('should render Options component', () => {
render(<FireCrawl {...defaultProps} />)
expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument()
expect(screen.getByText(/limit/i)).toBeInTheDocument()
})
it('should not render crawling or result components initially', () => {
render(<FireCrawl {...defaultProps} />)
// Crawling and result components should not be visible in init state
expect(screen.queryByText(/crawling/i)).not.toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Configuration Button Tests
// --------------------------------------------------------------------------
describe('Configuration Button', () => {
it('should call setShowAccountSettingModal when configure button is clicked', async () => {
const user = userEvent.setup()
render(<FireCrawl {...defaultProps} />)
const configButton = screen.getByText(/configureFirecrawl/i)
await user.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'data-source',
})
})
})
// --------------------------------------------------------------------------
// URL Validation Tests
// --------------------------------------------------------------------------
describe('URL Validation', () => {
it('should show error toast when URL is empty', async () => {
const user = userEvent.setup()
render(<FireCrawl {...defaultProps} />)
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
// Should not call API when validation fails
expect(mockCreateFirecrawlTask).not.toHaveBeenCalled()
})
it('should show error toast when URL does not start with http:// or https://', async () => {
const user = userEvent.setup()
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'invalid-url.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
// Should not call API when validation fails
expect(mockCreateFirecrawlTask).not.toHaveBeenCalled()
})
it('should show error toast when limit is empty', async () => {
const user = userEvent.setup()
const propsWithEmptyLimit = {
...defaultProps,
crawlOptions: createMockCrawlOptions({ limit: '' as unknown as number }),
}
render(<FireCrawl {...propsWithEmptyLimit} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
// Should not call API when validation fails
expect(mockCreateFirecrawlTask).not.toHaveBeenCalled()
})
it('should show error toast when limit is null', async () => {
const user = userEvent.setup()
const propsWithNullLimit = {
...defaultProps,
crawlOptions: createMockCrawlOptions({ limit: null as unknown as number }),
}
render(<FireCrawl {...propsWithNullLimit} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
expect(mockCreateFirecrawlTask).not.toHaveBeenCalled()
})
it('should accept valid http:// URL', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job-id' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: [],
total: 0,
current: 0,
time_consuming: 1,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'http://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockCreateFirecrawlTask).toHaveBeenCalled()
})
})
it('should accept valid https:// URL', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job-id' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: [],
total: 0,
current: 0,
time_consuming: 1,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockCreateFirecrawlTask).toHaveBeenCalled()
})
})
})
// --------------------------------------------------------------------------
// Crawl Execution Tests
// --------------------------------------------------------------------------
describe('Crawl Execution', () => {
it('should call createFirecrawlTask with correct parameters', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job-id' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: [],
total: 0,
current: 0,
time_consuming: 1,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockCreateFirecrawlTask).toHaveBeenCalledWith({
url: 'https://example.com',
options: expect.objectContaining({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
}),
})
})
})
it('should call onJobIdChange with job_id from API response', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'my-job-123' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: [],
total: 0,
current: 0,
time_consuming: 1,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockOnJobIdChange).toHaveBeenCalledWith('my-job-123')
})
})
it('should remove empty max_depth from crawlOptions before sending to API', async () => {
const user = userEvent.setup()
const propsWithEmptyMaxDepth = {
...defaultProps,
crawlOptions: createMockCrawlOptions({ max_depth: '' as unknown as number }),
}
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job-id' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: [],
total: 0,
current: 0,
time_consuming: 1,
})
render(<FireCrawl {...propsWithEmptyMaxDepth} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockCreateFirecrawlTask).toHaveBeenCalledWith({
url: 'https://example.com',
options: expect.not.objectContaining({
max_depth: '',
}),
})
})
})
it('should show loading state while running', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockImplementation(() => new Promise(() => {})) // Never resolves
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
// Button should show loading state (no longer show "run" text)
await waitFor(() => {
expect(runButton).not.toHaveTextContent(/run/i)
})
})
})
// --------------------------------------------------------------------------
// Crawl Status Polling Tests
// --------------------------------------------------------------------------
describe('Crawl Status Polling', () => {
it('should handle completed status', async () => {
const user = userEvent.setup()
const mockResults = [createMockCrawlResultItem()]
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: mockResults,
total: 1,
current: 1,
time_consuming: 2.5,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith(mockResults)
})
})
it('should handle error status from API', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'error',
message: 'Crawl failed',
data: [],
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(screen.getByText(/exceptionErrorTitle/i)).toBeInTheDocument()
})
})
it('should handle missing status as error', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: undefined,
message: 'No status',
data: [],
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(screen.getByText(/exceptionErrorTitle/i)).toBeInTheDocument()
})
})
it('should poll again when status is pending', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus
.mockResolvedValueOnce({
status: 'pending',
data: [{ title: 'Page 1', markdown: 'content', source_url: 'https://example.com/1' }],
total: 5,
current: 1,
})
.mockResolvedValueOnce({
status: 'completed',
data: [{ title: 'Page 1', markdown: 'content', source_url: 'https://example.com/1' }],
total: 5,
current: 5,
time_consuming: 3,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockCheckFirecrawlTaskStatus).toHaveBeenCalledTimes(2)
})
})
it('should update progress during crawling', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus
.mockResolvedValueOnce({
status: 'pending',
data: [{ title: 'Page 1', markdown: 'content', source_url: 'https://example.com/1' }],
total: 10,
current: 3,
})
.mockResolvedValueOnce({
status: 'completed',
data: [{ title: 'Page 1', markdown: 'content', source_url: 'https://example.com/1' }],
total: 10,
current: 10,
time_consuming: 5,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalled()
})
})
})
// --------------------------------------------------------------------------
// Error Handling Tests
// --------------------------------------------------------------------------
describe('Error Handling', () => {
it('should handle API exception during task creation', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockRejectedValueOnce(new Error('Network error'))
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(screen.getByText(/exceptionErrorTitle/i)).toBeInTheDocument()
})
})
it('should handle API exception during status check', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockRejectedValueOnce({
json: () => Promise.resolve({ message: 'Status check failed' }),
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(screen.getByText(/exceptionErrorTitle/i)).toBeInTheDocument()
})
})
it('should display error message from API', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'error',
message: 'Custom error message',
data: [],
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(screen.getByText('Custom error message')).toBeInTheDocument()
})
})
it('should display unknown error when no error message provided', async () => {
const user = userEvent.setup()
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'error',
message: undefined,
data: [],
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(screen.getByText(/unknownError/i)).toBeInTheDocument()
})
})
})
// --------------------------------------------------------------------------
// Options Change Tests
// --------------------------------------------------------------------------
describe('Options Change', () => {
it('should call onCrawlOptionsChange when options change', () => {
render(<FireCrawl {...defaultProps} />)
// Find and change limit input
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '20' } })
expect(mockOnCrawlOptionsChange).toHaveBeenCalledWith(
expect.objectContaining({ limit: 20 }),
)
})
it('should call onCrawlOptionsChange when checkbox changes', () => {
const { container } = render(<FireCrawl {...defaultProps} />)
// Use data-testid to find checkboxes since they are custom div elements
const checkboxes = container.querySelectorAll('[data-testid^="checkbox-"]')
fireEvent.click(checkboxes[0]) // crawl_sub_pages
expect(mockOnCrawlOptionsChange).toHaveBeenCalledWith(
expect.objectContaining({ crawl_sub_pages: false }),
)
})
})
// --------------------------------------------------------------------------
// Crawled Result Display Tests
// --------------------------------------------------------------------------
describe('Crawled Result Display', () => {
it('should display CrawledResult when crawl is finished successfully', async () => {
const user = userEvent.setup()
const mockResults = [
createMockCrawlResultItem({ title: 'Result Page 1' }),
createMockCrawlResultItem({ title: 'Result Page 2' }),
]
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: mockResults,
total: 2,
current: 2,
time_consuming: 1.5,
})
render(<FireCrawl {...defaultProps} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
expect(screen.getByText('Result Page 1')).toBeInTheDocument()
expect(screen.getByText('Result Page 2')).toBeInTheDocument()
})
})
it('should limit total to crawlOptions.limit', async () => {
const user = userEvent.setup()
const propsWithLimit5 = {
...defaultProps,
crawlOptions: createMockCrawlOptions({ limit: 5 }),
}
mockCreateFirecrawlTask.mockResolvedValueOnce({ job_id: 'test-job' })
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: [],
total: 100, // API returns more than limit
current: 5,
time_consuming: 1,
})
render(<FireCrawl {...propsWithLimit5} />)
const input = getUrlInput()
await user.type(input, 'https://example.com')
const runButton = screen.getByRole('button', { name: /run/i })
await user.click(runButton)
await waitFor(() => {
// Total should be capped to limit (5)
expect(mockCheckFirecrawlTaskStatus).toHaveBeenCalled()
})
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<FireCrawl {...defaultProps} />)
rerender(<FireCrawl {...defaultProps} />)
expect(screen.getByText(/firecrawlTitle/i)).toBeInTheDocument()
})
})
})

View File

@ -1,405 +0,0 @@
import type { CrawlOptions } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Options from './options'
// ============================================================================
// Test Data Factory
// ============================================================================
const createMockCrawlOptions = (overrides: Partial<CrawlOptions> = {}): CrawlOptions => ({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
excludes: '',
includes: '',
only_main_content: false,
use_sitemap: false,
...overrides,
})
// ============================================================================
// Options Component Tests
// ============================================================================
describe('Options', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// Helper to get checkboxes by test id pattern
const getCheckboxes = (container: HTMLElement) => {
return container.querySelectorAll('[data-testid^="checkbox-"]')
}
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
// Check that key elements are rendered
expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument()
expect(screen.getByText(/limit/i)).toBeInTheDocument()
expect(screen.getByText(/maxDepth/i)).toBeInTheDocument()
})
it('should render all form fields', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
// Checkboxes
expect(screen.getByText(/crawlSubPage/i)).toBeInTheDocument()
expect(screen.getByText(/extractOnlyMainContent/i)).toBeInTheDocument()
// Text/Number fields
expect(screen.getByText(/limit/i)).toBeInTheDocument()
expect(screen.getByText(/maxDepth/i)).toBeInTheDocument()
expect(screen.getByText(/excludePaths/i)).toBeInTheDocument()
expect(screen.getByText(/includeOnlyPaths/i)).toBeInTheDocument()
})
it('should render with custom className', () => {
const payload = createMockCrawlOptions()
const { container } = render(
<Options payload={payload} onChange={mockOnChange} className="custom-class" />,
)
const rootElement = container.firstChild as HTMLElement
expect(rootElement).toHaveClass('custom-class')
})
it('should render limit field with required indicator', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
// Limit field should have required indicator (*)
const requiredIndicator = screen.getByText('*')
expect(requiredIndicator).toBeInTheDocument()
})
it('should render placeholder for excludes field', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
const excludesInput = screen.getByPlaceholderText('blog/*, /about/*')
expect(excludesInput).toBeInTheDocument()
})
it('should render placeholder for includes field', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
const includesInput = screen.getByPlaceholderText('articles/*')
expect(includesInput).toBeInTheDocument()
})
it('should render two checkboxes', () => {
const payload = createMockCrawlOptions()
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
expect(checkboxes.length).toBe(2)
})
})
// --------------------------------------------------------------------------
// Props Display Tests
// --------------------------------------------------------------------------
describe('Props Display', () => {
it('should display crawl_sub_pages checkbox with check icon when true', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
// First checkbox should have check icon when checked
expect(checkboxes[0].querySelector('svg')).toBeInTheDocument()
})
it('should display crawl_sub_pages checkbox without check icon when false', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
// First checkbox should not have check icon when unchecked
expect(checkboxes[0].querySelector('svg')).not.toBeInTheDocument()
})
it('should display only_main_content checkbox with check icon when true', () => {
const payload = createMockCrawlOptions({ only_main_content: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
// Second checkbox should have check icon when checked
expect(checkboxes[1].querySelector('svg')).toBeInTheDocument()
})
it('should display only_main_content checkbox without check icon when false', () => {
const payload = createMockCrawlOptions({ only_main_content: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
// Second checkbox should not have check icon when unchecked
expect(checkboxes[1].querySelector('svg')).not.toBeInTheDocument()
})
it('should display limit value in input', () => {
const payload = createMockCrawlOptions({ limit: 25 })
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('25')
expect(limitInput).toBeInTheDocument()
})
it('should display max_depth value in input', () => {
const payload = createMockCrawlOptions({ max_depth: 5 })
render(<Options payload={payload} onChange={mockOnChange} />)
const maxDepthInput = screen.getByDisplayValue('5')
expect(maxDepthInput).toBeInTheDocument()
})
it('should display excludes value in input', () => {
const payload = createMockCrawlOptions({ excludes: 'test/*' })
render(<Options payload={payload} onChange={mockOnChange} />)
const excludesInput = screen.getByDisplayValue('test/*')
expect(excludesInput).toBeInTheDocument()
})
it('should display includes value in input', () => {
const payload = createMockCrawlOptions({ includes: 'docs/*' })
render(<Options payload={payload} onChange={mockOnChange} />)
const includesInput = screen.getByDisplayValue('docs/*')
expect(includesInput).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call onChange with updated crawl_sub_pages when checkbox is clicked', () => {
const payload = createMockCrawlOptions({ crawl_sub_pages: true })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
fireEvent.click(checkboxes[0])
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
crawl_sub_pages: false,
})
})
it('should call onChange with updated only_main_content when checkbox is clicked', () => {
const payload = createMockCrawlOptions({ only_main_content: false })
const { container } = render(<Options payload={payload} onChange={mockOnChange} />)
const checkboxes = getCheckboxes(container)
fireEvent.click(checkboxes[1])
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
only_main_content: true,
})
})
it('should call onChange with updated limit when input changes', () => {
const payload = createMockCrawlOptions({ limit: 10 })
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '50' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
limit: 50,
})
})
it('should call onChange with updated max_depth when input changes', () => {
const payload = createMockCrawlOptions({ max_depth: 2 })
render(<Options payload={payload} onChange={mockOnChange} />)
const maxDepthInput = screen.getByDisplayValue('2')
fireEvent.change(maxDepthInput, { target: { value: '10' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
max_depth: 10,
})
})
it('should call onChange with updated excludes when input changes', () => {
const payload = createMockCrawlOptions({ excludes: '' })
render(<Options payload={payload} onChange={mockOnChange} />)
const excludesInput = screen.getByPlaceholderText('blog/*, /about/*')
fireEvent.change(excludesInput, { target: { value: 'admin/*' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
excludes: 'admin/*',
})
})
it('should call onChange with updated includes when input changes', () => {
const payload = createMockCrawlOptions({ includes: '' })
render(<Options payload={payload} onChange={mockOnChange} />)
const includesInput = screen.getByPlaceholderText('articles/*')
fireEvent.change(includesInput, { target: { value: 'public/*' } })
expect(mockOnChange).toHaveBeenCalledWith({
...payload,
includes: 'public/*',
})
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle empty string values', () => {
const payload = createMockCrawlOptions({
limit: '',
max_depth: '',
excludes: '',
includes: '',
} as unknown as CrawlOptions)
render(<Options payload={payload} onChange={mockOnChange} />)
// Component should render without crashing
expect(screen.getByText(/limit/i)).toBeInTheDocument()
})
it('should handle zero values', () => {
const payload = createMockCrawlOptions({
limit: 0,
max_depth: 0,
})
render(<Options payload={payload} onChange={mockOnChange} />)
// Zero values should be displayed
const zeroInputs = screen.getAllByDisplayValue('0')
expect(zeroInputs.length).toBeGreaterThanOrEqual(1)
})
it('should handle large numbers', () => {
const payload = createMockCrawlOptions({
limit: 9999,
max_depth: 100,
})
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('9999')).toBeInTheDocument()
expect(screen.getByDisplayValue('100')).toBeInTheDocument()
})
it('should handle special characters in text fields', () => {
const payload = createMockCrawlOptions({
excludes: 'path/*/file?query=1&param=2',
includes: 'docs/**/*.md',
})
render(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('path/*/file?query=1&param=2')).toBeInTheDocument()
expect(screen.getByDisplayValue('docs/**/*.md')).toBeInTheDocument()
})
it('should preserve other payload fields when updating one field', () => {
const payload = createMockCrawlOptions({
crawl_sub_pages: true,
limit: 10,
max_depth: 2,
excludes: 'test/*',
includes: 'docs/*',
only_main_content: true,
})
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '20' } })
expect(mockOnChange).toHaveBeenCalledWith({
crawl_sub_pages: true,
limit: 20,
max_depth: 2,
excludes: 'test/*',
includes: 'docs/*',
only_main_content: true,
use_sitemap: false,
})
})
})
// --------------------------------------------------------------------------
// handleChange Callback Tests
// --------------------------------------------------------------------------
describe('handleChange Callback', () => {
it('should create a new callback for each key', () => {
const payload = createMockCrawlOptions()
render(<Options payload={payload} onChange={mockOnChange} />)
// Change limit
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '15' } })
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({ limit: 15 }),
)
// Change max_depth
const maxDepthInput = screen.getByDisplayValue('2')
fireEvent.change(maxDepthInput, { target: { value: '5' } })
expect(mockOnChange).toHaveBeenCalledWith(
expect.objectContaining({ max_depth: 5 }),
)
})
it('should handle multiple rapid changes', () => {
const payload = createMockCrawlOptions({ limit: 10 })
render(<Options payload={payload} onChange={mockOnChange} />)
const limitInput = screen.getByDisplayValue('10')
fireEvent.change(limitInput, { target: { value: '11' } })
fireEvent.change(limitInput, { target: { value: '12' } })
fireEvent.change(limitInput, { target: { value: '13' } })
expect(mockOnChange).toHaveBeenCalledTimes(3)
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const payload = createMockCrawlOptions()
const { rerender } = render(<Options payload={payload} onChange={mockOnChange} />)
rerender(<Options payload={payload} onChange={mockOnChange} />)
expect(screen.getByText(/limit/i)).toBeInTheDocument()
})
it('should re-render when payload changes', () => {
const payload1 = createMockCrawlOptions({ limit: 10 })
const payload2 = createMockCrawlOptions({ limit: 20 })
const { rerender } = render(<Options payload={payload1} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('10')).toBeInTheDocument()
rerender(<Options payload={payload2} onChange={mockOnChange} />)
expect(screen.getByDisplayValue('20')).toBeInTheDocument()
})
})
})

View File

@ -70,11 +70,6 @@ const createDefaultProps = (overrides: Partial<Parameters<typeof JinaReader>[0]>
describe('JinaReader', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
describe('Rendering', () => {
@ -163,7 +158,7 @@ describe('JinaReader', () => {
describe('Props', () => {
it('should call onCrawlOptionsChange when options change', async () => {
// Arrange
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
const user = userEvent.setup()
const onCrawlOptionsChange = vi.fn()
const props = createDefaultProps({ onCrawlOptionsChange })
@ -242,10 +237,9 @@ describe('JinaReader', () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
let resolvePromise: () => void
const taskPromise = new Promise((resolve) => {
mockCreateTask.mockImplementation(() => new Promise((resolve) => {
resolvePromise = () => resolve({ data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' } })
})
mockCreateTask.mockImplementation(() => taskPromise)
}))
const props = createDefaultProps()
@ -263,11 +257,8 @@ describe('JinaReader', () => {
expect(screen.getByText(/totalPageScraped/i)).toBeInTheDocument()
})
// Cleanup - resolve the promise and wait for component to finish
// Cleanup - resolve the promise
resolvePromise!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should transition to finished state after successful crawl', async () => {
@ -403,11 +394,7 @@ describe('JinaReader', () => {
it('should update controlFoldOptions when step changes', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
let resolvePromise: () => void
const taskPromise = new Promise((resolve) => {
resolvePromise = () => resolve({ data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' } })
})
mockCreateTask.mockImplementation(() => taskPromise)
mockCreateTask.mockImplementation(() => new Promise((_resolve) => { /* pending */ }))
const props = createDefaultProps()
@ -425,12 +412,6 @@ describe('JinaReader', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped/i)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolvePromise!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
})
@ -1092,13 +1073,9 @@ describe('JinaReader', () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
let resolveCheckStatus: () => void
const checkStatusPromise = new Promise((resolve) => {
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-current-job' })
mockCheckStatus.mockImplementation(() => checkStatusPromise)
mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ }))
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 10 }),
@ -1114,25 +1091,15 @@ describe('JinaReader', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolveCheckStatus!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should show 0/0 progress when limit is zero string', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
let resolveCheckStatus: () => void
const checkStatusPromise = new Promise((resolve) => {
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'zero-total-job' })
mockCheckStatus.mockImplementation(() => checkStatusPromise)
mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ }))
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: '0' }),
@ -1148,12 +1115,6 @@ describe('JinaReader', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/0/)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolveCheckStatus!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should complete successfully when result data is undefined', async () => {
@ -1189,13 +1150,9 @@ describe('JinaReader', () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
let resolveCheckStatus: () => void
const checkStatusPromise = new Promise((resolve) => {
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'no-total-job' })
mockCheckStatus.mockImplementation(() => checkStatusPromise)
mockCheckStatus.mockImplementation(() => new Promise(() => { /* never resolves */ }))
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 15 }),
@ -1211,22 +1168,12 @@ describe('JinaReader', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/15/)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolveCheckStatus!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should fallback to limit when crawlResult has zero total', async () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
let resolveCheckStatus: () => void
const checkStatusPromise = new Promise((resolve) => {
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'both-zero-job' })
mockCheckStatus
@ -1236,7 +1183,7 @@ describe('JinaReader', () => {
total: 0,
data: [],
})
.mockImplementationOnce(() => checkStatusPromise)
.mockImplementationOnce(() => new Promise(() => { /* never resolves */ }))
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 5 }),
@ -1252,12 +1199,6 @@ describe('JinaReader', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped/)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolveCheckStatus!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should construct result item from direct data response', async () => {
@ -1496,13 +1437,9 @@ describe('JinaReader', () => {
// Arrange
const mockCreateTask = createJinaReaderTask as Mock
const mockCheckStatus = checkJinaReaderTaskStatus as Mock
let resolveCheckStatus: () => void
const checkStatusPromise = new Promise((resolve) => {
resolveCheckStatus = () => resolve({ status: 'completed', current: 0, total: 0, data: [] })
})
mockCreateTask.mockResolvedValueOnce({ job_id: 'progress-job' })
mockCheckStatus.mockImplementation(() => checkStatusPromise)
mockCheckStatus.mockImplementation(() => new Promise((_resolve) => { /* pending */ })) // Never resolves
const props = createDefaultProps({
crawlOptions: createDefaultCrawlOptions({ limit: 10 }),
@ -1518,12 +1455,6 @@ describe('JinaReader', () => {
await waitFor(() => {
expect(screen.getByText(/totalPageScraped.*0\/10/)).toBeInTheDocument()
})
// Cleanup - resolve the promise
resolveCheckStatus!()
await waitFor(() => {
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
})
})
it('should display time consumed after crawl completion', async () => {

View File

@ -1,214 +0,0 @@
import type { SortType } from '@/service/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import DocumentsHeader from './documents-header'
// Mock the context hooks
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
// Mock child components that require API calls
vi.mock('@/app/components/datasets/common/document-status-with-action/auto-disabled-document', () => ({
default: () => <div data-testid="auto-disabled-document">AutoDisabledDocument</div>,
}))
vi.mock('@/app/components/datasets/common/document-status-with-action/index-failed', () => ({
default: () => <div data-testid="index-failed">IndexFailed</div>,
}))
vi.mock('@/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="metadata-drawer">
<button onClick={onClose}>Close</button>
MetadataDrawer
</div>
),
}))
describe('DocumentsHeader', () => {
const defaultProps = {
datasetId: 'dataset-123',
dataSourceType: DataSourceType.FILE,
embeddingAvailable: true,
isFreePlan: false,
statusFilterValue: 'all',
sortValue: 'created_at' as SortType,
inputValue: '',
onStatusFilterChange: vi.fn(),
onStatusFilterClear: vi.fn(),
onSortChange: vi.fn(),
onInputChange: vi.fn(),
isShowEditMetadataModal: false,
showEditMetadataModal: vi.fn(),
hideEditMetadataModal: vi.fn(),
datasetMetaData: [],
builtInMetaData: [],
builtInEnabled: true,
onAddMetaData: vi.fn(),
onRenameMetaData: vi.fn(),
onDeleteMetaData: vi.fn(),
onBuiltInEnabledChange: vi.fn(),
onAddDocument: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DocumentsHeader {...defaultProps} />)
expect(screen.getByText(/list\.title/i)).toBeInTheDocument()
})
it('should render title', () => {
render(<DocumentsHeader {...defaultProps} />)
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(/list\.title/i)
})
it('should render description text', () => {
render(<DocumentsHeader {...defaultProps} />)
expect(screen.getByText(/list\.desc/i)).toBeInTheDocument()
})
it('should render learn more link', () => {
render(<DocumentsHeader {...defaultProps} />)
const link = screen.getByRole('link')
expect(link).toHaveTextContent(/list\.learnMore/i)
expect(link).toHaveAttribute('href', expect.stringContaining('use-dify/knowledge'))
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should render filter input', () => {
render(<DocumentsHeader {...defaultProps} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
describe('AutoDisabledDocument', () => {
it('should show AutoDisabledDocument when not free plan', () => {
render(<DocumentsHeader {...defaultProps} isFreePlan={false} />)
expect(screen.getByTestId('auto-disabled-document')).toBeInTheDocument()
})
it('should not show AutoDisabledDocument when on free plan', () => {
render(<DocumentsHeader {...defaultProps} isFreePlan={true} />)
expect(screen.queryByTestId('auto-disabled-document')).not.toBeInTheDocument()
})
})
describe('IndexFailed', () => {
it('should always show IndexFailed component', () => {
render(<DocumentsHeader {...defaultProps} />)
expect(screen.getByTestId('index-failed')).toBeInTheDocument()
})
})
describe('Embedding Availability', () => {
it('should show metadata button when embedding is available', () => {
render(<DocumentsHeader {...defaultProps} embeddingAvailable={true} />)
expect(screen.getByText(/metadata\.metadata/i)).toBeInTheDocument()
})
it('should show add document button when embedding is available', () => {
render(<DocumentsHeader {...defaultProps} embeddingAvailable={true} />)
expect(screen.getByText(/list\.addFile/i)).toBeInTheDocument()
})
it('should show warning when embedding is not available', () => {
render(<DocumentsHeader {...defaultProps} embeddingAvailable={false} />)
expect(screen.queryByText(/metadata\.metadata/i)).not.toBeInTheDocument()
expect(screen.queryByText(/list\.addFile/i)).not.toBeInTheDocument()
})
})
describe('Add Button Text', () => {
it('should show "Add File" for FILE data source', () => {
render(<DocumentsHeader {...defaultProps} dataSourceType={DataSourceType.FILE} />)
expect(screen.getByText(/list\.addFile/i)).toBeInTheDocument()
})
it('should show "Add Pages" for NOTION data source', () => {
render(<DocumentsHeader {...defaultProps} dataSourceType={DataSourceType.NOTION} />)
expect(screen.getByText(/list\.addPages/i)).toBeInTheDocument()
})
it('should show "Add Url" for WEB data source', () => {
render(<DocumentsHeader {...defaultProps} dataSourceType={DataSourceType.WEB} />)
expect(screen.getByText(/list\.addUrl/i)).toBeInTheDocument()
})
})
describe('Metadata Modal', () => {
it('should show metadata drawer when isShowEditMetadataModal is true', () => {
render(<DocumentsHeader {...defaultProps} isShowEditMetadataModal={true} />)
expect(screen.getByTestId('metadata-drawer')).toBeInTheDocument()
})
it('should not show metadata drawer when isShowEditMetadataModal is false', () => {
render(<DocumentsHeader {...defaultProps} isShowEditMetadataModal={false} />)
expect(screen.queryByTestId('metadata-drawer')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call showEditMetadataModal when metadata button is clicked', () => {
const showEditMetadataModal = vi.fn()
render(<DocumentsHeader {...defaultProps} showEditMetadataModal={showEditMetadataModal} />)
const metadataButton = screen.getByText(/metadata\.metadata/i)
fireEvent.click(metadataButton)
expect(showEditMetadataModal).toHaveBeenCalledTimes(1)
})
it('should call onAddDocument when add button is clicked', () => {
const onAddDocument = vi.fn()
render(<DocumentsHeader {...defaultProps} onAddDocument={onAddDocument} />)
const addButton = screen.getByText(/list\.addFile/i)
fireEvent.click(addButton)
expect(onAddDocument).toHaveBeenCalledTimes(1)
})
it('should call onInputChange when typing in search input', () => {
const onInputChange = vi.fn()
render(<DocumentsHeader {...defaultProps} onInputChange={onInputChange} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'search query' } })
expect(onInputChange).toHaveBeenCalledWith('search query')
})
})
describe('Edge Cases', () => {
it('should handle undefined dataSourceType', () => {
render(<DocumentsHeader {...defaultProps} dataSourceType={undefined} />)
// Should default to "Add File" text
expect(screen.getByText(/list\.addFile/i)).toBeInTheDocument()
})
it('should handle empty metadata arrays', () => {
render(
<DocumentsHeader
{...defaultProps}
isShowEditMetadataModal={true}
datasetMetaData={[]}
builtInMetaData={[]}
/>,
)
expect(screen.getByTestId('metadata-drawer')).toBeInTheDocument()
})
it('should render with descending sort order', () => {
render(<DocumentsHeader {...defaultProps} sortValue="-created_at" />)
// Component should still render correctly
expect(screen.getByText(/list\.title/i)).toBeInTheDocument()
})
})
})

View File

@ -1,95 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import EmptyElement from './empty-element'
describe('EmptyElement', () => {
const defaultProps = {
canAdd: true,
onClick: vi.fn(),
}
describe('Rendering', () => {
it('should render without crashing', () => {
render(<EmptyElement {...defaultProps} />)
expect(screen.getByText(/list\.empty\.title/i)).toBeInTheDocument()
})
it('should render title text', () => {
render(<EmptyElement {...defaultProps} />)
expect(screen.getByText(/list\.empty\.title/i)).toBeInTheDocument()
})
it('should render tip text for upload type', () => {
render(<EmptyElement {...defaultProps} type="upload" />)
expect(screen.getByText(/list\.empty\.upload\.tip/i)).toBeInTheDocument()
})
it('should render tip text for sync type', () => {
render(<EmptyElement {...defaultProps} type="sync" />)
expect(screen.getByText(/list\.empty\.sync\.tip/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should use upload type by default', () => {
render(<EmptyElement {...defaultProps} />)
expect(screen.getByText(/list\.empty\.upload\.tip/i)).toBeInTheDocument()
})
it('should render FolderPlusIcon for upload type', () => {
const { container } = render(<EmptyElement {...defaultProps} type="upload" />)
// FolderPlusIcon has specific SVG attributes
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
})
it('should render NotionIcon for sync type', () => {
const { container } = render(<EmptyElement {...defaultProps} type="sync" />)
// NotionIcon has clipPath
const clipPath = container.querySelector('clipPath')
expect(clipPath).toBeInTheDocument()
})
})
describe('Add Button', () => {
it('should show add button when canAdd is true and type is upload', () => {
render(<EmptyElement {...defaultProps} canAdd={true} type="upload" />)
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByText(/list\.addFile/i)).toBeInTheDocument()
})
it('should not show add button when canAdd is false', () => {
render(<EmptyElement {...defaultProps} canAdd={false} type="upload" />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should not show add button for sync type', () => {
render(<EmptyElement {...defaultProps} canAdd={true} type="sync" />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('should not show add button for sync type even when canAdd is true', () => {
render(<EmptyElement canAdd={true} onClick={vi.fn()} type="sync" />)
expect(screen.queryByText(/list\.addFile/i)).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClick when add button is clicked', () => {
const handleClick = vi.fn()
render(<EmptyElement canAdd={true} onClick={handleClick} type="upload" />)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle default canAdd value (true)', () => {
render(<EmptyElement onClick={vi.fn()} canAdd={true} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})

View File

@ -1,81 +0,0 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { FolderPlusIcon, NotionIcon, ThreeDotsIcon } from './icons'
describe('Icons', () => {
describe('FolderPlusIcon', () => {
it('should render without crashing', () => {
render(<FolderPlusIcon />)
const svg = document.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should have correct dimensions', () => {
render(<FolderPlusIcon />)
const svg = document.querySelector('svg')
expect(svg).toHaveAttribute('width', '20')
expect(svg).toHaveAttribute('height', '20')
})
it('should apply custom className', () => {
render(<FolderPlusIcon className="custom-class" />)
const svg = document.querySelector('svg')
expect(svg).toHaveClass('custom-class')
})
it('should have empty className by default', () => {
render(<FolderPlusIcon />)
const svg = document.querySelector('svg')
expect(svg).toHaveAttribute('class', '')
})
})
describe('ThreeDotsIcon', () => {
it('should render without crashing', () => {
const { container } = render(<ThreeDotsIcon />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should have correct dimensions', () => {
const { container } = render(<ThreeDotsIcon />)
const svg = container.querySelector('svg')
expect(svg).toHaveAttribute('width', '16')
expect(svg).toHaveAttribute('height', '16')
})
it('should apply custom className', () => {
const { container } = render(<ThreeDotsIcon className="custom-class" />)
const svg = container.querySelector('svg')
expect(svg).toHaveClass('custom-class')
})
})
describe('NotionIcon', () => {
it('should render without crashing', () => {
const { container } = render(<NotionIcon />)
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('should have correct dimensions', () => {
const { container } = render(<NotionIcon />)
const svg = container.querySelector('svg')
expect(svg).toHaveAttribute('width', '20')
expect(svg).toHaveAttribute('height', '20')
})
it('should apply custom className', () => {
const { container } = render(<NotionIcon className="custom-class" />)
const svg = container.querySelector('svg')
expect(svg).toHaveClass('custom-class')
})
it('should contain clipPath definition', () => {
const { container } = render(<NotionIcon />)
const clipPath = container.querySelector('clipPath')
expect(clipPath).toBeInTheDocument()
expect(clipPath).toHaveAttribute('id', 'clip0_2164_11263')
})
})
})

View File

@ -1,643 +0,0 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Operations from './operations'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
// Mock ToastContext
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
ToastContext: {
Provider: ({ children }: { children: React.ReactNode }) => children,
},
}))
vi.mock('use-context-selector', () => ({
useContext: () => ({
notify: mockNotify,
}),
}))
// Mock document service hooks
const mockArchive = vi.fn()
const mockUnArchive = vi.fn()
const mockEnable = vi.fn()
const mockDisable = vi.fn()
const mockDelete = vi.fn()
const mockDownload = vi.fn()
const mockSync = vi.fn()
const mockSyncWebsite = vi.fn()
const mockPause = vi.fn()
const mockResume = vi.fn()
let isDownloadPending = false
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentArchive: () => ({ mutateAsync: mockArchive }),
useDocumentUnArchive: () => ({ mutateAsync: mockUnArchive }),
useDocumentEnable: () => ({ mutateAsync: mockEnable }),
useDocumentDisable: () => ({ mutateAsync: mockDisable }),
useDocumentDelete: () => ({ mutateAsync: mockDelete }),
useDocumentDownload: () => ({ mutateAsync: mockDownload, isPending: isDownloadPending }),
useSyncDocument: () => ({ mutateAsync: mockSync }),
useSyncWebsite: () => ({ mutateAsync: mockSyncWebsite }),
useDocumentPause: () => ({ mutateAsync: mockPause }),
useDocumentResume: () => ({ mutateAsync: mockResume }),
}))
// Mock downloadUrl utility
const mockDownloadUrl = vi.fn()
vi.mock('@/utils/download', () => ({
downloadUrl: (params: { url: string, fileName: string }) => mockDownloadUrl(params),
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
isDownloadPending = false
})
describe('Operations', () => {
const mockOnUpdate = vi.fn()
const mockOnSelectedIdChange = vi.fn()
const defaultDetail = {
id: 'doc-1',
name: 'Test Document',
enabled: true,
archived: false,
data_source_type: 'upload_file',
doc_form: 'text_model',
display_status: 'available',
}
const defaultProps = {
embeddingAvailable: true,
datasetId: 'dataset-1',
detail: defaultDetail,
onUpdate: mockOnUpdate,
}
beforeEach(() => {
vi.clearAllMocks()
mockArchive.mockResolvedValue({})
mockUnArchive.mockResolvedValue({})
mockEnable.mockResolvedValue({})
mockDisable.mockResolvedValue({})
mockDelete.mockResolvedValue({})
mockDownload.mockResolvedValue({ url: 'https://example.com/download' })
mockSync.mockResolvedValue({})
mockSyncWebsite.mockResolvedValue({})
mockPause.mockResolvedValue({})
mockResume.mockResolvedValue({})
})
describe('rendering', () => {
it('should render without crashing', () => {
render(<Operations {...defaultProps} />)
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should render buttons when embeddingAvailable', () => {
render(<Operations {...defaultProps} />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('should not render settings when embeddingAvailable is false', () => {
render(<Operations {...defaultProps} embeddingAvailable={false} />)
expect(screen.queryByText('list.action.settings')).not.toBeInTheDocument()
})
it('should render disabled switch when embeddingAvailable is false in list scene', () => {
render(<Operations {...defaultProps} embeddingAvailable={false} scene="list" />)
// Switch component uses opacity-50 class when disabled
const disabledSwitch = document.querySelector('.\\!opacity-50')
expect(disabledSwitch).toBeInTheDocument()
})
})
describe('switch toggle', () => {
it('should render switch in list scene', () => {
render(<Operations {...defaultProps} scene="list" />)
const switches = document.querySelectorAll('[role="switch"], [class*="switch"]')
expect(switches.length).toBeGreaterThan(0)
})
it('should render disabled switch when archived', () => {
render(
<Operations
{...defaultProps}
scene="list"
detail={{ ...defaultDetail, archived: true }}
/>,
)
const disabledSwitch = document.querySelector('[disabled]')
expect(disabledSwitch).toBeDefined()
})
it('should call enable when switch is toggled on', async () => {
vi.useFakeTimers()
render(
<Operations
{...defaultProps}
scene="list"
detail={{ ...defaultDetail, enabled: false }}
/>,
)
const switchElement = document.querySelector('[role="switch"]')
await act(async () => {
fireEvent.click(switchElement!)
})
// Wait for debounce
await act(async () => {
vi.advanceTimersByTime(600)
})
expect(mockEnable).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
vi.useRealTimers()
})
it('should call disable when switch is toggled off', async () => {
vi.useFakeTimers()
render(
<Operations
{...defaultProps}
scene="list"
detail={{ ...defaultDetail, enabled: true }}
/>,
)
const switchElement = document.querySelector('[role="switch"]')
await act(async () => {
fireEvent.click(switchElement!)
})
// Wait for debounce
await act(async () => {
vi.advanceTimersByTime(600)
})
expect(mockDisable).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
vi.useRealTimers()
})
it('should not call enable if already enabled', async () => {
vi.useFakeTimers()
render(
<Operations
{...defaultProps}
scene="list"
detail={{ ...defaultDetail, enabled: true }}
/>,
)
// Simulate trying to enable when already enabled - this won't happen via switch click
// because the switch would toggle to disable. But handleSwitch has early returns
vi.useRealTimers()
})
})
describe('settings navigation', () => {
it('should navigate to settings when settings button is clicked', async () => {
render(<Operations {...defaultProps} />)
// Get the first button which is the settings button
const buttons = screen.getAllByRole('button')
const settingsButton = buttons[0]
await act(async () => {
fireEvent.click(settingsButton)
})
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/doc-1/settings')
})
})
describe('detail scene', () => {
it('should render differently in detail scene', () => {
render(<Operations {...defaultProps} scene="detail" />)
const container = document.querySelector('.flex.items-center')
expect(container).toBeInTheDocument()
})
it('should not render switch in detail scene', () => {
render(<Operations {...defaultProps} scene="detail" />)
// In detail scene, there should be no switch
const switchInParent = document.querySelector('.flex.items-center > [role="switch"]')
expect(switchInParent).toBeNull()
})
})
describe('selectedIds handling', () => {
it('should accept selectedIds prop', () => {
render(
<Operations
{...defaultProps}
selectedIds={['doc-1', 'doc-2']}
onSelectedIdChange={mockOnSelectedIdChange}
/>,
)
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
})
describe('popover menu actions', () => {
const openPopover = async () => {
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
}
it('should open popover when more button is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
// Check if popover content is visible
expect(screen.getByText('list.table.rename')).toBeInTheDocument()
})
it('should call archive when archive action is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const archiveButton = screen.getByText('list.action.archive')
await act(async () => {
fireEvent.click(archiveButton)
})
await waitFor(() => {
expect(mockArchive).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should call un_archive when unarchive action is clicked', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: true }}
/>,
)
await openPopover()
const unarchiveButton = screen.getByText('list.action.unarchive')
await act(async () => {
fireEvent.click(unarchiveButton)
})
await waitFor(() => {
expect(mockUnArchive).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should show delete confirmation modal when delete is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const deleteButton = screen.getByText('list.action.delete')
await act(async () => {
fireEvent.click(deleteButton)
})
// Check if confirmation modal is shown
expect(screen.getByText('list.delete.title')).toBeInTheDocument()
})
it('should call delete when confirm is clicked in delete modal', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const deleteButton = screen.getByText('list.action.delete')
await act(async () => {
fireEvent.click(deleteButton)
})
// Click confirm button
const confirmButton = screen.getByText('operation.sure')
await act(async () => {
fireEvent.click(confirmButton)
})
await waitFor(() => {
expect(mockDelete).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should close delete modal when cancel is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const deleteButton = screen.getByText('list.action.delete')
await act(async () => {
fireEvent.click(deleteButton)
})
// Verify modal is shown
expect(screen.getByText('list.delete.title')).toBeInTheDocument()
// Find and click the cancel button (text: operation.cancel)
const cancelButton = screen.getByText('operation.cancel')
await act(async () => {
fireEvent.click(cancelButton)
})
// Modal should be closed - title shouldn't be visible
await waitFor(() => {
expect(screen.queryByText('list.delete.title')).not.toBeInTheDocument()
})
})
it('should update selectedIds after delete operation', async () => {
render(
<Operations
{...defaultProps}
selectedIds={['doc-1', 'doc-2']}
onSelectedIdChange={mockOnSelectedIdChange}
/>,
)
await openPopover()
const deleteButton = screen.getByText('list.action.delete')
await act(async () => {
fireEvent.click(deleteButton)
})
const confirmButton = screen.getByText('operation.sure')
await act(async () => {
fireEvent.click(confirmButton)
})
await waitFor(() => {
expect(mockOnSelectedIdChange).toHaveBeenCalledWith(['doc-2'])
})
})
it('should show rename modal when rename is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const renameButton = screen.getByText('list.table.rename')
await act(async () => {
fireEvent.click(renameButton)
})
// Rename modal should be shown
await waitFor(() => {
expect(screen.getByDisplayValue('Test Document')).toBeInTheDocument()
})
})
it('should call sync for notion data source', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
/>,
)
await openPopover()
const syncButton = screen.getByText('list.action.sync')
await act(async () => {
fireEvent.click(syncButton)
})
await waitFor(() => {
expect(mockSync).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should call syncWebsite for web data source', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: 'website_crawl' }}
/>,
)
await openPopover()
const syncButton = screen.getByText('list.action.sync')
await act(async () => {
fireEvent.click(syncButton)
})
await waitFor(() => {
expect(mockSyncWebsite).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should call pause when pause action is clicked', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'indexing' }}
/>,
)
await openPopover()
const pauseButton = screen.getByText('list.action.pause')
await act(async () => {
fireEvent.click(pauseButton)
})
await waitFor(() => {
expect(mockPause).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should call resume when resume action is clicked', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'paused' }}
/>,
)
await openPopover()
const resumeButton = screen.getByText('list.action.resume')
await act(async () => {
fireEvent.click(resumeButton)
})
await waitFor(() => {
expect(mockResume).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should download file when download action is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const downloadButton = screen.getByText('list.action.download')
await act(async () => {
fireEvent.click(downloadButton)
})
await waitFor(() => {
expect(mockDownload).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
expect(mockDownloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/download', fileName: 'Test Document' })
})
})
it('should show download option for archived file data source', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: true, data_source_type: 'upload_file' }}
/>,
)
await openPopover()
expect(screen.getByText('list.action.download')).toBeInTheDocument()
})
it('should download archived file when download is clicked', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: true, data_source_type: 'upload_file' }}
/>,
)
await openPopover()
const downloadButton = screen.getByText('list.action.download')
await act(async () => {
fireEvent.click(downloadButton)
})
await waitFor(() => {
expect(mockDownload).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
})
describe('error handling', () => {
it('should show error notification when operation fails', async () => {
mockArchive.mockRejectedValue(new Error('API Error'))
render(<Operations {...defaultProps} />)
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
const archiveButton = screen.getByText('list.action.archive')
await act(async () => {
fireEvent.click(archiveButton)
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'actionMsg.modifiedUnsuccessfully',
})
})
})
it('should show error notification when download fails', async () => {
mockDownload.mockRejectedValue(new Error('Download Error'))
render(<Operations {...defaultProps} />)
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
const downloadButton = screen.getByText('list.action.download')
await act(async () => {
fireEvent.click(downloadButton)
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'actionMsg.downloadUnsuccessfully',
})
})
})
it('should show error notification when download returns no url', async () => {
mockDownload.mockResolvedValue({})
render(<Operations {...defaultProps} />)
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
const downloadButton = screen.getByText('list.action.download')
await act(async () => {
fireEvent.click(downloadButton)
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'actionMsg.downloadUnsuccessfully',
})
})
})
})
describe('display status', () => {
it('should render pause action when status is indexing', () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'indexing' }}
/>,
)
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should render resume action when status is paused', () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'paused' }}
/>,
)
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should not show pause/resume for available status', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'available' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
expect(screen.queryByText('list.action.pause')).not.toBeInTheDocument()
expect(screen.queryByText('list.action.resume')).not.toBeInTheDocument()
})
})
describe('data source types', () => {
it('should handle notion data source type', () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
/>,
)
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should handle web data source type', () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: 'website_crawl' }}
/>,
)
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should not show download for non-file data source', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
expect(screen.queryByText('list.action.download')).not.toBeInTheDocument()
})
})
describe('memoization', () => {
it('should be wrapped with React.memo', () => {
expect((Operations as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})
describe('className prop', () => {
it('should accept custom className prop', () => {
// The className is passed to CustomPopover, verify component renders without errors
render(<Operations {...defaultProps} className="custom-class" />)
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
})
})

View File

@ -1,183 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import after mock
import { renameDocumentName } from '@/service/datasets'
import RenameModal from './rename-modal'
// Mock the service
vi.mock('@/service/datasets', () => ({
renameDocumentName: vi.fn(),
}))
const mockRenameDocumentName = vi.mocked(renameDocumentName)
describe('RenameModal', () => {
const defaultProps = {
datasetId: 'dataset-123',
documentId: 'doc-456',
name: 'Original Document',
onClose: vi.fn(),
onSaved: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<RenameModal {...defaultProps} />)
expect(screen.getByText(/list\.table\.rename/i)).toBeInTheDocument()
})
it('should render modal title', () => {
render(<RenameModal {...defaultProps} />)
expect(screen.getByText(/list\.table\.rename/i)).toBeInTheDocument()
})
it('should render name label', () => {
render(<RenameModal {...defaultProps} />)
expect(screen.getByText(/list\.table\.name/i)).toBeInTheDocument()
})
it('should render input with initial name', () => {
render(<RenameModal {...defaultProps} />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('Original Document')
})
it('should render cancel button', () => {
render(<RenameModal {...defaultProps} />)
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
})
it('should render save button', () => {
render(<RenameModal {...defaultProps} />)
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should display the provided name in input', () => {
render(<RenameModal {...defaultProps} name="Custom Name" />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('Custom Name')
})
})
describe('User Interactions', () => {
it('should update input value when typing', () => {
render(<RenameModal {...defaultProps} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'New Name' } })
expect(input).toHaveValue('New Name')
})
it('should call onClose when cancel button is clicked', () => {
const handleClose = vi.fn()
render(<RenameModal {...defaultProps} onClose={handleClose} />)
const cancelButton = screen.getByText(/operation\.cancel/i)
fireEvent.click(cancelButton)
expect(handleClose).toHaveBeenCalledTimes(1)
})
it('should call renameDocumentName with correct params when save is clicked', async () => {
mockRenameDocumentName.mockResolvedValueOnce({ result: 'success' })
render(<RenameModal {...defaultProps} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'New Document Name' } })
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockRenameDocumentName).toHaveBeenCalledWith({
datasetId: 'dataset-123',
documentId: 'doc-456',
name: 'New Document Name',
})
})
})
it('should call onSaved and onClose on successful save', async () => {
mockRenameDocumentName.mockResolvedValueOnce({ result: 'success' })
const handleSaved = vi.fn()
const handleClose = vi.fn()
render(<RenameModal {...defaultProps} onSaved={handleSaved} onClose={handleClose} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
expect(handleSaved).toHaveBeenCalledTimes(1)
expect(handleClose).toHaveBeenCalledTimes(1)
})
})
})
describe('Loading State', () => {
it('should show loading state while saving', async () => {
// Create a promise that we can resolve manually
let resolvePromise: (value: { result: 'success' | 'fail' }) => void
const pendingPromise = new Promise<{ result: 'success' | 'fail' }>((resolve) => {
resolvePromise = resolve
})
mockRenameDocumentName.mockReturnValueOnce(pendingPromise)
render(<RenameModal {...defaultProps} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
// The button should be in loading state
await waitFor(() => {
const buttons = screen.getAllByRole('button')
const saveBtn = buttons.find(btn => btn.textContent?.includes('operation.save'))
expect(saveBtn).toBeInTheDocument()
})
// Resolve the promise to clean up
resolvePromise!({ result: 'success' })
})
})
describe('Error Handling', () => {
it('should handle API error gracefully', async () => {
const error = new Error('API Error')
mockRenameDocumentName.mockRejectedValueOnce(error)
const handleSaved = vi.fn()
const handleClose = vi.fn()
render(<RenameModal {...defaultProps} onSaved={handleSaved} onClose={handleClose} />)
const saveButton = screen.getByText(/operation\.save/i)
fireEvent.click(saveButton)
await waitFor(() => {
// onSaved and onClose should not be called on error
expect(handleSaved).not.toHaveBeenCalled()
expect(handleClose).not.toHaveBeenCalled()
})
})
})
describe('Edge Cases', () => {
it('should handle empty name', () => {
render(<RenameModal {...defaultProps} name="" />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('')
})
it('should handle name with special characters', () => {
render(<RenameModal {...defaultProps} name="Document <with> 'special' chars" />)
const input = screen.getByRole('textbox')
expect(input).toHaveValue('Document <with> \'special\' chars')
})
})
})

View File

@ -1,38 +0,0 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import EmptyFolder from './empty-folder'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
afterEach(() => {
cleanup()
})
describe('EmptyFolder', () => {
it('should render without crashing', () => {
render(<EmptyFolder />)
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should render the empty folder text', () => {
render(<EmptyFolder />)
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should have proper styling classes', () => {
const { container } = render(<EmptyFolder />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('justify-center')
})
it('should be wrapped with React.memo', () => {
expect((EmptyFolder as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})

View File

@ -1,279 +0,0 @@
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse, FileItem } from '@/models/datasets'
import type { OnlineDriveFile } from '@/models/pipeline'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DatasourceType } from '@/models/pipeline'
import { StepOnePreview, StepTwoPreview } from './preview-panel'
// Mock context hooks (底层依赖)
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: vi.fn((selector: (state: unknown) => unknown) => {
const mockState = {
dataset: {
id: 'mock-dataset-id',
doc_form: 'text_model',
pipeline_id: 'mock-pipeline-id',
},
}
return selector(mockState)
}),
}))
// Mock API hooks (底层依赖)
vi.mock('@/service/use-common', () => ({
useFilePreview: vi.fn(() => ({
data: { content: 'Mock file content for testing' },
isFetching: false,
})),
}))
vi.mock('@/service/use-pipeline', () => ({
usePreviewOnlineDocument: vi.fn(() => ({
mutateAsync: vi.fn().mockResolvedValue({ content: 'Mock document content' }),
isPending: false,
})),
}))
// Mock data source store
vi.mock('../data-source/store', () => ({
useDataSourceStore: vi.fn(() => ({
getState: () => ({ currentCredentialId: 'mock-credential-id' }),
})),
}))
describe('StepOnePreview', () => {
const mockDatasource: Datasource = {
nodeId: 'test-node-id',
nodeData: { type: 'data-source' } as unknown as DataSourceNodeType,
}
const mockLocalFile: CustomFile = {
id: 'file-1',
name: 'test-file.txt',
type: 'text/plain',
size: 1024,
progress: 100,
extension: 'txt',
} as unknown as CustomFile
const mockWebsite: CrawlResultItem = {
source_url: 'https://example.com',
title: 'Example Site',
markdown: 'Mock markdown content',
} as CrawlResultItem
const defaultProps = {
datasource: mockDatasource,
currentLocalFile: undefined,
currentDocument: undefined,
currentWebsite: undefined,
hidePreviewLocalFile: vi.fn(),
hidePreviewOnlineDocument: vi.fn(),
hideWebsitePreview: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<StepOnePreview {...defaultProps} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
it('should render container with correct structure', () => {
const { container } = render(<StepOnePreview {...defaultProps} />)
expect(container.querySelector('.flex.h-full.flex-col')).toBeInTheDocument()
})
})
describe('Conditional Rendering - FilePreview', () => {
it('should render FilePreview when currentLocalFile is provided', () => {
render(<StepOnePreview {...defaultProps} currentLocalFile={mockLocalFile} />)
// FilePreview renders a preview header with file name
expect(screen.getByText(/test-file/i)).toBeInTheDocument()
})
it('should not render FilePreview when currentLocalFile is undefined', () => {
const { container } = render(<StepOnePreview {...defaultProps} currentLocalFile={undefined} />)
// Container should still render but without file preview content
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
})
describe('Conditional Rendering - WebsitePreview', () => {
it('should render WebsitePreview when currentWebsite is provided', () => {
render(<StepOnePreview {...defaultProps} currentWebsite={mockWebsite} />)
// WebsitePreview displays the website title and URL
expect(screen.getByText('Example Site')).toBeInTheDocument()
expect(screen.getByText('https://example.com')).toBeInTheDocument()
})
it('should not render WebsitePreview when currentWebsite is undefined', () => {
const { container } = render(<StepOnePreview {...defaultProps} currentWebsite={undefined} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
it('should call hideWebsitePreview when close button is clicked', () => {
const hideWebsitePreview = vi.fn()
render(
<StepOnePreview
{...defaultProps}
currentWebsite={mockWebsite}
hideWebsitePreview={hideWebsitePreview}
/>,
)
// Find and click the close button (RiCloseLine icon)
const closeButton = screen.getByRole('button')
closeButton.click()
expect(hideWebsitePreview).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle website with long markdown content', () => {
const longWebsite: CrawlResultItem = {
...mockWebsite,
markdown: 'A'.repeat(10000),
}
render(<StepOnePreview {...defaultProps} currentWebsite={longWebsite} />)
expect(screen.getByText('Example Site')).toBeInTheDocument()
})
})
})
describe('StepTwoPreview', () => {
const mockFileList: FileItem[] = [
{
file: {
id: 'file-1',
name: 'file1.txt',
extension: 'txt',
size: 1024,
} as CustomFile,
progress: 100,
},
{
file: {
id: 'file-2',
name: 'file2.txt',
extension: 'txt',
size: 2048,
} as CustomFile,
progress: 100,
},
] as FileItem[]
const mockOnlineDocuments: (NotionPage & { workspace_id: string })[] = [
{
page_id: 'page-1',
page_name: 'Page 1',
type: 'page',
workspace_id: 'workspace-1',
page_icon: null,
is_bound: false,
parent_id: '',
},
]
const mockWebsitePages: CrawlResultItem[] = [
{ source_url: 'https://example.com', title: 'Example', markdown: 'Content' } as CrawlResultItem,
]
const mockOnlineDriveFiles: OnlineDriveFile[] = [
{ id: 'drive-1', name: 'drive-file.txt' } as OnlineDriveFile,
]
const mockEstimateData: FileIndexingEstimateResponse = {
tokens: 1000,
total_price: 0.01,
total_segments: 10,
} as FileIndexingEstimateResponse
const defaultProps = {
datasourceType: DatasourceType.localFile,
localFileList: mockFileList,
onlineDocuments: mockOnlineDocuments,
websitePages: mockWebsitePages,
selectedOnlineDriveFileList: mockOnlineDriveFiles,
isIdle: true,
isPendingPreview: false,
estimateData: mockEstimateData,
onPreview: vi.fn(),
handlePreviewFileChange: vi.fn(),
handlePreviewOnlineDocumentChange: vi.fn(),
handlePreviewWebsitePageChange: vi.fn(),
handlePreviewOnlineDriveFileChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<StepTwoPreview {...defaultProps} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
it('should render ChunkPreview component structure', () => {
const { container } = render(<StepTwoPreview {...defaultProps} />)
expect(container.querySelector('.flex.h-full.flex-col')).toBeInTheDocument()
})
})
describe('Props Passing', () => {
it('should render preview button when isIdle is true', () => {
render(<StepTwoPreview {...defaultProps} isIdle={true} />)
// ChunkPreview shows a preview button when idle
const previewButton = screen.queryByRole('button')
expect(previewButton).toBeInTheDocument()
})
it('should call onPreview when preview button is clicked', () => {
const onPreview = vi.fn()
render(<StepTwoPreview {...defaultProps} isIdle={true} onPreview={onPreview} />)
// Find and click the preview button
const buttons = screen.getAllByRole('button')
const previewButton = buttons.find(btn => btn.textContent?.toLowerCase().includes('preview'))
if (previewButton) {
previewButton.click()
expect(onPreview).toHaveBeenCalled()
}
})
})
describe('Edge Cases', () => {
it('should handle empty localFileList', () => {
const { container } = render(<StepTwoPreview {...defaultProps} localFileList={[]} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
it('should handle empty onlineDocuments', () => {
const { container } = render(<StepTwoPreview {...defaultProps} onlineDocuments={[]} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
it('should handle empty websitePages', () => {
const { container } = render(<StepTwoPreview {...defaultProps} websitePages={[]} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
it('should handle empty onlineDriveFiles', () => {
const { container } = render(<StepTwoPreview {...defaultProps} selectedOnlineDriveFileList={[]} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
it('should handle undefined estimateData', () => {
const { container } = render(<StepTwoPreview {...defaultProps} estimateData={undefined} />)
expect(container.querySelector('.h-full')).toBeInTheDocument()
})
})
})

View File

@ -1,413 +0,0 @@
import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import type { Node } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DatasourceType } from '@/models/pipeline'
import StepOneContent from './step-one-content'
// Mock context providers and hooks (底层依赖)
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(() => ({
setShowPricingModal: vi.fn(),
})),
}))
// Mock billing components that have complex provider dependencies
vi.mock('@/app/components/billing/vector-space-full', () => ({
default: () => <div data-testid="vector-space-full">Vector Space Full</div>,
}))
vi.mock('@/app/components/billing/upgrade-btn', () => ({
default: ({ onClick }: { onClick?: () => void }) => (
<button data-testid="upgrade-btn" onClick={onClick}>Upgrade</button>
),
}))
// Mock data source store
vi.mock('../data-source/store', () => ({
useDataSourceStore: vi.fn(() => ({
getState: () => ({
localFileList: [],
currentCredentialId: 'mock-credential-id',
}),
setState: vi.fn(),
})),
useDataSourceStoreWithSelector: vi.fn((selector: (state: unknown) => unknown) => {
const mockState = {
localFileList: [],
onlineDocuments: [],
websitePages: [],
selectedOnlineDriveFileList: [],
}
return selector(mockState)
}),
}))
// Mock file upload config
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: vi.fn(() => ({
data: {
file_size_limit: 15 * 1024 * 1024,
batch_count_limit: 20,
document_file_extensions: ['.txt', '.md', '.pdf'],
},
isLoading: false,
})),
}))
// Mock hooks used by data source options
vi.mock('../hooks', () => ({
useDatasourceOptions: vi.fn(() => [
{ label: 'Local File', value: 'node-1', data: { type: 'data-source' } },
]),
}))
// Mock useDatasourceIcon hook to avoid complex data source list transformation
vi.mock('../data-source-options/hooks', () => ({
useDatasourceIcon: vi.fn(() => '/icons/local-file.svg'),
}))
// Mock the entire local-file component since it has deep context dependencies
vi.mock('../data-source/local-file', () => ({
default: ({ allowedExtensions, supportBatchUpload }: {
allowedExtensions: string[]
supportBatchUpload: boolean
}) => (
<div data-testid="local-file">
<div>Drag and drop file here</div>
<span data-testid="allowed-extensions">{allowedExtensions.join(',')}</span>
<span data-testid="support-batch-upload">{String(supportBatchUpload)}</span>
</div>
),
}))
// Mock online documents since it has complex OAuth/API dependencies
vi.mock('../data-source/online-documents', () => ({
default: ({ nodeId, onCredentialChange }: {
nodeId: string
onCredentialChange: (credentialId: string) => void
}) => (
<div data-testid="online-documents">
<span data-testid="online-doc-node-id">{nodeId}</span>
<button data-testid="credential-change-btn" onClick={() => onCredentialChange('new-credential')}>
Change Credential
</button>
</div>
),
}))
// Mock website crawl
vi.mock('../data-source/website-crawl', () => ({
default: ({ nodeId, onCredentialChange }: {
nodeId: string
onCredentialChange: (credentialId: string) => void
}) => (
<div data-testid="website-crawl">
<span data-testid="website-crawl-node-id">{nodeId}</span>
<button data-testid="website-credential-btn" onClick={() => onCredentialChange('website-credential')}>
Change Website Credential
</button>
</div>
),
}))
// Mock online drive
vi.mock('../data-source/online-drive', () => ({
default: ({ nodeId, onCredentialChange }: {
nodeId: string
onCredentialChange: (credentialId: string) => void
}) => (
<div data-testid="online-drive">
<span data-testid="online-drive-node-id">{nodeId}</span>
<button data-testid="drive-credential-btn" onClick={() => onCredentialChange('drive-credential')}>
Change Drive Credential
</button>
</div>
),
}))
// Mock locale context
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(() => 'en'),
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
// Mock theme hook
vi.mock('@/hooks/use-theme', () => ({
default: vi.fn(() => 'light'),
}))
// Mock upload service
vi.mock('@/service/base', () => ({
upload: vi.fn().mockResolvedValue({ id: 'uploaded-file-id' }),
}))
// Mock next/navigation
vi.mock('next/navigation', () => ({
useParams: () => ({ datasetId: 'mock-dataset-id' }),
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/datasets/mock-dataset-id',
}))
// Mock pipeline service hooks
vi.mock('@/service/use-pipeline', () => ({
useNotionWorkspaces: vi.fn(() => ({
data: [],
isLoading: false,
})),
useNotionPages: vi.fn(() => ({
data: { pages: [] },
isLoading: false,
})),
useDataSourceList: vi.fn(() => ({
data: [
{
type: 'local_file',
declaration: {
identity: {
name: 'Local File',
icon: '/icons/local-file.svg',
},
},
},
],
isSuccess: true,
isLoading: false,
})),
useCrawlResult: vi.fn(() => ({
data: { data: [] },
isLoading: false,
})),
useSupportedOauth: vi.fn(() => ({
data: [],
isLoading: false,
})),
useOnlineDriveCredentialList: vi.fn(() => ({
data: [],
isLoading: false,
})),
useOnlineDriveFileList: vi.fn(() => ({
data: { data: [] },
isLoading: false,
})),
}))
describe('StepOneContent', () => {
const mockDatasource: Datasource = {
nodeId: 'test-node-id',
nodeData: {
type: 'data-source',
fileExtensions: ['txt', 'pdf'],
title: 'Test Data Source',
desc: 'Test description',
} as unknown as DataSourceNodeType,
}
const mockPipelineNodes: Node<DataSourceNodeType>[] = [
{
id: 'node-1',
data: {
type: 'data-source',
title: 'Node 1',
desc: 'Description 1',
} as unknown as DataSourceNodeType,
} as Node<DataSourceNodeType>,
{
id: 'node-2',
data: {
type: 'data-source',
title: 'Node 2',
desc: 'Description 2',
} as unknown as DataSourceNodeType,
} as Node<DataSourceNodeType>,
]
const defaultProps = {
datasource: mockDatasource,
datasourceType: DatasourceType.localFile,
pipelineNodes: mockPipelineNodes,
supportBatchUpload: true,
localFileListLength: 0,
isShowVectorSpaceFull: false,
showSelect: false,
totalOptions: 10,
selectedOptions: 5,
tip: 'Test tip',
nextBtnDisabled: false,
onSelectDataSource: vi.fn(),
onCredentialChange: vi.fn(),
onSelectAll: vi.fn(),
onNextStep: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<StepOneContent {...defaultProps} />)
expect(container.querySelector('.flex.flex-col')).toBeInTheDocument()
})
it('should render DataSourceOptions component', () => {
render(<StepOneContent {...defaultProps} />)
// DataSourceOptions renders option cards
expect(screen.getByText('Local File')).toBeInTheDocument()
})
it('should render Actions component with next button', () => {
render(<StepOneContent {...defaultProps} />)
// Actions component renders a next step button (uses i18n key)
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
expect(nextButton).toBeInTheDocument()
})
})
describe('Conditional Rendering - DatasourceType', () => {
it('should render LocalFile component when datasourceType is localFile', () => {
render(<StepOneContent {...defaultProps} datasourceType={DatasourceType.localFile} />)
expect(screen.getByTestId('local-file')).toBeInTheDocument()
})
it('should render OnlineDocuments component when datasourceType is onlineDocument', () => {
render(<StepOneContent {...defaultProps} datasourceType={DatasourceType.onlineDocument} />)
expect(screen.getByTestId('online-documents')).toBeInTheDocument()
})
it('should render WebsiteCrawl component when datasourceType is websiteCrawl', () => {
render(<StepOneContent {...defaultProps} datasourceType={DatasourceType.websiteCrawl} />)
expect(screen.getByTestId('website-crawl')).toBeInTheDocument()
})
it('should render OnlineDrive component when datasourceType is onlineDrive', () => {
render(<StepOneContent {...defaultProps} datasourceType={DatasourceType.onlineDrive} />)
expect(screen.getByTestId('online-drive')).toBeInTheDocument()
})
it('should not render data source component when datasourceType is undefined', () => {
const { container } = render(<StepOneContent {...defaultProps} datasourceType={undefined} />)
expect(container.querySelector('.flex.flex-col')).toBeInTheDocument()
expect(screen.queryByTestId('local-file')).not.toBeInTheDocument()
})
})
describe('Conditional Rendering - VectorSpaceFull', () => {
it('should render VectorSpaceFull when isShowVectorSpaceFull is true', () => {
render(<StepOneContent {...defaultProps} isShowVectorSpaceFull={true} />)
expect(screen.getByTestId('vector-space-full')).toBeInTheDocument()
})
it('should not render VectorSpaceFull when isShowVectorSpaceFull is false', () => {
render(<StepOneContent {...defaultProps} isShowVectorSpaceFull={false} />)
expect(screen.queryByTestId('vector-space-full')).not.toBeInTheDocument()
})
})
describe('Conditional Rendering - UpgradeCard', () => {
it('should render UpgradeCard when batch upload not supported and has local files', () => {
render(
<StepOneContent
{...defaultProps}
supportBatchUpload={false}
datasourceType={DatasourceType.localFile}
localFileListLength={3}
/>,
)
// UpgradeCard contains an upgrade button
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
})
it('should not render UpgradeCard when batch upload is supported', () => {
render(
<StepOneContent
{...defaultProps}
supportBatchUpload={true}
datasourceType={DatasourceType.localFile}
localFileListLength={3}
/>,
)
// The upgrade card should not be present
const upgradeCard = screen.queryByText(/upload multiple files/i)
expect(upgradeCard).not.toBeInTheDocument()
})
it('should not render UpgradeCard when datasourceType is not localFile', () => {
render(
<StepOneContent
{...defaultProps}
supportBatchUpload={false}
datasourceType={undefined}
localFileListLength={3}
/>,
)
expect(screen.queryByTestId('upgrade-btn')).not.toBeInTheDocument()
})
it('should not render UpgradeCard when localFileListLength is 0', () => {
render(
<StepOneContent
{...defaultProps}
supportBatchUpload={false}
datasourceType={DatasourceType.localFile}
localFileListLength={0}
/>,
)
expect(screen.queryByTestId('upgrade-btn')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onNextStep when next button is clicked', () => {
const onNextStep = vi.fn()
render(<StepOneContent {...defaultProps} onNextStep={onNextStep} />)
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
nextButton.click()
expect(onNextStep).toHaveBeenCalledTimes(1)
})
it('should disable next button when nextBtnDisabled is true', () => {
render(<StepOneContent {...defaultProps} nextBtnDisabled={true} />)
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
expect(nextButton).toBeDisabled()
})
})
describe('Edge Cases', () => {
it('should handle undefined datasource when datasourceType is undefined', () => {
const { container } = render(
<StepOneContent {...defaultProps} datasource={undefined} datasourceType={undefined} />,
)
expect(container.querySelector('.flex.flex-col')).toBeInTheDocument()
})
it('should handle empty pipelineNodes array', () => {
render(<StepOneContent {...defaultProps} pipelineNodes={[]} />)
// Should still render but DataSourceOptions may show no options
const { container } = render(<StepOneContent {...defaultProps} pipelineNodes={[]} />)
expect(container.querySelector('.flex.flex-col')).toBeInTheDocument()
})
it('should handle undefined totalOptions', () => {
render(<StepOneContent {...defaultProps} totalOptions={undefined} />)
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
expect(nextButton).toBeInTheDocument()
})
it('should handle undefined selectedOptions', () => {
render(<StepOneContent {...defaultProps} selectedOptions={undefined} />)
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
expect(nextButton).toBeInTheDocument()
})
it('should handle empty tip', () => {
render(<StepOneContent {...defaultProps} tip="" />)
const nextButton = screen.getByRole('button', { name: /datasetCreation\.stepOne\.button/i })
expect(nextButton).toBeInTheDocument()
})
})
})

View File

@ -1,97 +0,0 @@
import type { InitialDocumentDetail } from '@/models/pipeline'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import StepThreeContent from './step-three-content'
// Mock context hooks used by Processing component
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: vi.fn((selector: (state: unknown) => unknown) => {
const mockState = {
dataset: {
id: 'mock-dataset-id',
indexing_technique: 'high_quality',
retrieval_model_dict: {
search_method: 'semantic_search',
},
},
}
return selector(mockState)
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
// Mock EmbeddingProcess component as it has complex dependencies
vi.mock('../processing/embedding-process', () => ({
default: ({ datasetId, batchId, documents }: {
datasetId: string
batchId: string
documents: InitialDocumentDetail[]
}) => (
<div data-testid="embedding-process">
<span data-testid="dataset-id">{datasetId}</span>
<span data-testid="batch-id">{batchId}</span>
<span data-testid="documents-count">{documents.length}</span>
</div>
),
}))
describe('StepThreeContent', () => {
const mockDocuments: InitialDocumentDetail[] = [
{ id: 'doc1', name: 'Document 1' } as InitialDocumentDetail,
{ id: 'doc2', name: 'Document 2' } as InitialDocumentDetail,
]
const defaultProps = {
batchId: 'test-batch-id',
documents: mockDocuments,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<StepThreeContent {...defaultProps} />)
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
it('should render Processing component', () => {
render(<StepThreeContent {...defaultProps} />)
expect(screen.getByTestId('embedding-process')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass batchId to Processing component', () => {
render(<StepThreeContent {...defaultProps} />)
expect(screen.getByTestId('batch-id')).toHaveTextContent('test-batch-id')
})
it('should pass documents to Processing component', () => {
render(<StepThreeContent {...defaultProps} />)
expect(screen.getByTestId('documents-count')).toHaveTextContent('2')
})
it('should handle empty documents array', () => {
render(<StepThreeContent batchId="test-batch-id" documents={[]} />)
expect(screen.getByTestId('documents-count')).toHaveTextContent('0')
})
})
describe('Edge Cases', () => {
it('should render with different batchId', () => {
render(<StepThreeContent batchId="another-batch-id" documents={mockDocuments} />)
expect(screen.getByTestId('batch-id')).toHaveTextContent('another-batch-id')
})
it('should render with single document', () => {
const singleDocument = [mockDocuments[0]]
render(<StepThreeContent batchId="test-batch-id" documents={singleDocument} />)
expect(screen.getByTestId('documents-count')).toHaveTextContent('1')
})
})
})

View File

@ -1,136 +0,0 @@
import type { RefObject } from 'react'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import StepTwoContent from './step-two-content'
// Mock ProcessDocuments component as it has complex hook dependencies
vi.mock('../process-documents', () => ({
default: vi.fn().mockImplementation(({
dataSourceNodeId,
isRunning,
onProcess,
onPreview,
onSubmit,
onBack,
}: {
dataSourceNodeId: string
isRunning: boolean
onProcess: () => void
onPreview: () => void
onSubmit: (data: Record<string, unknown>) => void
onBack: () => void
}) => (
<div data-testid="process-documents">
<span data-testid="data-source-node-id">{dataSourceNodeId}</span>
<span data-testid="is-running">{String(isRunning)}</span>
<button data-testid="process-btn" onClick={onProcess}>Process</button>
<button data-testid="preview-btn" onClick={onPreview}>Preview</button>
<button data-testid="submit-btn" onClick={() => onSubmit({ key: 'value' })}>Submit</button>
<button data-testid="back-btn" onClick={onBack}>Back</button>
</div>
)),
}))
describe('StepTwoContent', () => {
const mockFormRef: RefObject<{ submit: () => void } | null> = { current: null }
const defaultProps = {
formRef: mockFormRef,
dataSourceNodeId: 'test-node-id',
isRunning: false,
onProcess: vi.fn(),
onPreview: vi.fn(),
onSubmit: vi.fn(),
onBack: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<StepTwoContent {...defaultProps} />)
expect(screen.getByTestId('process-documents')).toBeInTheDocument()
})
it('should render ProcessDocuments component', () => {
render(<StepTwoContent {...defaultProps} />)
expect(screen.getByTestId('process-documents')).toBeInTheDocument()
})
})
describe('Props', () => {
it('should pass dataSourceNodeId to ProcessDocuments', () => {
render(<StepTwoContent {...defaultProps} />)
expect(screen.getByTestId('data-source-node-id')).toHaveTextContent('test-node-id')
})
it('should pass isRunning false to ProcessDocuments', () => {
render(<StepTwoContent {...defaultProps} isRunning={false} />)
expect(screen.getByTestId('is-running')).toHaveTextContent('false')
})
it('should pass isRunning true to ProcessDocuments', () => {
render(<StepTwoContent {...defaultProps} isRunning={true} />)
expect(screen.getByTestId('is-running')).toHaveTextContent('true')
})
it('should pass different dataSourceNodeId', () => {
render(<StepTwoContent {...defaultProps} dataSourceNodeId="different-node-id" />)
expect(screen.getByTestId('data-source-node-id')).toHaveTextContent('different-node-id')
})
})
describe('User Interactions', () => {
it('should call onProcess when process button is clicked', () => {
const onProcess = vi.fn()
render(<StepTwoContent {...defaultProps} onProcess={onProcess} />)
screen.getByTestId('process-btn').click()
expect(onProcess).toHaveBeenCalledTimes(1)
})
it('should call onPreview when preview button is clicked', () => {
const onPreview = vi.fn()
render(<StepTwoContent {...defaultProps} onPreview={onPreview} />)
screen.getByTestId('preview-btn').click()
expect(onPreview).toHaveBeenCalledTimes(1)
})
it('should call onSubmit when submit button is clicked', () => {
const onSubmit = vi.fn()
render(<StepTwoContent {...defaultProps} onSubmit={onSubmit} />)
screen.getByTestId('submit-btn').click()
expect(onSubmit).toHaveBeenCalledTimes(1)
expect(onSubmit).toHaveBeenCalledWith({ key: 'value' })
})
it('should call onBack when back button is clicked', () => {
const onBack = vi.fn()
render(<StepTwoContent {...defaultProps} onBack={onBack} />)
screen.getByTestId('back-btn').click()
expect(onBack).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle empty dataSourceNodeId', () => {
render(<StepTwoContent {...defaultProps} dataSourceNodeId="" />)
expect(screen.getByTestId('data-source-node-id')).toHaveTextContent('')
})
it('should handle null formRef', () => {
const nullRef = { current: null }
render(<StepTwoContent {...defaultProps} formRef={nullRef} />)
expect(screen.getByTestId('process-documents')).toBeInTheDocument()
})
})
})

View File

@ -1,243 +0,0 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LanguagesSupported } from '@/i18n-config/language'
import { ChunkingMode } from '@/models/datasets'
import CSVDownload from './csv-downloader'
// Mock useLocale
let mockLocale = LanguagesSupported[0] // en-US
vi.mock('@/context/i18n', () => ({
useLocale: () => mockLocale,
}))
// Mock react-papaparse
const MockCSVDownloader = ({ children, data, filename, type }: { children: ReactNode, data: unknown, filename: string, type: string }) => (
<div
data-testid="csv-downloader-link"
data-filename={filename}
data-type={type}
data-data={JSON.stringify(data)}
>
{children}
</div>
)
vi.mock('react-papaparse', () => ({
useCSVDownloader: () => ({
CSVDownloader: MockCSVDownloader,
Type: { Link: 'link' },
}),
}))
describe('CSVDownloader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLocale = LanguagesSupported[0] // Reset to English
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render structure title', () => {
// Arrange & Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert - i18n key format
expect(screen.getByText(/csvStructureTitle/i)).toBeInTheDocument()
})
it('should render download template link', () => {
// Arrange & Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
expect(screen.getByTestId('csv-downloader-link')).toBeInTheDocument()
expect(screen.getByText(/list\.batchModal\.template/i)).toBeInTheDocument()
})
})
// Table structure for QA mode
describe('QA Mode Table', () => {
it('should render QA table with question and answer columns when docForm is qa', () => {
// Arrange & Act
render(<CSVDownload docForm={ChunkingMode.qa} />)
// Assert - Check for question/answer headers
const questionHeaders = screen.getAllByText(/list\.batchModal\.question/i)
const answerHeaders = screen.getAllByText(/list\.batchModal\.answer/i)
expect(questionHeaders.length).toBeGreaterThan(0)
expect(answerHeaders.length).toBeGreaterThan(0)
})
it('should render two data rows for QA mode', () => {
// Arrange & Act
const { container } = render(<CSVDownload docForm={ChunkingMode.qa} />)
// Assert
const tbody = container.querySelector('tbody')
expect(tbody).toBeInTheDocument()
const rows = tbody?.querySelectorAll('tr')
expect(rows?.length).toBe(2)
})
})
// Table structure for Text mode
describe('Text Mode Table', () => {
it('should render text table with content column when docForm is text', () => {
// Arrange & Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert - Check for content header
expect(screen.getByText(/list\.batchModal\.contentTitle/i)).toBeInTheDocument()
})
it('should not render question/answer columns in text mode', () => {
// Arrange & Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
expect(screen.queryByText(/list\.batchModal\.question/i)).not.toBeInTheDocument()
expect(screen.queryByText(/list\.batchModal\.answer/i)).not.toBeInTheDocument()
})
it('should render two data rows for text mode', () => {
// Arrange & Act
const { container } = render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
const tbody = container.querySelector('tbody')
expect(tbody).toBeInTheDocument()
const rows = tbody?.querySelectorAll('tr')
expect(rows?.length).toBe(2)
})
})
// CSV Template Data
describe('CSV Template Data', () => {
it('should provide English QA template when locale is English and docForm is qa', () => {
// Arrange
mockLocale = LanguagesSupported[0] // en-US
// Act
render(<CSVDownload docForm={ChunkingMode.qa} />)
// Assert
const link = screen.getByTestId('csv-downloader-link')
const data = JSON.parse(link.getAttribute('data-data') || '[]')
expect(data).toEqual([
['question', 'answer'],
['question1', 'answer1'],
['question2', 'answer2'],
])
})
it('should provide English text template when locale is English and docForm is text', () => {
// Arrange
mockLocale = LanguagesSupported[0] // en-US
// Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
const link = screen.getByTestId('csv-downloader-link')
const data = JSON.parse(link.getAttribute('data-data') || '[]')
expect(data).toEqual([
['segment content'],
['content1'],
['content2'],
])
})
it('should provide Chinese QA template when locale is Chinese and docForm is qa', () => {
// Arrange
mockLocale = LanguagesSupported[1] // zh-Hans
// Act
render(<CSVDownload docForm={ChunkingMode.qa} />)
// Assert
const link = screen.getByTestId('csv-downloader-link')
const data = JSON.parse(link.getAttribute('data-data') || '[]')
expect(data).toEqual([
['问题', '答案'],
['问题 1', '答案 1'],
['问题 2', '答案 2'],
])
})
it('should provide Chinese text template when locale is Chinese and docForm is text', () => {
// Arrange
mockLocale = LanguagesSupported[1] // zh-Hans
// Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
const link = screen.getByTestId('csv-downloader-link')
const data = JSON.parse(link.getAttribute('data-data') || '[]')
expect(data).toEqual([
['分段内容'],
['内容 1'],
['内容 2'],
])
})
})
// CSVDownloader props
describe('CSVDownloader Props', () => {
it('should set filename to template', () => {
// Arrange & Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
const link = screen.getByTestId('csv-downloader-link')
expect(link.getAttribute('data-filename')).toBe('template')
})
it('should set type to Link', () => {
// Arrange & Act
render(<CSVDownload docForm={ChunkingMode.text} />)
// Assert
const link = screen.getByTestId('csv-downloader-link')
expect(link.getAttribute('data-type')).toBe('link')
})
})
// Edge cases
describe('Edge Cases', () => {
it('should maintain structure when rerendered with different docForm', () => {
// Arrange
const { rerender } = render(<CSVDownload docForm={ChunkingMode.text} />)
// Act
rerender(<CSVDownload docForm={ChunkingMode.qa} />)
// Assert - should now show QA table
expect(screen.getAllByText(/list\.batchModal\.question/i).length).toBeGreaterThan(0)
})
it('should render correctly for non-English locales', () => {
// Arrange
mockLocale = LanguagesSupported[1] // zh-Hans
// Act
render(<CSVDownload docForm={ChunkingMode.qa} />)
// Assert - Check that Chinese template is used
const link = screen.getByTestId('csv-downloader-link')
const data = JSON.parse(link.getAttribute('data-data') || '[]')
expect(data[0]).toEqual(['问题', '答案'])
})
})
})

View File

@ -1,485 +0,0 @@
import type { ReactNode } from 'react'
import type { CustomFile, FileItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Theme } from '@/types/app'
import CSVUploader from './csv-uploader'
// Mock upload service
const mockUpload = vi.fn()
vi.mock('@/service/base', () => ({
upload: (...args: unknown[]) => mockUpload(...args),
}))
// Mock useFileUploadConfig
vi.mock('@/service/use-common', () => ({
useFileUploadConfig: () => ({
data: { file_size_limit: 15 },
}),
}))
// Mock useTheme
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: Theme.light }),
}))
// Mock ToastContext
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
ToastContext: {
Provider: ({ children }: { children: ReactNode }) => children,
Consumer: ({ children }: { children: (ctx: { notify: typeof mockNotify }) => ReactNode }) => children({ notify: mockNotify }),
},
}))
// Create a mock ToastContext for useContext
vi.mock('use-context-selector', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
useContext: () => ({ notify: mockNotify }),
}
})
describe('CSVUploader', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const defaultProps = {
file: undefined as FileItem | undefined,
updateFile: vi.fn(),
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<CSVUploader {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render upload area when no file is present', () => {
// Arrange & Act
render(<CSVUploader {...defaultProps} />)
// Assert
expect(screen.getByText(/list\.batchModal\.csvUploadTitle/i)).toBeInTheDocument()
expect(screen.getByText(/list\.batchModal\.browse/i)).toBeInTheDocument()
})
it('should render hidden file input', () => {
// Arrange & Act
const { container } = render(<CSVUploader {...defaultProps} />)
// Assert
const fileInput = container.querySelector('input[type="file"]')
expect(fileInput).toBeInTheDocument()
expect(fileInput).toHaveStyle({ display: 'none' })
})
it('should accept only CSV files', () => {
// Arrange & Act
const { container } = render(<CSVUploader {...defaultProps} />)
// Assert
const fileInput = container.querySelector('input[type="file"]')
expect(fileInput).toHaveAttribute('accept', '.csv')
})
})
// File display tests
describe('File Display', () => {
it('should display file info when file is present', () => {
// Arrange
const mockFile: FileItem = {
fileID: 'file-1',
file: new File(['content'], 'test-file.csv', { type: 'text/csv' }) as CustomFile,
progress: 100,
}
// Act
render(<CSVUploader {...defaultProps} file={mockFile} />)
// Assert
expect(screen.getByText('test-file')).toBeInTheDocument()
expect(screen.getByText('.csv')).toBeInTheDocument()
})
it('should not show upload area when file is present', () => {
// Arrange
const mockFile: FileItem = {
fileID: 'file-1',
file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
progress: 100,
}
// Act
render(<CSVUploader {...defaultProps} file={mockFile} />)
// Assert
expect(screen.queryByText(/list\.batchModal\.csvUploadTitle/i)).not.toBeInTheDocument()
})
it('should show change button when file is present', () => {
// Arrange
const mockFile: FileItem = {
fileID: 'file-1',
file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
progress: 100,
}
// Act
render(<CSVUploader {...defaultProps} file={mockFile} />)
// Assert
expect(screen.getByText(/stepOne\.uploader\.change/i)).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should trigger file input click when browse is clicked', () => {
// Arrange
const { container } = render(<CSVUploader {...defaultProps} />)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const clickSpy = vi.spyOn(fileInput, 'click')
// Act
fireEvent.click(screen.getByText(/list\.batchModal\.browse/i))
// Assert
expect(clickSpy).toHaveBeenCalled()
})
it('should call updateFile when file is selected', async () => {
// Arrange
const mockUpdateFile = vi.fn()
mockUpload.mockResolvedValueOnce({ id: 'uploaded-id' })
const { container } = render(
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Assert
await waitFor(() => {
expect(mockUpdateFile).toHaveBeenCalled()
})
})
it('should call updateFile with undefined when remove is clicked', () => {
// Arrange
const mockUpdateFile = vi.fn()
const mockFile: FileItem = {
fileID: 'file-1',
file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
progress: 100,
}
const { container } = render(
<CSVUploader {...defaultProps} file={mockFile} updateFile={mockUpdateFile} />,
)
// Act
const deleteButton = container.querySelector('.cursor-pointer')
if (deleteButton)
fireEvent.click(deleteButton)
// Assert
expect(mockUpdateFile).toHaveBeenCalledWith()
})
})
// Validation tests
describe('Validation', () => {
it('should show error for non-CSV files', () => {
// Arrange
const { container } = render(<CSVUploader {...defaultProps} />)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const testFile = new File(['content'], 'test.txt', { type: 'text/plain' })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Assert
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
it('should show error for files exceeding size limit', () => {
// Arrange
const { container } = render(<CSVUploader {...defaultProps} />)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
// Create a mock file with a large size (16MB) without actually creating the data
const testFile = new File(['test'], 'large.csv', { type: 'text/csv' })
Object.defineProperty(testFile, 'size', { value: 16 * 1024 * 1024 })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Assert
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
})
// Upload progress tests
describe('Upload Progress', () => {
it('should show progress indicator when upload is in progress', () => {
// Arrange
const mockFile: FileItem = {
fileID: 'file-1',
file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
progress: 50,
}
// Act
const { container } = render(<CSVUploader {...defaultProps} file={mockFile} />)
// Assert - SimplePieChart should be rendered for progress 0-99
// The pie chart would be in the hidden group element
expect(container.querySelector('.group')).toBeInTheDocument()
})
it('should not show progress for completed uploads', () => {
// Arrange
const mockFile: FileItem = {
fileID: 'file-1',
file: new File(['content'], 'test.csv', { type: 'text/csv' }) as CustomFile,
progress: 100,
}
// Act
render(<CSVUploader {...defaultProps} file={mockFile} />)
// Assert - File name should be displayed
expect(screen.getByText('test')).toBeInTheDocument()
})
})
// Props tests
describe('Props', () => {
it('should call updateFile prop when provided', async () => {
// Arrange
const mockUpdateFile = vi.fn()
mockUpload.mockResolvedValueOnce({ id: 'test-id' })
const { container } = render(
<CSVUploader file={undefined} updateFile={mockUpdateFile} />,
)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Assert
await waitFor(() => {
expect(mockUpdateFile).toHaveBeenCalled()
})
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty file list', () => {
// Arrange
const mockUpdateFile = vi.fn()
const { container } = render(
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
// Act
fireEvent.change(fileInput, { target: { files: [] } })
// Assert
expect(mockUpdateFile).not.toHaveBeenCalled()
})
it('should handle null file', () => {
// Arrange
const mockUpdateFile = vi.fn()
const { container } = render(
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
// Act
fireEvent.change(fileInput, { target: { files: null } })
// Assert
expect(mockUpdateFile).not.toHaveBeenCalled()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<CSVUploader {...defaultProps} />)
// Act
const mockFile: FileItem = {
fileID: 'file-1',
file: new File(['content'], 'updated.csv', { type: 'text/csv' }) as CustomFile,
progress: 100,
}
rerender(<CSVUploader {...defaultProps} file={mockFile} />)
// Assert
expect(screen.getByText('updated')).toBeInTheDocument()
})
it('should handle upload error', async () => {
// Arrange
const mockUpdateFile = vi.fn()
mockUpload.mockRejectedValueOnce(new Error('Upload failed'))
const { container } = render(
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
})
it('should handle file without extension', () => {
// Arrange
const { container } = render(<CSVUploader {...defaultProps} />)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const testFile = new File(['content'], 'noextension', { type: 'text/plain' })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Assert
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
})
// Drag and drop tests
// Note: Native drag and drop events use addEventListener which is set up in useEffect.
// Testing these requires triggering native DOM events on the actual dropRef element.
describe('Drag and Drop', () => {
it('should render drop zone element', () => {
// Arrange & Act
const { container } = render(<CSVUploader {...defaultProps} />)
// Assert - drop zone should exist for drag and drop
const dropZone = container.querySelector('div > div')
expect(dropZone).toBeInTheDocument()
})
it('should have drag overlay element that can appear during drag', () => {
// Arrange & Act
const { container } = render(<CSVUploader {...defaultProps} />)
// Assert - component structure supports dragging
expect(container.querySelector('div')).toBeInTheDocument()
})
})
// Upload progress callback tests
describe('Upload Progress Callbacks', () => {
it('should update progress during file upload', async () => {
// Arrange
const mockUpdateFile = vi.fn()
let progressCallback: ((e: ProgressEvent) => void) | undefined
mockUpload.mockImplementation(({ onprogress }) => {
progressCallback = onprogress
return Promise.resolve({ id: 'uploaded-id' })
})
const { container } = render(
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Simulate progress event
if (progressCallback) {
const progressEvent = new ProgressEvent('progress', {
lengthComputable: true,
loaded: 50,
total: 100,
})
progressCallback(progressEvent)
}
// Assert
await waitFor(() => {
expect(mockUpdateFile).toHaveBeenCalledWith(
expect.objectContaining({
progress: expect.any(Number),
}),
)
})
})
it('should handle progress event with lengthComputable false', async () => {
// Arrange
const mockUpdateFile = vi.fn()
let progressCallback: ((e: ProgressEvent) => void) | undefined
mockUpload.mockImplementation(({ onprogress }) => {
progressCallback = onprogress
return Promise.resolve({ id: 'uploaded-id' })
})
const { container } = render(
<CSVUploader {...defaultProps} updateFile={mockUpdateFile} />,
)
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement
const testFile = new File(['content'], 'test.csv', { type: 'text/csv' })
// Act
fireEvent.change(fileInput, { target: { files: [testFile] } })
// Simulate progress event with lengthComputable false
if (progressCallback) {
const progressEvent = new ProgressEvent('progress', {
lengthComputable: false,
loaded: 50,
total: 100,
})
progressCallback(progressEvent)
}
// Assert - should complete upload without progress updates when lengthComputable is false
await waitFor(() => {
expect(mockUpdateFile).toHaveBeenCalled()
})
})
})
})

View File

@ -1,232 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import BatchModal from './index'
// Mock child components
vi.mock('./csv-downloader', () => ({
default: ({ docForm }: { docForm: ChunkingMode }) => (
<div data-testid="csv-downloader" data-doc-form={docForm}>
CSV Downloader
</div>
),
}))
vi.mock('./csv-uploader', () => ({
default: ({ file, updateFile }: { file: { file?: { id: string } } | undefined, updateFile: (file: { file: { id: string } } | undefined) => void }) => (
<div data-testid="csv-uploader">
<button
data-testid="upload-btn"
onClick={() => updateFile({ file: { id: 'test-file-id' } })}
>
Upload
</button>
<button
data-testid="clear-btn"
onClick={() => updateFile(undefined)}
>
Clear
</button>
{file && <span data-testid="file-info">{file.file?.id}</span>}
</div>
),
}))
describe('BatchModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const defaultProps = {
isShow: true,
docForm: ChunkingMode.text,
onCancel: vi.fn(),
onConfirm: vi.fn(),
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing when isShow is true', () => {
// Arrange & Act
render(<BatchModal {...defaultProps} />)
// Assert
expect(screen.getByText(/list\.batchModal\.title/i)).toBeInTheDocument()
})
it('should not render content when isShow is false', () => {
// Arrange & Act
render(<BatchModal {...defaultProps} isShow={false} />)
// Assert - Modal is closed
expect(screen.queryByText(/list\.batchModal\.title/i)).not.toBeInTheDocument()
})
it('should render CSVDownloader component', () => {
// Arrange & Act
render(<BatchModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('csv-downloader')).toBeInTheDocument()
})
it('should render CSVUploader component', () => {
// Arrange & Act
render(<BatchModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('csv-uploader')).toBeInTheDocument()
})
it('should render cancel and run buttons', () => {
// Arrange & Act
render(<BatchModal {...defaultProps} />)
// Assert
expect(screen.getByText(/list\.batchModal\.cancel/i)).toBeInTheDocument()
expect(screen.getByText(/list\.batchModal\.run/i)).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onCancel when cancel button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
render(<BatchModal {...defaultProps} onCancel={mockOnCancel} />)
// Act
fireEvent.click(screen.getByText(/list\.batchModal\.cancel/i))
// Assert
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('should disable run button when no file is uploaded', () => {
// Arrange & Act
render(<BatchModal {...defaultProps} />)
// Assert
const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
expect(runButton).toBeDisabled()
})
it('should enable run button after file is uploaded', async () => {
// Arrange
render(<BatchModal {...defaultProps} />)
// Act
fireEvent.click(screen.getByTestId('upload-btn'))
// Assert
await waitFor(() => {
const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
expect(runButton).not.toBeDisabled()
})
})
it('should call onConfirm with file when run button is clicked', async () => {
// Arrange
const mockOnConfirm = vi.fn()
const mockOnCancel = vi.fn()
render(<BatchModal {...defaultProps} onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
// Act - upload file first
fireEvent.click(screen.getByTestId('upload-btn'))
await waitFor(() => {
const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
expect(runButton).not.toBeDisabled()
})
// Act - click run
fireEvent.click(screen.getByText(/list\.batchModal\.run/i))
// Assert
expect(mockOnCancel).toHaveBeenCalledTimes(1)
expect(mockOnConfirm).toHaveBeenCalledWith({ file: { id: 'test-file-id' } })
})
})
// Props tests
describe('Props', () => {
it('should pass docForm to CSVDownloader', () => {
// Arrange & Act
render(<BatchModal {...defaultProps} docForm={ChunkingMode.qa} />)
// Assert
expect(screen.getByTestId('csv-downloader').getAttribute('data-doc-form')).toBe(ChunkingMode.qa)
})
})
// State reset tests
describe('State Reset', () => {
it('should reset file when modal is closed and reopened', async () => {
// Arrange
const { rerender } = render(<BatchModal {...defaultProps} />)
// Upload a file
fireEvent.click(screen.getByTestId('upload-btn'))
await waitFor(() => {
expect(screen.getByTestId('file-info')).toBeInTheDocument()
})
// Close modal
rerender(<BatchModal {...defaultProps} isShow={false} />)
// Reopen modal
rerender(<BatchModal {...defaultProps} isShow={true} />)
// Assert - file should be cleared
expect(screen.queryByTestId('file-info')).not.toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should not call onConfirm when no file is present', () => {
// Arrange
const mockOnConfirm = vi.fn()
render(<BatchModal {...defaultProps} onConfirm={mockOnConfirm} />)
// Act - try to click run (should be disabled)
const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
if (runButton)
fireEvent.click(runButton)
// Assert
expect(mockOnConfirm).not.toHaveBeenCalled()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<BatchModal {...defaultProps} />)
// Act
rerender(<BatchModal {...defaultProps} docForm={ChunkingMode.qa} />)
// Assert
expect(screen.getByText(/list\.batchModal\.title/i)).toBeInTheDocument()
})
it('should handle file cleared after upload', async () => {
// Arrange
const mockOnConfirm = vi.fn()
render(<BatchModal {...defaultProps} onConfirm={mockOnConfirm} />)
// Upload a file first
fireEvent.click(screen.getByTestId('upload-btn'))
await waitFor(() => {
expect(screen.getByTestId('file-info')).toBeInTheDocument()
})
// Clear the file
fireEvent.click(screen.getByTestId('clear-btn'))
// Assert - run button should be disabled again
const runButton = screen.getByText(/list\.batchModal\.run/i).closest('button')
expect(runButton).toBeDisabled()
})
})
})

View File

@ -1,330 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import ChildSegmentDetail from './child-segment-detail'
// Mock segment list context
let mockFullScreen = false
const mockToggleFullScreen = vi.fn()
vi.mock('./index', () => ({
useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => {
const state = {
fullScreen: mockFullScreen,
toggleFullScreen: mockToggleFullScreen,
}
return selector(state)
},
}))
// Mock event emitter context
let mockSubscriptionCallback: ((v: string) => void) | null = null
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: (callback: (v: string) => void) => {
mockSubscriptionCallback = callback
},
},
}),
}))
// Mock child components
vi.mock('./common/action-buttons', () => ({
default: ({ handleCancel, handleSave, loading, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, isChildChunk?: boolean }) => (
<div data-testid="action-buttons">
<button onClick={handleCancel} data-testid="cancel-btn">Cancel</button>
<button onClick={handleSave} disabled={loading} data-testid="save-btn">Save</button>
<span data-testid="is-child-chunk">{isChildChunk ? 'true' : 'false'}</span>
</div>
),
}))
vi.mock('./common/chunk-content', () => ({
default: ({ question, onQuestionChange, isEditMode }: { question: string, onQuestionChange: (v: string) => void, isEditMode: boolean }) => (
<div data-testid="chunk-content">
<input
data-testid="content-input"
value={question}
onChange={e => onQuestionChange(e.target.value)}
/>
<span data-testid="edit-mode">{isEditMode ? 'editing' : 'viewing'}</span>
</div>
),
}))
vi.mock('./common/dot', () => ({
default: () => <span data-testid="dot"></span>,
}))
vi.mock('./common/segment-index-tag', () => ({
SegmentIndexTag: ({ positionId, labelPrefix }: { positionId?: string, labelPrefix?: string }) => (
<span data-testid="segment-index-tag">
{labelPrefix}
{' '}
{positionId}
</span>
),
}))
describe('ChildSegmentDetail', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFullScreen = false
mockSubscriptionCallback = null
})
const defaultChildChunkInfo = {
id: 'child-chunk-1',
content: 'Test content',
position: 1,
updated_at: 1609459200, // 2021-01-01
}
const defaultProps = {
chunkId: 'chunk-1',
childChunkInfo: defaultChildChunkInfo,
onUpdate: vi.fn(),
onCancel: vi.fn(),
docForm: ChunkingMode.text,
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render edit child chunk title', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByText(/segment\.editChildChunk/i)).toBeInTheDocument()
})
it('should render chunk content component', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
})
it('should render segment index tag', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument()
})
it('should render word count', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByText(/segment\.characters/i)).toBeInTheDocument()
})
it('should render edit time', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByText(/segment\.editedAt/i)).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onCancel when close button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
const { container } = render(
<ChildSegmentDetail {...defaultProps} onCancel={mockOnCancel} />,
)
// Act
const closeButtons = container.querySelectorAll('.cursor-pointer')
if (closeButtons.length > 1)
fireEvent.click(closeButtons[1])
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call toggleFullScreen when expand button is clicked', () => {
// Arrange
const { container } = render(<ChildSegmentDetail {...defaultProps} />)
// Act
const expandButtons = container.querySelectorAll('.cursor-pointer')
if (expandButtons.length > 0)
fireEvent.click(expandButtons[0])
// Assert
expect(mockToggleFullScreen).toHaveBeenCalled()
})
it('should call onUpdate when save is clicked', () => {
// Arrange
const mockOnUpdate = vi.fn()
render(<ChildSegmentDetail {...defaultProps} onUpdate={mockOnUpdate} />)
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
expect(mockOnUpdate).toHaveBeenCalledWith(
'chunk-1',
'child-chunk-1',
'Test content',
)
})
it('should update content when input changes', () => {
// Arrange
render(<ChildSegmentDetail {...defaultProps} />)
// Act
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Updated content' },
})
// Assert
expect(screen.getByTestId('content-input')).toHaveValue('Updated content')
})
})
// Full screen mode
describe('Full Screen Mode', () => {
it('should show action buttons in header when fullScreen is true', () => {
// Arrange
mockFullScreen = true
// Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
})
it('should not show footer action buttons when fullScreen is true', () => {
// Arrange
mockFullScreen = true
// Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert - footer with border-t-divider-subtle should not exist
const actionButtons = screen.getAllByTestId('action-buttons')
// Only one action buttons set should exist in fullScreen mode
expect(actionButtons.length).toBe(1)
})
it('should show footer action buttons when fullScreen is false', () => {
// Arrange
mockFullScreen = false
// Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
})
})
// Props
describe('Props', () => {
it('should pass isChildChunk true to ActionButtons', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('is-child-chunk')).toHaveTextContent('true')
})
it('should pass isEditMode true to ChunkContent', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert
expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing')
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle undefined childChunkInfo', () => {
// Arrange & Act
const { container } = render(
<ChildSegmentDetail {...defaultProps} childChunkInfo={undefined} />,
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should handle empty content', () => {
// Arrange
const emptyChildChunkInfo = { ...defaultChildChunkInfo, content: '' }
// Act
render(<ChildSegmentDetail {...defaultProps} childChunkInfo={emptyChildChunkInfo} />)
// Assert
expect(screen.getByTestId('content-input')).toHaveValue('')
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<ChildSegmentDetail {...defaultProps} />)
// Act
const updatedInfo = { ...defaultChildChunkInfo, content: 'New content' }
rerender(<ChildSegmentDetail {...defaultProps} childChunkInfo={updatedInfo} />)
// Assert
expect(screen.getByTestId('content-input')).toBeInTheDocument()
})
})
// Event subscription tests
describe('Event Subscription', () => {
it('should register event subscription', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert - subscription callback should be registered
expect(mockSubscriptionCallback).not.toBeNull()
})
it('should have save button enabled by default', () => {
// Arrange & Act
render(<ChildSegmentDetail {...defaultProps} />)
// Assert - save button should be enabled initially
expect(screen.getByTestId('save-btn')).not.toBeDisabled()
})
})
// Cancel behavior
describe('Cancel Behavior', () => {
it('should call onCancel when cancel button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
render(<ChildSegmentDetail {...defaultProps} onCancel={mockOnCancel} />)
// Act
fireEvent.click(screen.getByTestId('cancel-btn'))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
})
})

View File

@ -1,430 +1,499 @@
import type { ChildChunkDetail } from '@/models/datasets'
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
import type { ChildChunkDetail, ChunkingMode, ParentMode } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as React from 'react'
import ChildSegmentList from './child-segment-list'
// ============================================================================
// Hoisted Mocks
// ============================================================================
const {
mockParentMode,
mockCurrChildChunk,
} = vi.hoisted(() => ({
mockParentMode: { current: 'paragraph' as ParentMode },
mockCurrChildChunk: { current: { childChunkInfo: undefined, showModal: false } as { childChunkInfo?: ChildChunkDetail, showModal: boolean } },
}))
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: { count?: number, ns?: string }) => {
if (key === 'segment.childChunks')
return options?.count === 1 ? 'child chunk' : 'child chunks'
if (key === 'segment.searchResults')
return 'search results'
if (key === 'segment.edited')
return 'edited'
if (key === 'operation.add')
return 'Add'
const prefix = options?.ns ? `${options.ns}.` : ''
return `${prefix}${key}`
},
}),
}))
// Mock document context
let mockParentMode = 'paragraph'
vi.mock('../context', () => ({
useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => {
return selector({ parentMode: mockParentMode })
useDocumentContext: (selector: (value: DocumentContextValue) => unknown) => {
const value: DocumentContextValue = {
datasetId: 'test-dataset-id',
documentId: 'test-document-id',
docForm: 'text' as ChunkingMode,
parentMode: mockParentMode.current,
}
return selector(value)
},
}))
// Mock segment list context
let mockCurrChildChunk: { childChunkInfo: { id: string } } | null = null
vi.mock('./index', () => ({
useSegmentListContext: (selector: (state: { currChildChunk: { childChunkInfo: { id: string } } | null }) => unknown) => {
return selector({ currChildChunk: mockCurrChildChunk })
useSegmentListContext: (selector: (value: { currChildChunk: { childChunkInfo?: ChildChunkDetail, showModal: boolean } }) => unknown) => {
return selector({ currChildChunk: mockCurrChildChunk.current })
},
}))
// Mock child components
// Mock skeleton component
vi.mock('./skeleton/full-doc-list-skeleton', () => ({
default: () => <div data-testid="full-doc-list-skeleton">Loading...</div>,
}))
// Mock Empty component
vi.mock('./common/empty', () => ({
default: ({ onClearFilter }: { onClearFilter: () => void }) => (
<div data-testid="empty">
<button onClick={onClearFilter} data-testid="clear-filter-btn">Clear Filter</button>
</div>
),
}))
vi.mock('./skeleton/full-doc-list-skeleton', () => ({
default: () => <div data-testid="full-doc-skeleton">Loading...</div>,
}))
vi.mock('../../../formatted-text/flavours/edit-slice', () => ({
EditSlice: ({
label,
text,
onDelete,
className,
labelClassName,
onClick,
}: {
label: string
text: string
onDelete: () => void
className: string
labelClassName: string
contentClassName: string
labelInnerClassName: string
showDivider: boolean
onClick: (e: React.MouseEvent) => void
offsetOptions: unknown
}) => (
<div data-testid="edit-slice" className={className}>
<span data-testid="slice-label" className={labelClassName}>{label}</span>
<span data-testid="slice-text">{text}</span>
<button data-testid="delete-slice-btn" onClick={onDelete}>Delete</button>
<button data-testid="click-slice-btn" onClick={e => onClick(e)}>Click</button>
<div data-testid="empty-component">
<button onClick={onClearFilter}>Clear Filter</button>
</div>
),
}))
// Mock FormattedText and EditSlice
vi.mock('../../../formatted-text/formatted', () => ({
FormattedText: ({ children, className }: { children: React.ReactNode, className: string }) => (
FormattedText: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="formatted-text" className={className}>{children}</div>
),
}))
vi.mock('../../../formatted-text/flavours/edit-slice', () => ({
EditSlice: ({ label, text, onDelete, onClick, labelClassName, contentClassName }: {
label: string
text: string
onDelete: () => void
onClick: (e: React.MouseEvent) => void
labelClassName?: string
contentClassName?: string
}) => (
<div data-testid="edit-slice" onClick={onClick}>
<span data-testid="edit-slice-label" className={labelClassName}>{label}</span>
<span data-testid="edit-slice-content" className={contentClassName}>{text}</span>
<button
data-testid="delete-button"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
>
Delete
</button>
</div>
),
}))
// ============================================================================
// Test Data Factories
// ============================================================================
const createMockChildChunk = (overrides: Partial<ChildChunkDetail> = {}): ChildChunkDetail => ({
id: `child-${Math.random().toString(36).substr(2, 9)}`,
position: 1,
segment_id: 'segment-1',
content: 'Child chunk content',
word_count: 100,
created_at: 1700000000,
updated_at: 1700000000,
type: 'automatic',
...overrides,
})
// ============================================================================
// Tests
// ============================================================================
describe('ChildSegmentList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockParentMode = 'paragraph'
mockCurrChildChunk = null
})
const createMockChildChunk = (id: string, content: string, edited = false): ChildChunkDetail => ({
id,
content,
position: 1,
word_count: 10,
segment_id: 'seg-1',
created_at: Date.now(),
updated_at: edited ? Date.now() + 1000 : Date.now(),
type: 'automatic',
})
const defaultProps = {
childChunks: [createMockChildChunk('child-1', 'Child content 1')],
childChunks: [] as ChildChunkDetail[],
parentChunkId: 'parent-1',
handleInputChange: vi.fn(),
handleAddNewChildChunk: vi.fn(),
enabled: true,
onDelete: vi.fn(),
onClickSlice: vi.fn(),
total: 1,
inputValue: '',
onClearFilter: vi.fn(),
isLoading: false,
focused: false,
}
// Rendering tests
beforeEach(() => {
vi.clearAllMocks()
mockParentMode.current = 'paragraph'
mockCurrChildChunk.current = { childChunkInfo: undefined, showModal: false }
})
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<ChildSegmentList {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render total count text', () => {
// Arrange & Act
it('should render with empty child chunks', () => {
render(<ChildSegmentList {...defaultProps} />)
// Assert
expect(screen.getByText(/segment\.childChunks/i)).toBeInTheDocument()
expect(screen.getByText(/child chunks/i)).toBeInTheDocument()
})
it('should render add button', () => {
// Arrange & Act
render(<ChildSegmentList {...defaultProps} />)
it('should render child chunks when provided', () => {
const childChunks = [
createMockChildChunk({ id: 'child-1', position: 1, content: 'First chunk' }),
createMockChildChunk({ id: 'child-2', position: 2, content: 'Second chunk' }),
]
// Assert
expect(screen.getByText(/operation\.add/i)).toBeInTheDocument()
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
// In paragraph mode, content is collapsed by default
expect(screen.getByText(/2 child chunks/i)).toBeInTheDocument()
})
it('should render total count correctly with total prop in full-doc mode', () => {
mockParentMode.current = 'full-doc'
const childChunks = [createMockChildChunk()]
// Pass inputValue="" to ensure isSearching is false
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} total={5} isLoading={false} inputValue="" />)
expect(screen.getByText(/5 child chunks/i)).toBeInTheDocument()
})
it('should render loading skeleton in full-doc mode when loading', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
expect(screen.getByTestId('full-doc-list-skeleton')).toBeInTheDocument()
})
it('should not render loading skeleton when not loading', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} isLoading={false} />)
expect(screen.queryByTestId('full-doc-list-skeleton')).not.toBeInTheDocument()
})
})
// Paragraph mode tests
describe('Paragraph Mode', () => {
beforeEach(() => {
mockParentMode = 'paragraph'
mockParentMode.current = 'paragraph'
})
it('should render collapsed by default in paragraph mode', () => {
// Arrange & Act
render(<ChildSegmentList {...defaultProps} />)
it('should show collapse icon in paragraph mode', () => {
const childChunks = [createMockChildChunk()]
// Assert - collapsed icon should be present
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
// Check for collapse/expand behavior
const totalRow = screen.getByText(/1 child chunk/i).closest('div')
expect(totalRow).toBeInTheDocument()
})
it('should toggle collapsed state when clicked', () => {
const childChunks = [createMockChildChunk({ content: 'Test content' })]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
// Initially collapsed in paragraph mode - content should not be visible
expect(screen.queryByTestId('formatted-text')).not.toBeInTheDocument()
})
it('should expand when clicking toggle in paragraph mode', () => {
// Arrange
render(<ChildSegmentList {...defaultProps} />)
// Find and click the toggle area
const toggleArea = screen.getByText(/1 child chunk/i).closest('div')
// Act - click on the collapse toggle
const toggleArea = screen.getByText(/segment\.childChunks/i).closest('div')
// Click to expand
if (toggleArea)
fireEvent.click(toggleArea)
// Assert - child chunks should be visible
// After expansion, content should be visible
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
})
it('should collapse when clicking toggle again', () => {
// Arrange
render(<ChildSegmentList {...defaultProps} />)
// Act - click twice
const toggleArea = screen.getByText(/segment\.childChunks/i).closest('div')
if (toggleArea) {
fireEvent.click(toggleArea)
fireEvent.click(toggleArea)
}
// Assert - child chunks should be hidden
expect(screen.queryByTestId('formatted-text')).not.toBeInTheDocument()
})
})
// Full doc mode tests
describe('Full Doc Mode', () => {
beforeEach(() => {
mockParentMode = 'full-doc'
})
it('should render input field in full-doc mode', () => {
// Arrange & Act
render(<ChildSegmentList {...defaultProps} />)
// Assert
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
})
it('should render child chunks without collapse in full-doc mode', () => {
// Arrange & Act
render(<ChildSegmentList {...defaultProps} />)
// Assert
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
})
it('should call handleInputChange when input changes', () => {
// Arrange
const mockHandleInputChange = vi.fn()
render(<ChildSegmentList {...defaultProps} handleInputChange={mockHandleInputChange} />)
// Act
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'search term' } })
// Assert
expect(mockHandleInputChange).toHaveBeenCalledWith('search term')
})
it('should show search results text when searching', () => {
// Arrange & Act
render(<ChildSegmentList {...defaultProps} inputValue="search" total={5} />)
// Assert
expect(screen.getByText(/segment\.searchResults/i)).toBeInTheDocument()
})
it('should show empty component when no results and searching', () => {
// Arrange & Act
render(<ChildSegmentList {...defaultProps} inputValue="search" childChunks={[]} total={0} />)
// Assert
expect(screen.getByTestId('empty')).toBeInTheDocument()
})
it('should show loading skeleton when isLoading is true', () => {
// Arrange & Act
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
// Assert
expect(screen.getByTestId('full-doc-skeleton')).toBeInTheDocument()
})
it('should handle undefined total in full-doc mode', () => {
// Arrange & Act
const { container } = render(<ChildSegmentList {...defaultProps} total={undefined} />)
// Assert - component should render without crashing
expect(container.firstChild).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call handleAddNewChildChunk when add button is clicked', () => {
// Arrange
mockParentMode = 'full-doc'
const mockHandleAddNewChildChunk = vi.fn()
render(<ChildSegmentList {...defaultProps} handleAddNewChildChunk={mockHandleAddNewChildChunk} />)
// Act
fireEvent.click(screen.getByText(/operation\.add/i))
// Assert
expect(mockHandleAddNewChildChunk).toHaveBeenCalledWith('parent-1')
})
it('should call onDelete when delete button is clicked', () => {
// Arrange
mockParentMode = 'full-doc'
const mockOnDelete = vi.fn()
render(<ChildSegmentList {...defaultProps} onDelete={mockOnDelete} />)
// Act
fireEvent.click(screen.getByTestId('delete-slice-btn'))
// Assert
expect(mockOnDelete).toHaveBeenCalledWith('seg-1', 'child-1')
})
it('should call onClickSlice when slice is clicked', () => {
// Arrange
mockParentMode = 'full-doc'
const mockOnClickSlice = vi.fn()
render(<ChildSegmentList {...defaultProps} onClickSlice={mockOnClickSlice} />)
// Act
fireEvent.click(screen.getByTestId('click-slice-btn'))
// Assert
expect(mockOnClickSlice).toHaveBeenCalledWith(expect.objectContaining({ id: 'child-1' }))
})
it('should call onClearFilter when clear filter button is clicked', () => {
// Arrange
mockParentMode = 'full-doc'
const mockOnClearFilter = vi.fn()
render(<ChildSegmentList {...defaultProps} inputValue="search" childChunks={[]} onClearFilter={mockOnClearFilter} />)
// Act
fireEvent.click(screen.getByTestId('clear-filter-btn'))
// Assert
expect(mockOnClearFilter).toHaveBeenCalled()
})
})
// Focused state
describe('Focused State', () => {
it('should apply focused style when currChildChunk matches', () => {
// Arrange
mockParentMode = 'full-doc'
mockCurrChildChunk = { childChunkInfo: { id: 'child-1' } }
// Act
render(<ChildSegmentList {...defaultProps} />)
// Assert - check for focused class on label
const label = screen.getByTestId('slice-label')
expect(label).toHaveClass('bg-state-accent-solid')
})
it('should not apply focused style when currChildChunk does not match', () => {
// Arrange
mockParentMode = 'full-doc'
mockCurrChildChunk = { childChunkInfo: { id: 'other-child' } }
// Act
render(<ChildSegmentList {...defaultProps} />)
// Assert
const label = screen.getByTestId('slice-label')
expect(label).not.toHaveClass('bg-state-accent-solid')
})
})
// Enabled/Disabled state
describe('Enabled State', () => {
it('should apply opacity when enabled is false', () => {
// Arrange & Act
it('should apply opacity when disabled', () => {
const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} />)
// Assert
const wrapper = container.firstChild as HTMLElement
const wrapper = container.firstChild
expect(wrapper).toHaveClass('opacity-50')
})
it('should not apply opacity when enabled is true', () => {
// Arrange & Act
it('should not apply opacity when enabled', () => {
const { container } = render(<ChildSegmentList {...defaultProps} enabled={true} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).not.toHaveClass('opacity-50')
})
it('should not apply opacity when focused is true even if enabled is false', () => {
// Arrange & Act
const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} focused={true} />)
// Assert
const wrapper = container.firstChild as HTMLElement
const wrapper = container.firstChild
expect(wrapper).not.toHaveClass('opacity-50')
})
})
// Edited indicator
describe('Edited Indicator', () => {
it('should show edited indicator for edited chunks', () => {
// Arrange
mockParentMode = 'full-doc'
const editedChunk = createMockChildChunk('child-edited', 'Edited content', true)
describe('Full-Doc Mode', () => {
beforeEach(() => {
mockParentMode.current = 'full-doc'
})
// Act
render(<ChildSegmentList {...defaultProps} childChunks={[editedChunk]} />)
it('should show content by default in full-doc mode', () => {
const childChunks = [createMockChildChunk({ content: 'Full doc content' })]
// Assert
const label = screen.getByTestId('slice-label')
expect(label.textContent).toContain('segment.edited')
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} isLoading={false} />)
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
})
it('should render search input in full-doc mode', () => {
render(<ChildSegmentList {...defaultProps} inputValue="" handleInputChange={vi.fn()} />)
const input = document.querySelector('input')
expect(input).toBeInTheDocument()
})
it('should call handleInputChange when input changes', () => {
const handleInputChange = vi.fn()
render(<ChildSegmentList {...defaultProps} inputValue="" handleInputChange={handleInputChange} />)
const input = document.querySelector('input')
if (input) {
fireEvent.change(input, { target: { value: 'test search' } })
expect(handleInputChange).toHaveBeenCalledWith('test search')
}
})
it('should show search results text when searching', () => {
render(<ChildSegmentList {...defaultProps} inputValue="search term" total={3} />)
expect(screen.getByText(/3 search results/i)).toBeInTheDocument()
})
it('should show empty component when no results and searching', () => {
render(
<ChildSegmentList
{...defaultProps}
childChunks={[]}
inputValue="search term"
onClearFilter={vi.fn()}
isLoading={false}
/>,
)
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
})
it('should call onClearFilter when clear button clicked in empty state', () => {
const onClearFilter = vi.fn()
render(
<ChildSegmentList
{...defaultProps}
childChunks={[]}
inputValue="search term"
onClearFilter={onClearFilter}
isLoading={false}
/>,
)
const clearButton = screen.getByText('Clear Filter')
fireEvent.click(clearButton)
expect(onClearFilter).toHaveBeenCalled()
})
})
// Multiple chunks
describe('Multiple Chunks', () => {
it('should render multiple child chunks', () => {
// Arrange
mockParentMode = 'full-doc'
const chunks = [
createMockChildChunk('child-1', 'Content 1'),
createMockChildChunk('child-2', 'Content 2'),
createMockChildChunk('child-3', 'Content 3'),
]
describe('Child Chunk Items', () => {
it('should render edited label when chunk is edited', () => {
mockParentMode.current = 'full-doc'
const editedChunk = createMockChildChunk({
id: 'edited-chunk',
position: 1,
created_at: 1700000000,
updated_at: 1700000001, // Different from created_at
})
// Act
render(<ChildSegmentList {...defaultProps} childChunks={chunks} total={3} />)
render(<ChildSegmentList {...defaultProps} childChunks={[editedChunk]} isLoading={false} />)
// Assert
expect(screen.getAllByTestId('edit-slice')).toHaveLength(3)
expect(screen.getByText(/C-1 · edited/i)).toBeInTheDocument()
})
it('should not show edited label when chunk is not edited', () => {
mockParentMode.current = 'full-doc'
const normalChunk = createMockChildChunk({
id: 'normal-chunk',
position: 2,
created_at: 1700000000,
updated_at: 1700000000, // Same as created_at
})
render(<ChildSegmentList {...defaultProps} childChunks={[normalChunk]} isLoading={false} />)
expect(screen.getByText('C-2')).toBeInTheDocument()
expect(screen.queryByText(/edited/i)).not.toBeInTheDocument()
})
it('should call onClickSlice when chunk is clicked', () => {
mockParentMode.current = 'full-doc'
const onClickSlice = vi.fn()
const chunk = createMockChildChunk({ id: 'clickable-chunk' })
render(
<ChildSegmentList
{...defaultProps}
childChunks={[chunk]}
onClickSlice={onClickSlice}
isLoading={false}
/>,
)
const editSlice = screen.getByTestId('edit-slice')
fireEvent.click(editSlice)
expect(onClickSlice).toHaveBeenCalledWith(chunk)
})
it('should call onDelete when delete button is clicked', () => {
mockParentMode.current = 'full-doc'
const onDelete = vi.fn()
const chunk = createMockChildChunk({ id: 'deletable-chunk', segment_id: 'seg-1' })
render(
<ChildSegmentList
{...defaultProps}
childChunks={[chunk]}
onDelete={onDelete}
isLoading={false}
/>,
)
const deleteButton = screen.getByTestId('delete-button')
fireEvent.click(deleteButton)
expect(onDelete).toHaveBeenCalledWith('seg-1', 'deletable-chunk')
})
it('should apply focused styles when chunk is currently selected', () => {
mockParentMode.current = 'full-doc'
const chunk = createMockChildChunk({ id: 'focused-chunk' })
mockCurrChildChunk.current = { childChunkInfo: chunk, showModal: true }
render(<ChildSegmentList {...defaultProps} childChunks={[chunk]} isLoading={false} />)
const label = screen.getByTestId('edit-slice-label')
expect(label).toHaveClass('bg-state-accent-solid')
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty childChunks array', () => {
// Arrange
mockParentMode = 'full-doc'
describe('Add Button', () => {
it('should call handleAddNewChildChunk when Add button is clicked', () => {
const handleAddNewChildChunk = vi.fn()
// Act
const { container } = render(<ChildSegmentList {...defaultProps} childChunks={[]} />)
render(
<ChildSegmentList
{...defaultProps}
handleAddNewChildChunk={handleAddNewChildChunk}
parentChunkId="parent-123"
/>,
)
// Assert
expect(container.firstChild).toBeInTheDocument()
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
expect(handleAddNewChildChunk).toHaveBeenCalledWith('parent-123')
})
it('should maintain structure when rerendered', () => {
// Arrange
mockParentMode = 'full-doc'
const { rerender } = render(<ChildSegmentList {...defaultProps} />)
it('should disable Add button when loading in full-doc mode', () => {
mockParentMode.current = 'full-doc'
// Act
const newChunks = [createMockChildChunk('new-child', 'New content')]
rerender(<ChildSegmentList {...defaultProps} childChunks={newChunks} />)
// Assert
expect(screen.getByText('New content')).toBeInTheDocument()
})
it('should disable add button when loading', () => {
// Arrange
mockParentMode = 'full-doc'
// Act
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
// Assert
const addButton = screen.getByText(/operation\.add/i)
const addButton = screen.getByText('Add')
expect(addButton).toBeDisabled()
})
it('should stop propagation when Add button is clicked', () => {
const handleAddNewChildChunk = vi.fn()
const parentClickHandler = vi.fn()
render(
<div onClick={parentClickHandler}>
<ChildSegmentList
{...defaultProps}
handleAddNewChildChunk={handleAddNewChildChunk}
/>
</div>,
)
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
expect(handleAddNewChildChunk).toHaveBeenCalled()
// Parent should not be called due to stopPropagation
})
})
describe('computeTotalInfo function', () => {
it('should return search results when searching in full-doc mode', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} inputValue="search" total={10} />)
expect(screen.getByText(/10 search results/i)).toBeInTheDocument()
})
it('should return "--" when total is 0 in full-doc mode', () => {
mockParentMode.current = 'full-doc'
render(<ChildSegmentList {...defaultProps} total={0} />)
// When total is 0, displayText is '--'
expect(screen.getByText(/--/)).toBeInTheDocument()
})
it('should use childChunks length in paragraph mode', () => {
mockParentMode.current = 'paragraph'
const childChunks = [
createMockChildChunk(),
createMockChildChunk(),
createMockChildChunk(),
]
render(<ChildSegmentList {...defaultProps} childChunks={childChunks} />)
expect(screen.getByText(/3 child chunks/i)).toBeInTheDocument()
})
})
describe('Focused State', () => {
it('should not apply opacity when focused even if disabled', () => {
const { container } = render(
<ChildSegmentList {...defaultProps} enabled={false} focused={true} />,
)
const wrapper = container.firstChild
expect(wrapper).not.toHaveClass('opacity-50')
})
})
describe('Input clear button', () => {
it('should call handleInputChange with empty string when clear is clicked', () => {
mockParentMode.current = 'full-doc'
const handleInputChange = vi.fn()
render(
<ChildSegmentList
{...defaultProps}
inputValue="test"
handleInputChange={handleInputChange}
/>,
)
// Find the clear button (it's the showClearIcon button in Input)
const input = document.querySelector('input')
if (input) {
// Trigger clear by simulating the input's onClear
const clearButton = document.querySelector('[class*="cursor-pointer"]')
if (clearButton)
fireEvent.click(clearButton)
}
})
})
})

View File

@ -1,523 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import { DocumentContext } from '../../context'
import ActionButtons from './action-buttons'
// Mock useKeyPress from ahooks to capture and test callback functions
const mockUseKeyPress = vi.fn()
vi.mock('ahooks', () => ({
useKeyPress: (keys: string | string[], callback: (e: KeyboardEvent) => void, options?: object) => {
mockUseKeyPress(keys, callback, options)
},
}))
// Create wrapper component for providing context
const createWrapper = (contextValue: {
docForm?: ChunkingMode
parentMode?: 'paragraph' | 'full-doc'
}) => {
return ({ children }: { children: React.ReactNode }) => (
<DocumentContext.Provider value={contextValue}>
{children}
</DocumentContext.Provider>
)
}
// Helper to get captured callbacks from useKeyPress mock
const getEscCallback = (): ((e: KeyboardEvent) => void) | undefined => {
const escCall = mockUseKeyPress.mock.calls.find(
(call) => {
const keys = call[0]
return Array.isArray(keys) && keys.includes('esc')
},
)
return escCall?.[1]
}
const getCtrlSCallback = (): ((e: KeyboardEvent) => void) | undefined => {
const ctrlSCall = mockUseKeyPress.mock.calls.find(
(call) => {
const keys = call[0]
return typeof keys === 'string' && keys.includes('.s')
},
)
return ctrlSCall?.[1]
}
describe('ActionButtons', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseKeyPress.mockClear()
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render cancel button', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Assert
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
})
it('should render save button', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Assert
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
})
it('should render ESC keyboard hint on cancel button', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Assert
expect(screen.getByText('ESC')).toBeInTheDocument()
})
it('should render S keyboard hint on save button', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Assert
expect(screen.getByText('S')).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call handleCancel when cancel button is clicked', () => {
// Arrange
const mockHandleCancel = vi.fn()
render(
<ActionButtons
handleCancel={mockHandleCancel}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Act
const cancelButton = screen.getAllByRole('button')[0]
fireEvent.click(cancelButton)
// Assert
expect(mockHandleCancel).toHaveBeenCalledTimes(1)
})
it('should call handleSave when save button is clicked', () => {
// Arrange
const mockHandleSave = vi.fn()
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={mockHandleSave}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Act
const buttons = screen.getAllByRole('button')
const saveButton = buttons[buttons.length - 1] // Save button is last
fireEvent.click(saveButton)
// Assert
expect(mockHandleSave).toHaveBeenCalledTimes(1)
})
it('should disable save button when loading is true', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={true}
/>,
{ wrapper: createWrapper({}) },
)
// Assert
const buttons = screen.getAllByRole('button')
const saveButton = buttons[buttons.length - 1]
expect(saveButton).toBeDisabled()
})
})
// Regeneration button tests
describe('Regeneration Button', () => {
it('should show regeneration button in parent-child paragraph mode for edit action', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={false}
actionType="edit"
isChildChunk={false}
showRegenerationButton={true}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert
expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
})
it('should not show regeneration button when isChildChunk is true', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={false}
actionType="edit"
isChildChunk={true}
showRegenerationButton={true}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert
expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument()
})
it('should not show regeneration button when showRegenerationButton is false', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={false}
actionType="edit"
isChildChunk={false}
showRegenerationButton={false}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert
expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument()
})
it('should not show regeneration button when actionType is add', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={false}
actionType="add"
isChildChunk={false}
showRegenerationButton={true}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert
expect(screen.queryByText(/operation\.saveAndRegenerate/i)).not.toBeInTheDocument()
})
it('should call handleRegeneration when regeneration button is clicked', () => {
// Arrange
const mockHandleRegeneration = vi.fn()
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={mockHandleRegeneration}
loading={false}
actionType="edit"
isChildChunk={false}
showRegenerationButton={true}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Act
const regenerationButton = screen.getByText(/operation\.saveAndRegenerate/i).closest('button')
if (regenerationButton)
fireEvent.click(regenerationButton)
// Assert
expect(mockHandleRegeneration).toHaveBeenCalledTimes(1)
})
it('should disable regeneration button when loading is true', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={true}
actionType="edit"
isChildChunk={false}
showRegenerationButton={true}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert
const regenerationButton = screen.getByText(/operation\.saveAndRegenerate/i).closest('button')
expect(regenerationButton).toBeDisabled()
})
})
// Default props tests
describe('Default Props', () => {
it('should use default actionType of edit', () => {
// Arrange & Act - when not specifying actionType and other conditions are met
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={false}
showRegenerationButton={true}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert - regeneration button should show with default actionType='edit'
expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
})
it('should use default isChildChunk of false', () => {
// Arrange & Act - when not specifying isChildChunk
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={false}
actionType="edit"
showRegenerationButton={true}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert - regeneration button should show with default isChildChunk=false
expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
})
it('should use default showRegenerationButton of true', () => {
// Arrange & Act - when not specifying showRegenerationButton
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
handleRegeneration={vi.fn()}
loading={false}
actionType="edit"
isChildChunk={false}
/>,
{ wrapper: createWrapper({ docForm: ChunkingMode.parentChild, parentMode: 'paragraph' }) },
)
// Assert - regeneration button should show with default showRegenerationButton=true
expect(screen.getByText(/operation\.saveAndRegenerate/i)).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle missing context values gracefully', () => {
// Arrange & Act & Assert - should not throw
expect(() => {
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
}).not.toThrow()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Act
rerender(
<DocumentContext.Provider value={{}}>
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={true}
/>
</DocumentContext.Provider>,
)
// Assert
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
expect(screen.getByText(/operation\.save/i)).toBeInTheDocument()
})
})
// Keyboard shortcuts tests via useKeyPress callbacks
describe('Keyboard Shortcuts', () => {
it('should display ctrl key hint on save button', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Assert - check for ctrl key hint (Ctrl or Cmd depending on system)
const kbdElements = document.querySelectorAll('.system-kbd')
expect(kbdElements.length).toBeGreaterThan(0)
})
it('should call handleCancel and preventDefault when ESC key is pressed', () => {
// Arrange
const mockHandleCancel = vi.fn()
const mockPreventDefault = vi.fn()
render(
<ActionButtons
handleCancel={mockHandleCancel}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Act - get the ESC callback and invoke it
const escCallback = getEscCallback()
expect(escCallback).toBeDefined()
escCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent)
// Assert
expect(mockPreventDefault).toHaveBeenCalledTimes(1)
expect(mockHandleCancel).toHaveBeenCalledTimes(1)
})
it('should call handleSave and preventDefault when Ctrl+S is pressed and not loading', () => {
// Arrange
const mockHandleSave = vi.fn()
const mockPreventDefault = vi.fn()
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={mockHandleSave}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Act - get the Ctrl+S callback and invoke it
const ctrlSCallback = getCtrlSCallback()
expect(ctrlSCallback).toBeDefined()
ctrlSCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent)
// Assert
expect(mockPreventDefault).toHaveBeenCalledTimes(1)
expect(mockHandleSave).toHaveBeenCalledTimes(1)
})
it('should not call handleSave when Ctrl+S is pressed while loading', () => {
// Arrange
const mockHandleSave = vi.fn()
const mockPreventDefault = vi.fn()
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={mockHandleSave}
loading={true}
/>,
{ wrapper: createWrapper({}) },
)
// Act - get the Ctrl+S callback and invoke it
const ctrlSCallback = getCtrlSCallback()
expect(ctrlSCallback).toBeDefined()
ctrlSCallback!({ preventDefault: mockPreventDefault } as unknown as KeyboardEvent)
// Assert
expect(mockPreventDefault).toHaveBeenCalledTimes(1)
expect(mockHandleSave).not.toHaveBeenCalled()
})
it('should register useKeyPress with correct options for Ctrl+S', () => {
// Arrange & Act
render(
<ActionButtons
handleCancel={vi.fn()}
handleSave={vi.fn()}
loading={false}
/>,
{ wrapper: createWrapper({}) },
)
// Assert - verify useKeyPress was called with correct options
const ctrlSCall = mockUseKeyPress.mock.calls.find(
call => typeof call[0] === 'string' && call[0].includes('.s'),
)
expect(ctrlSCall).toBeDefined()
expect(ctrlSCall![2]).toEqual({ exactMatch: true, useCapture: true })
})
})
})

View File

@ -1,194 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AddAnother from './add-another'
describe('AddAnother', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(
<AddAnother isChecked={false} onCheck={vi.fn()} />,
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render the checkbox', () => {
// Arrange & Act
const { container } = render(
<AddAnother isChecked={false} onCheck={vi.fn()} />,
)
// Assert - Checkbox component renders with shrink-0 class
const checkbox = container.querySelector('.shrink-0')
expect(checkbox).toBeInTheDocument()
})
it('should render the add another text', () => {
// Arrange & Act
render(<AddAnother isChecked={false} onCheck={vi.fn()} />)
// Assert - i18n key format
expect(screen.getByText(/segment\.addAnother/i)).toBeInTheDocument()
})
it('should render with correct base styling classes', () => {
// Arrange & Act
const { container } = render(
<AddAnother isChecked={false} onCheck={vi.fn()} />,
)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('gap-x-1')
expect(wrapper).toHaveClass('pl-1')
})
})
// Props tests
describe('Props', () => {
it('should render unchecked state when isChecked is false', () => {
// Arrange & Act
const { container } = render(
<AddAnother isChecked={false} onCheck={vi.fn()} />,
)
// Assert - unchecked checkbox has border class
const checkbox = container.querySelector('.border-components-checkbox-border')
expect(checkbox).toBeInTheDocument()
})
it('should render checked state when isChecked is true', () => {
// Arrange & Act
const { container } = render(
<AddAnother isChecked={true} onCheck={vi.fn()} />,
)
// Assert - checked checkbox has bg-components-checkbox-bg class
const checkbox = container.querySelector('.bg-components-checkbox-bg')
expect(checkbox).toBeInTheDocument()
})
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<AddAnother
isChecked={false}
onCheck={vi.fn()}
className="custom-class"
/>,
)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onCheck when checkbox is clicked', () => {
// Arrange
const mockOnCheck = vi.fn()
const { container } = render(
<AddAnother isChecked={false} onCheck={mockOnCheck} />,
)
// Act - click on the checkbox element
const checkbox = container.querySelector('.shrink-0')
if (checkbox)
fireEvent.click(checkbox)
// Assert
expect(mockOnCheck).toHaveBeenCalledTimes(1)
})
it('should toggle checked state on multiple clicks', () => {
// Arrange
const mockOnCheck = vi.fn()
const { container, rerender } = render(
<AddAnother isChecked={false} onCheck={mockOnCheck} />,
)
// Act - first click
const checkbox = container.querySelector('.shrink-0')
if (checkbox) {
fireEvent.click(checkbox)
rerender(<AddAnother isChecked={true} onCheck={mockOnCheck} />)
fireEvent.click(checkbox)
}
// Assert
expect(mockOnCheck).toHaveBeenCalledTimes(2)
})
})
// Structure tests
describe('Structure', () => {
it('should render text with tertiary text color', () => {
// Arrange & Act
const { container } = render(
<AddAnother isChecked={false} onCheck={vi.fn()} />,
)
// Assert
const textElement = container.querySelector('.text-text-tertiary')
expect(textElement).toBeInTheDocument()
})
it('should render text with xs medium font styling', () => {
// Arrange & Act
const { container } = render(
<AddAnother isChecked={false} onCheck={vi.fn()} />,
)
// Assert
const textElement = container.querySelector('.system-xs-medium')
expect(textElement).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should maintain structure when rerendered', () => {
// Arrange
const mockOnCheck = vi.fn()
const { rerender, container } = render(
<AddAnother isChecked={false} onCheck={mockOnCheck} />,
)
// Act
rerender(<AddAnother isChecked={true} onCheck={mockOnCheck} />)
// Assert
const checkbox = container.querySelector('.shrink-0')
expect(checkbox).toBeInTheDocument()
})
it('should handle rapid state changes', () => {
// Arrange
const mockOnCheck = vi.fn()
const { container } = render(
<AddAnother isChecked={false} onCheck={mockOnCheck} />,
)
// Act
const checkbox = container.querySelector('.shrink-0')
if (checkbox) {
for (let i = 0; i < 5; i++)
fireEvent.click(checkbox)
}
// Assert
expect(mockOnCheck).toHaveBeenCalledTimes(5)
})
})
})

View File

@ -1,277 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BatchAction from './batch-action'
describe('BatchAction', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const defaultProps = {
selectedIds: ['1', '2', '3'],
onBatchEnable: vi.fn(),
onBatchDisable: vi.fn(),
onBatchDelete: vi.fn().mockResolvedValue(undefined),
onCancel: vi.fn(),
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<BatchAction {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should display selected count', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} />)
// Assert
expect(screen.getByText('3')).toBeInTheDocument()
})
it('should render enable button', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} />)
// Assert
expect(screen.getByText(/batchAction\.enable/i)).toBeInTheDocument()
})
it('should render disable button', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} />)
// Assert
expect(screen.getByText(/batchAction\.disable/i)).toBeInTheDocument()
})
it('should render delete button', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} />)
// Assert
expect(screen.getByText(/batchAction\.delete/i)).toBeInTheDocument()
})
it('should render cancel button', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} />)
// Assert
expect(screen.getByText(/batchAction\.cancel/i)).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onBatchEnable when enable button is clicked', () => {
// Arrange
const mockOnBatchEnable = vi.fn()
render(<BatchAction {...defaultProps} onBatchEnable={mockOnBatchEnable} />)
// Act
fireEvent.click(screen.getByText(/batchAction\.enable/i))
// Assert
expect(mockOnBatchEnable).toHaveBeenCalledTimes(1)
})
it('should call onBatchDisable when disable button is clicked', () => {
// Arrange
const mockOnBatchDisable = vi.fn()
render(<BatchAction {...defaultProps} onBatchDisable={mockOnBatchDisable} />)
// Act
fireEvent.click(screen.getByText(/batchAction\.disable/i))
// Assert
expect(mockOnBatchDisable).toHaveBeenCalledTimes(1)
})
it('should call onCancel when cancel button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
render(<BatchAction {...defaultProps} onCancel={mockOnCancel} />)
// Act
fireEvent.click(screen.getByText(/batchAction\.cancel/i))
// Assert
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('should show delete confirmation dialog when delete button is clicked', () => {
// Arrange
render(<BatchAction {...defaultProps} />)
// Act
fireEvent.click(screen.getByText(/batchAction\.delete/i))
// Assert - Confirm dialog should appear
expect(screen.getByText(/list\.delete\.title/i)).toBeInTheDocument()
})
it('should call onBatchDelete when confirm is clicked in delete dialog', async () => {
// Arrange
const mockOnBatchDelete = vi.fn().mockResolvedValue(undefined)
render(<BatchAction {...defaultProps} onBatchDelete={mockOnBatchDelete} />)
// Act - open delete dialog
fireEvent.click(screen.getByText(/batchAction\.delete/i))
// Act - click confirm
const confirmButton = screen.getByText(/operation\.sure/i)
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(mockOnBatchDelete).toHaveBeenCalledTimes(1)
})
})
})
// Optional props tests
describe('Optional Props', () => {
it('should render download button when onBatchDownload is provided', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} onBatchDownload={vi.fn()} />)
// Assert
expect(screen.getByText(/batchAction\.download/i)).toBeInTheDocument()
})
it('should not render download button when onBatchDownload is not provided', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} />)
// Assert
expect(screen.queryByText(/batchAction\.download/i)).not.toBeInTheDocument()
})
it('should render archive button when onArchive is provided', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} onArchive={vi.fn()} />)
// Assert
expect(screen.getByText(/batchAction\.archive/i)).toBeInTheDocument()
})
it('should render metadata button when onEditMetadata is provided', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} onEditMetadata={vi.fn()} />)
// Assert
expect(screen.getByText(/metadata\.metadata/i)).toBeInTheDocument()
})
it('should render re-index button when onBatchReIndex is provided', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} onBatchReIndex={vi.fn()} />)
// Assert
expect(screen.getByText(/batchAction\.reIndex/i)).toBeInTheDocument()
})
it('should call onBatchDownload when download button is clicked', () => {
// Arrange
const mockOnBatchDownload = vi.fn()
render(<BatchAction {...defaultProps} onBatchDownload={mockOnBatchDownload} />)
// Act
fireEvent.click(screen.getByText(/batchAction\.download/i))
// Assert
expect(mockOnBatchDownload).toHaveBeenCalledTimes(1)
})
it('should call onArchive when archive button is clicked', () => {
// Arrange
const mockOnArchive = vi.fn()
render(<BatchAction {...defaultProps} onArchive={mockOnArchive} />)
// Act
fireEvent.click(screen.getByText(/batchAction\.archive/i))
// Assert
expect(mockOnArchive).toHaveBeenCalledTimes(1)
})
it('should call onEditMetadata when metadata button is clicked', () => {
// Arrange
const mockOnEditMetadata = vi.fn()
render(<BatchAction {...defaultProps} onEditMetadata={mockOnEditMetadata} />)
// Act
fireEvent.click(screen.getByText(/metadata\.metadata/i))
// Assert
expect(mockOnEditMetadata).toHaveBeenCalledTimes(1)
})
it('should call onBatchReIndex when re-index button is clicked', () => {
// Arrange
const mockOnBatchReIndex = vi.fn()
render(<BatchAction {...defaultProps} onBatchReIndex={mockOnBatchReIndex} />)
// Act
fireEvent.click(screen.getByText(/batchAction\.reIndex/i))
// Assert
expect(mockOnBatchReIndex).toHaveBeenCalledTimes(1)
})
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(<BatchAction {...defaultProps} className="custom-class" />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
})
// Selected count display tests
describe('Selected Count', () => {
it('should display correct count for single selection', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} selectedIds={['1']} />)
// Assert
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should display correct count for multiple selections', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} selectedIds={['1', '2', '3', '4', '5']} />)
// Assert
expect(screen.getByText('5')).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<BatchAction {...defaultProps} />)
// Act
rerender(<BatchAction {...defaultProps} selectedIds={['1', '2']} />)
// Assert
expect(screen.getByText('2')).toBeInTheDocument()
})
it('should handle empty selectedIds array', () => {
// Arrange & Act
render(<BatchAction {...defaultProps} selectedIds={[]} />)
// Assert
expect(screen.getByText('0')).toBeInTheDocument()
})
})
})

View File

@ -1,317 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { ChunkingMode } from '@/models/datasets'
import ChunkContent from './chunk-content'
// Mock ResizeObserver
const OriginalResizeObserver = globalThis.ResizeObserver
class MockResizeObserver {
observe = vi.fn()
disconnect = vi.fn()
unobserve = vi.fn()
}
beforeAll(() => {
globalThis.ResizeObserver = MockResizeObserver as typeof ResizeObserver
})
afterAll(() => {
globalThis.ResizeObserver = OriginalResizeObserver
})
describe('ChunkContent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const defaultProps = {
question: 'Test question content',
onQuestionChange: vi.fn(),
docForm: ChunkingMode.text,
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<ChunkContent {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render textarea in edit mode with text docForm', () => {
// Arrange & Act
render(<ChunkContent {...defaultProps} isEditMode={true} />)
// Assert
const textarea = screen.getByRole('textbox')
expect(textarea).toBeInTheDocument()
})
it('should render Markdown content in view mode with text docForm', () => {
// Arrange & Act
const { container } = render(<ChunkContent {...defaultProps} isEditMode={false} />)
// Assert - In view mode, textarea should not be present, Markdown renders instead
expect(container.querySelector('textarea')).not.toBeInTheDocument()
})
})
// QA mode tests
describe('QA Mode', () => {
it('should render QA layout when docForm is qa', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
answer="Test answer"
onAnswerChange={vi.fn()}
isEditMode={true}
/>,
)
// Assert - QA mode has QUESTION and ANSWER labels
expect(screen.getByText('QUESTION')).toBeInTheDocument()
expect(screen.getByText('ANSWER')).toBeInTheDocument()
})
it('should display question value in QA mode', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
question="My question"
answer="My answer"
onAnswerChange={vi.fn()}
isEditMode={true}
/>,
)
// Assert
const textareas = screen.getAllByRole('textbox')
expect(textareas[0]).toHaveValue('My question')
})
it('should display answer value in QA mode', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
question="My question"
answer="My answer"
onAnswerChange={vi.fn()}
isEditMode={true}
/>,
)
// Assert
const textareas = screen.getAllByRole('textbox')
expect(textareas[1]).toHaveValue('My answer')
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onQuestionChange when textarea value changes in text mode', () => {
// Arrange
const mockOnQuestionChange = vi.fn()
render(
<ChunkContent
{...defaultProps}
isEditMode={true}
onQuestionChange={mockOnQuestionChange}
/>,
)
// Act
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: 'New content' } })
// Assert
expect(mockOnQuestionChange).toHaveBeenCalledWith('New content')
})
it('should call onQuestionChange when question textarea changes in QA mode', () => {
// Arrange
const mockOnQuestionChange = vi.fn()
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
isEditMode={true}
onQuestionChange={mockOnQuestionChange}
onAnswerChange={vi.fn()}
/>,
)
// Act
const textareas = screen.getAllByRole('textbox')
fireEvent.change(textareas[0], { target: { value: 'New question' } })
// Assert
expect(mockOnQuestionChange).toHaveBeenCalledWith('New question')
})
it('should call onAnswerChange when answer textarea changes in QA mode', () => {
// Arrange
const mockOnAnswerChange = vi.fn()
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
isEditMode={true}
answer="Old answer"
onAnswerChange={mockOnAnswerChange}
/>,
)
// Act
const textareas = screen.getAllByRole('textbox')
fireEvent.change(textareas[1], { target: { value: 'New answer' } })
// Assert
expect(mockOnAnswerChange).toHaveBeenCalledWith('New answer')
})
it('should disable textarea when isEditMode is false in text mode', () => {
// Arrange & Act
const { container } = render(
<ChunkContent {...defaultProps} isEditMode={false} />,
)
// Assert - In view mode, Markdown is rendered instead of textarea
expect(container.querySelector('textarea')).not.toBeInTheDocument()
})
it('should disable textareas when isEditMode is false in QA mode', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
isEditMode={false}
answer="Answer"
onAnswerChange={vi.fn()}
/>,
)
// Assert
const textareas = screen.getAllByRole('textbox')
textareas.forEach((textarea) => {
expect(textarea).toBeDisabled()
})
})
})
// DocForm variations
describe('DocForm Variations', () => {
it('should handle ChunkingMode.text', () => {
// Arrange & Act
render(<ChunkContent {...defaultProps} docForm={ChunkingMode.text} isEditMode={true} />)
// Assert
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should handle ChunkingMode.qa', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
answer="answer"
onAnswerChange={vi.fn()}
isEditMode={true}
/>,
)
// Assert - QA mode should show both question and answer
expect(screen.getByText('QUESTION')).toBeInTheDocument()
expect(screen.getByText('ANSWER')).toBeInTheDocument()
})
it('should handle ChunkingMode.parentChild similar to text mode', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.parentChild}
isEditMode={true}
/>,
)
// Assert - parentChild should render like text mode
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty question', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
question=""
isEditMode={true}
/>,
)
// Assert
const textarea = screen.getByRole('textbox')
expect(textarea).toHaveValue('')
})
it('should handle empty answer in QA mode', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
question="question"
answer=""
onAnswerChange={vi.fn()}
isEditMode={true}
/>,
)
// Assert
const textareas = screen.getAllByRole('textbox')
expect(textareas[1]).toHaveValue('')
})
it('should handle undefined answer in QA mode', () => {
// Arrange & Act
render(
<ChunkContent
{...defaultProps}
docForm={ChunkingMode.qa}
isEditMode={true}
/>,
)
// Assert - should render without crashing
expect(screen.getByText('QUESTION')).toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(
<ChunkContent {...defaultProps} question="Initial" isEditMode={true} />,
)
// Act
rerender(
<ChunkContent {...defaultProps} question="Updated" isEditMode={true} />,
)
// Assert
const textarea = screen.getByRole('textbox')
expect(textarea).toHaveValue('Updated')
})
})
})

View File

@ -1,60 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Dot from './dot'
describe('Dot', () => {
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<Dot />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render the dot character', () => {
// Arrange & Act
render(<Dot />)
// Assert
expect(screen.getByText('·')).toBeInTheDocument()
})
it('should render with correct styling classes', () => {
// Arrange & Act
const { container } = render(<Dot />)
// Assert
const dotElement = container.firstChild as HTMLElement
expect(dotElement).toHaveClass('system-xs-medium')
expect(dotElement).toHaveClass('text-text-quaternary')
})
})
// Memoization tests
describe('Memoization', () => {
it('should render consistently across multiple renders', () => {
// Arrange & Act
const { container: container1 } = render(<Dot />)
const { container: container2 } = render(<Dot />)
// Assert
expect(container1.firstChild?.textContent).toBe(container2.firstChild?.textContent)
})
})
// Edge cases
describe('Edge Cases', () => {
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<Dot />)
// Act
rerender(<Dot />)
// Assert
expect(screen.getByText('·')).toBeInTheDocument()
})
})
})

View File

@ -1,153 +1,129 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Empty from './empty'
describe('Empty', () => {
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
if (key === 'segment.empty')
return 'No results found'
if (key === 'segment.clearFilter')
return 'Clear Filter'
return key
},
}),
}))
describe('Empty Component', () => {
const defaultProps = {
onClearFilter: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<Empty onClearFilter={vi.fn()} />)
it('should render empty state message', () => {
render(<Empty {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render the file list icon', () => {
// Arrange & Act
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// Assert - RiFileList2Line icon should be rendered
const icon = container.querySelector('.h-6.w-6')
expect(icon).toBeInTheDocument()
})
it('should render empty message text', () => {
// Arrange & Act
render(<Empty onClearFilter={vi.fn()} />)
// Assert - i18n key format: datasetDocuments:segment.empty
expect(screen.getByText(/segment\.empty/i)).toBeInTheDocument()
expect(screen.getByText('No results found')).toBeInTheDocument()
})
it('should render clear filter button', () => {
// Arrange & Act
render(<Empty onClearFilter={vi.fn()} />)
render(<Empty {...defaultProps} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByText('Clear Filter')).toBeInTheDocument()
})
it('should render background empty cards', () => {
// Arrange & Act
const { container } = render(<Empty onClearFilter={vi.fn()} />)
it('should render icon', () => {
const { container } = render(<Empty {...defaultProps} />)
// Assert - should have 10 background cards
const emptyCards = container.querySelectorAll('.bg-background-section-burn')
expect(emptyCards).toHaveLength(10)
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onClearFilter when clear filter button is clicked', () => {
// Arrange
const mockOnClearFilter = vi.fn()
render(<Empty onClearFilter={mockOnClearFilter} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockOnClearFilter).toHaveBeenCalledTimes(1)
})
})
// Structure tests
describe('Structure', () => {
it('should render the decorative lines', () => {
// Arrange & Act
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// Assert - there should be 4 Line components (SVG elements)
const svgElements = container.querySelectorAll('svg')
expect(svgElements.length).toBeGreaterThanOrEqual(4)
})
it('should render mask overlay', () => {
// Arrange & Act
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// Assert
const maskElement = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(maskElement).toBeInTheDocument()
})
it('should render icon container with proper styling', () => {
// Arrange & Act
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// Assert
// Check for the icon container
const iconContainer = container.querySelector('.shadow-lg')
expect(iconContainer).toBeInTheDocument()
})
it('should render clear filter button with accent text styling', () => {
// Arrange & Act
render(<Empty onClearFilter={vi.fn()} />)
it('should render decorative lines', () => {
const { container } = render(<Empty {...defaultProps} />)
// Assert
const button = screen.getByRole('button')
expect(button).toHaveClass('text-text-accent')
// Check for SVG lines
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThan(0)
})
it('should render background cards', () => {
const { container } = render(<Empty {...defaultProps} />)
// Check for background empty cards (10 of them)
const backgroundCards = container.querySelectorAll('.rounded-xl.bg-background-section-burn')
expect(backgroundCards.length).toBe(10)
})
it('should render mask overlay', () => {
const { container } = render(<Empty {...defaultProps} />)
const maskOverlay = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(maskOverlay).toBeInTheDocument()
})
})
// Props tests
describe('Props', () => {
it('should accept onClearFilter callback prop', () => {
// Arrange
const mockCallback = vi.fn()
describe('Interactions', () => {
it('should call onClearFilter when clear filter button is clicked', () => {
const onClearFilter = vi.fn()
// Act
render(<Empty onClearFilter={mockCallback} />)
fireEvent.click(screen.getByRole('button'))
render(<Empty onClearFilter={onClearFilter} />)
// Assert
expect(mockCallback).toHaveBeenCalled()
const clearButton = screen.getByText('Clear Filter')
fireEvent.click(clearButton)
expect(onClearFilter).toHaveBeenCalledTimes(1)
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle multiple clicks on clear filter button', () => {
// Arrange
const mockOnClearFilter = vi.fn()
render(<Empty onClearFilter={mockOnClearFilter} />)
describe('Memoization', () => {
it('should be memoized', () => {
// Empty is wrapped with React.memo
const { rerender } = render(<Empty {...defaultProps} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.click(button)
// Same props should not cause re-render issues
rerender(<Empty {...defaultProps} />)
// Assert
expect(mockOnClearFilter).toHaveBeenCalledTimes(3)
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender, container } = render(<Empty onClearFilter={vi.fn()} />)
// Act
rerender(<Empty onClearFilter={vi.fn()} />)
// Assert
const emptyCards = container.querySelectorAll('.bg-background-section-burn')
expect(emptyCards).toHaveLength(10)
expect(screen.getByText('No results found')).toBeInTheDocument()
})
})
})
describe('EmptyCard Component', () => {
it('should render within Empty component', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// EmptyCard renders as background cards
const emptyCards = container.querySelectorAll('.h-32.w-full')
expect(emptyCards.length).toBe(10)
})
it('should have correct opacity', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
const emptyCards = container.querySelectorAll('.opacity-30')
expect(emptyCards.length).toBe(10)
})
})
describe('Line Component', () => {
it('should render SVG lines within Empty component', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
// Line components render as SVG elements (4 Line components + 1 icon SVG)
const lines = container.querySelectorAll('svg')
expect(lines.length).toBeGreaterThanOrEqual(4)
})
it('should have gradient definition', () => {
const { container } = render(<Empty onClearFilter={vi.fn()} />)
const gradients = container.querySelectorAll('linearGradient')
expect(gradients.length).toBeGreaterThan(0)
})
})

View File

@ -1,262 +0,0 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import FullScreenDrawer from './full-screen-drawer'
// Mock the Drawer component since it has high complexity
vi.mock('./drawer', () => ({
default: ({ children, open, panelClassName, panelContentClassName, showOverlay, needCheckChunks, modal }: { children: ReactNode, open: boolean, panelClassName: string, panelContentClassName: string, showOverlay: boolean, needCheckChunks: boolean, modal: boolean }) => {
if (!open)
return null
return (
<div
data-testid="drawer-mock"
data-panel-class={panelClassName}
data-panel-content-class={panelContentClassName}
data-show-overlay={showOverlay}
data-need-check-chunks={needCheckChunks}
data-modal={modal}
>
{children}
</div>
)
},
}))
describe('FullScreenDrawer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing when open', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
})
it('should not render when closed', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={false} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
})
it('should render children content', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Test Content</div>
</FullScreenDrawer>,
)
// Assert
expect(screen.getByText('Test Content')).toBeInTheDocument()
})
})
// Props tests
describe('Props', () => {
it('should pass fullScreen=true to Drawer with full width class', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={true}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-panel-class')).toContain('w-full')
})
it('should pass fullScreen=false to Drawer with fixed width class', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-panel-class')).toContain('w-[568px]')
})
it('should pass showOverlay prop with default true', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-show-overlay')).toBe('true')
})
it('should pass showOverlay=false when specified', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false} showOverlay={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-show-overlay')).toBe('false')
})
it('should pass needCheckChunks prop with default false', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-need-check-chunks')).toBe('false')
})
it('should pass needCheckChunks=true when specified', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false} needCheckChunks={true}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-need-check-chunks')).toBe('true')
})
it('should pass modal prop with default false', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-modal')).toBe('false')
})
it('should pass modal=true when specified', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false} modal={true}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
expect(drawer.getAttribute('data-modal')).toBe('true')
})
})
// Styling tests
describe('Styling', () => {
it('should apply panel content classes for non-fullScreen mode', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
const contentClass = drawer.getAttribute('data-panel-content-class')
expect(contentClass).toContain('bg-components-panel-bg')
expect(contentClass).toContain('rounded-xl')
})
it('should apply panel content classes without border for fullScreen mode', () => {
// Arrange & Act
render(
<FullScreenDrawer isOpen={true} fullScreen={true}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
const drawer = screen.getByTestId('drawer-mock')
const contentClass = drawer.getAttribute('data-panel-content-class')
expect(contentClass).toContain('bg-components-panel-bg')
expect(contentClass).not.toContain('rounded-xl')
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle undefined onClose gracefully', () => {
// Arrange & Act & Assert - should not throw
expect(() => {
render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
}).not.toThrow()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Act
rerender(
<FullScreenDrawer isOpen={true} fullScreen={true}>
<div>Updated Content</div>
</FullScreenDrawer>,
)
// Assert
expect(screen.getByText('Updated Content')).toBeInTheDocument()
})
it('should handle toggle between open and closed states', () => {
// Arrange
const { rerender } = render(
<FullScreenDrawer isOpen={true} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
expect(screen.getByTestId('drawer-mock')).toBeInTheDocument()
// Act
rerender(
<FullScreenDrawer isOpen={false} fullScreen={false}>
<div>Content</div>
</FullScreenDrawer>,
)
// Assert
expect(screen.queryByTestId('drawer-mock')).not.toBeInTheDocument()
})
})
})

View File

@ -1,317 +0,0 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Keywords from './keywords'
describe('Keywords', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(
<Keywords
keywords={['test']}
onKeywordsChange={vi.fn()}
/>,
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render the keywords label', () => {
// Arrange & Act
render(
<Keywords
keywords={['test']}
onKeywordsChange={vi.fn()}
/>,
)
// Assert - i18n key format
expect(screen.getByText(/segment\.keywords/i)).toBeInTheDocument()
})
it('should render with correct container classes', () => {
// Arrange & Act
const { container } = render(
<Keywords
keywords={['test']}
onKeywordsChange={vi.fn()}
/>,
)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('flex-col')
})
})
// Props tests
describe('Props', () => {
it('should display dash when no keywords and actionType is view', () => {
// Arrange & Act
render(
<Keywords
segInfo={{ id: '1', keywords: [] }}
keywords={[]}
onKeywordsChange={vi.fn()}
actionType="view"
/>,
)
// Assert
expect(screen.getByText('-')).toBeInTheDocument()
})
it('should not display dash when actionType is edit', () => {
// Arrange & Act
render(
<Keywords
segInfo={{ id: '1', keywords: [] }}
keywords={[]}
onKeywordsChange={vi.fn()}
actionType="edit"
/>,
)
// Assert
expect(screen.queryByText('-')).not.toBeInTheDocument()
})
it('should not display dash when actionType is add', () => {
// Arrange & Act
render(
<Keywords
segInfo={{ id: '1', keywords: [] }}
keywords={[]}
onKeywordsChange={vi.fn()}
actionType="add"
/>,
)
// Assert
expect(screen.queryByText('-')).not.toBeInTheDocument()
})
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<Keywords
keywords={['test']}
onKeywordsChange={vi.fn()}
className="custom-class"
/>,
)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
it('should use default actionType of view', () => {
// Arrange & Act
render(
<Keywords
segInfo={{ id: '1', keywords: [] }}
keywords={[]}
onKeywordsChange={vi.fn()}
/>,
)
// Assert - dash should appear in view mode with empty keywords
expect(screen.getByText('-')).toBeInTheDocument()
})
})
// Structure tests
describe('Structure', () => {
it('should render label with uppercase styling', () => {
// Arrange & Act
const { container } = render(
<Keywords
keywords={['test']}
onKeywordsChange={vi.fn()}
/>,
)
// Assert
const labelElement = container.querySelector('.system-xs-medium-uppercase')
expect(labelElement).toBeInTheDocument()
})
it('should render keywords container with overflow handling', () => {
// Arrange & Act
const { container } = render(
<Keywords
keywords={['test']}
onKeywordsChange={vi.fn()}
/>,
)
// Assert
const keywordsContainer = container.querySelector('.overflow-auto')
expect(keywordsContainer).toBeInTheDocument()
})
it('should render keywords container with max height', () => {
// Arrange & Act
const { container } = render(
<Keywords
keywords={['test']}
onKeywordsChange={vi.fn()}
/>,
)
// Assert
const keywordsContainer = container.querySelector('.max-h-\\[200px\\]')
expect(keywordsContainer).toBeInTheDocument()
})
})
// Edit mode tests
describe('Edit Mode', () => {
it('should render TagInput component when keywords exist', () => {
// Arrange & Act
const { container } = render(
<Keywords
segInfo={{ id: '1', keywords: ['keyword1', 'keyword2'] }}
keywords={['keyword1', 'keyword2']}
onKeywordsChange={vi.fn()}
isEditMode={true}
/>,
)
// Assert - TagInput should be rendered instead of dash
expect(screen.queryByText('-')).not.toBeInTheDocument()
expect(container.querySelector('.flex-wrap')).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty keywords array in view mode without segInfo keywords', () => {
// Arrange & Act
const { container } = render(
<Keywords
keywords={[]}
onKeywordsChange={vi.fn()}
actionType="view"
/>,
)
// Assert - container should be rendered
expect(container.firstChild).toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender, container } = render(
<Keywords
segInfo={{ id: '1', keywords: ['test'] }}
keywords={['test']}
onKeywordsChange={vi.fn()}
/>,
)
// Act
rerender(
<Keywords
segInfo={{ id: '1', keywords: ['test', 'new'] }}
keywords={['test', 'new']}
onKeywordsChange={vi.fn()}
/>,
)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should handle segInfo with undefined keywords showing dash in view mode', () => {
// Arrange & Act
render(
<Keywords
segInfo={{ id: '1' }}
keywords={['test']}
onKeywordsChange={vi.fn()}
actionType="view"
/>,
)
// Assert - dash should show because segInfo.keywords is undefined/empty
expect(screen.getByText('-')).toBeInTheDocument()
})
})
// TagInput callback tests
describe('TagInput Callback', () => {
it('should call onKeywordsChange when keywords are modified', () => {
// Arrange
const mockOnKeywordsChange = vi.fn()
render(
<Keywords
segInfo={{ id: '1', keywords: ['existing'] }}
keywords={['existing']}
onKeywordsChange={mockOnKeywordsChange}
isEditMode={true}
actionType="edit"
/>,
)
// Assert - TagInput should be rendered
expect(screen.queryByText('-')).not.toBeInTheDocument()
})
it('should disable add when isEditMode is false', () => {
// Arrange & Act
const { container } = render(
<Keywords
segInfo={{ id: '1', keywords: ['test'] }}
keywords={['test']}
onKeywordsChange={vi.fn()}
isEditMode={false}
actionType="view"
/>,
)
// Assert - TagInput should exist but with disabled add
expect(container.firstChild).toBeInTheDocument()
})
it('should disable remove when only one keyword exists in edit mode', () => {
// Arrange & Act
const { container } = render(
<Keywords
segInfo={{ id: '1', keywords: ['only-one'] }}
keywords={['only-one']}
onKeywordsChange={vi.fn()}
isEditMode={true}
actionType="edit"
/>,
)
// Assert - component should render
expect(container.firstChild).toBeInTheDocument()
})
it('should allow remove when multiple keywords exist in edit mode', () => {
// Arrange & Act
const { container } = render(
<Keywords
segInfo={{ id: '1', keywords: ['first', 'second'] }}
keywords={['first', 'second']}
onKeywordsChange={vi.fn()}
isEditMode={true}
actionType="edit"
/>,
)
// Assert - component should render
expect(container.firstChild).toBeInTheDocument()
})
})
})

View File

@ -1,327 +0,0 @@
import type { ReactNode } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EventEmitterContextProvider, useEventEmitterContextContext } from '@/context/event-emitter'
import RegenerationModal from './regeneration-modal'
// Store emit function for triggering events in tests
let emitFunction: ((v: string) => void) | null = null
const EmitCapture = () => {
const { eventEmitter } = useEventEmitterContextContext()
emitFunction = eventEmitter?.emit?.bind(eventEmitter) || null
return null
}
// Custom wrapper that captures emit function
const TestWrapper = ({ children }: { children: ReactNode }) => {
return (
<EventEmitterContextProvider>
<EmitCapture />
{children}
</EventEmitterContextProvider>
)
}
// Create a wrapper component with event emitter context
const createWrapper = () => {
return ({ children }: { children: ReactNode }) => (
<TestWrapper>
{children}
</TestWrapper>
)
}
describe('RegenerationModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const defaultProps = {
isShow: true,
onConfirm: vi.fn(),
onCancel: vi.fn(),
onClose: vi.fn(),
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing when isShow is true', () => {
// Arrange & Act
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Assert
expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
})
it('should not render content when isShow is false', () => {
// Arrange & Act
render(<RegenerationModal {...defaultProps} isShow={false} />, { wrapper: createWrapper() })
// Assert - Modal container might exist but content should not be visible
expect(screen.queryByText(/segment\.regenerationConfirmTitle/i)).not.toBeInTheDocument()
})
it('should render confirmation message', () => {
// Arrange & Act
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Assert
expect(screen.getByText(/segment\.regenerationConfirmMessage/i)).toBeInTheDocument()
})
it('should render cancel button in default state', () => {
// Arrange & Act
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Assert
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
})
it('should render regenerate button in default state', () => {
// Arrange & Act
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Assert
expect(screen.getByText(/operation\.regenerate/i)).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onCancel when cancel button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
render(<RegenerationModal {...defaultProps} onCancel={mockOnCancel} />, { wrapper: createWrapper() })
// Act
fireEvent.click(screen.getByText(/operation\.cancel/i))
// Assert
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('should call onConfirm when regenerate button is clicked', () => {
// Arrange
const mockOnConfirm = vi.fn()
render(<RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} />, { wrapper: createWrapper() })
// Act
fireEvent.click(screen.getByText(/operation\.regenerate/i))
// Assert
expect(mockOnConfirm).toHaveBeenCalledTimes(1)
})
})
// Modal content states - these would require event emitter manipulation
describe('Modal States', () => {
it('should show default content initially', () => {
// Arrange & Act
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Assert
expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle toggling isShow prop', () => {
// Arrange
const { rerender } = render(
<RegenerationModal {...defaultProps} isShow={true} />,
{ wrapper: createWrapper() },
)
expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
// Act
rerender(
<TestWrapper>
<RegenerationModal {...defaultProps} isShow={false} />
</TestWrapper>,
)
// Assert
expect(screen.queryByText(/segment\.regenerationConfirmTitle/i)).not.toBeInTheDocument()
})
it('should maintain handlers when rerendered', () => {
// Arrange
const mockOnConfirm = vi.fn()
const { rerender } = render(
<RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} />,
{ wrapper: createWrapper() },
)
// Act
rerender(
<TestWrapper>
<RegenerationModal {...defaultProps} onConfirm={mockOnConfirm} />
</TestWrapper>,
)
fireEvent.click(screen.getByText(/operation\.regenerate/i))
// Assert
expect(mockOnConfirm).toHaveBeenCalledTimes(1)
})
})
// Loading state
describe('Loading State', () => {
it('should show regenerating content when update-segment event is emitted', async () => {
// Arrange
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Act
act(() => {
if (emitFunction)
emitFunction('update-segment')
})
// Assert
await waitFor(() => {
expect(screen.getByText(/segment\.regeneratingTitle/i)).toBeInTheDocument()
})
})
it('should show regenerating message during loading', async () => {
// Arrange
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Act
act(() => {
if (emitFunction)
emitFunction('update-segment')
})
// Assert
await waitFor(() => {
expect(screen.getByText(/segment\.regeneratingMessage/i)).toBeInTheDocument()
})
})
it('should disable regenerate button during loading', async () => {
// Arrange
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Act
act(() => {
if (emitFunction)
emitFunction('update-segment')
})
// Assert
await waitFor(() => {
const button = screen.getByText(/operation\.regenerate/i).closest('button')
expect(button).toBeDisabled()
})
})
})
// Success state
describe('Success State', () => {
it('should show success content when update-segment-success event is emitted followed by done', async () => {
// Arrange
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Act - trigger loading then success then done
act(() => {
if (emitFunction) {
emitFunction('update-segment')
emitFunction('update-segment-success')
emitFunction('update-segment-done')
}
})
// Assert
await waitFor(() => {
expect(screen.getByText(/segment\.regenerationSuccessTitle/i)).toBeInTheDocument()
})
})
it('should show success message when completed', async () => {
// Arrange
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Act
act(() => {
if (emitFunction) {
emitFunction('update-segment')
emitFunction('update-segment-success')
emitFunction('update-segment-done')
}
})
// Assert
await waitFor(() => {
expect(screen.getByText(/segment\.regenerationSuccessMessage/i)).toBeInTheDocument()
})
})
it('should show close button with countdown in success state', async () => {
// Arrange
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Act
act(() => {
if (emitFunction) {
emitFunction('update-segment')
emitFunction('update-segment-success')
emitFunction('update-segment-done')
}
})
// Assert
await waitFor(() => {
expect(screen.getByText(/operation\.close/i)).toBeInTheDocument()
})
})
it('should call onClose when close button is clicked in success state', async () => {
// Arrange
const mockOnClose = vi.fn()
render(<RegenerationModal {...defaultProps} onClose={mockOnClose} />, { wrapper: createWrapper() })
// Act
act(() => {
if (emitFunction) {
emitFunction('update-segment')
emitFunction('update-segment-success')
emitFunction('update-segment-done')
}
})
await waitFor(() => {
expect(screen.getByText(/operation\.close/i)).toBeInTheDocument()
})
fireEvent.click(screen.getByText(/operation\.close/i))
// Assert
expect(mockOnClose).toHaveBeenCalled()
})
})
// State transitions
describe('State Transitions', () => {
it('should return to default content when update fails (no success event)', async () => {
// Arrange
render(<RegenerationModal {...defaultProps} />, { wrapper: createWrapper() })
// Act - trigger loading then done without success
act(() => {
if (emitFunction) {
emitFunction('update-segment')
emitFunction('update-segment-done')
}
})
// Assert - should show default content
await waitFor(() => {
expect(screen.getByText(/segment\.regenerationConfirmTitle/i)).toBeInTheDocument()
})
})
})
})

View File

@ -1,215 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import SegmentIndexTag from './segment-index-tag'
describe('SegmentIndexTag', () => {
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<SegmentIndexTag positionId={1} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render the Chunk icon', () => {
// Arrange & Act
const { container } = render(<SegmentIndexTag positionId={1} />)
// Assert
const icon = container.querySelector('.h-3.w-3')
expect(icon).toBeInTheDocument()
})
it('should render with correct container classes', () => {
// Arrange & Act
const { container } = render(<SegmentIndexTag positionId={1} />)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('items-center')
})
})
// Props tests
describe('Props', () => {
it('should render position ID with default prefix', () => {
// Arrange & Act
render(<SegmentIndexTag positionId={5} />)
// Assert - default prefix is 'Chunk'
expect(screen.getByText('Chunk-05')).toBeInTheDocument()
})
it('should render position ID without padding for two-digit numbers', () => {
// Arrange & Act
render(<SegmentIndexTag positionId={15} />)
// Assert
expect(screen.getByText('Chunk-15')).toBeInTheDocument()
})
it('should render position ID without padding for three-digit numbers', () => {
// Arrange & Act
render(<SegmentIndexTag positionId={123} />)
// Assert
expect(screen.getByText('Chunk-123')).toBeInTheDocument()
})
it('should render custom label when provided', () => {
// Arrange & Act
render(<SegmentIndexTag positionId={1} label="Custom Label" />)
// Assert
expect(screen.getByText('Custom Label')).toBeInTheDocument()
})
it('should use custom labelPrefix', () => {
// Arrange & Act
render(<SegmentIndexTag positionId={3} labelPrefix="Segment" />)
// Assert
expect(screen.getByText('Segment-03')).toBeInTheDocument()
})
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(
<SegmentIndexTag positionId={1} className="custom-class" />,
)
// Assert
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-class')
})
it('should apply custom iconClassName', () => {
// Arrange & Act
const { container } = render(
<SegmentIndexTag positionId={1} iconClassName="custom-icon-class" />,
)
// Assert
const icon = container.querySelector('.custom-icon-class')
expect(icon).toBeInTheDocument()
})
it('should apply custom labelClassName', () => {
// Arrange & Act
const { container } = render(
<SegmentIndexTag positionId={1} labelClassName="custom-label-class" />,
)
// Assert
const label = container.querySelector('.custom-label-class')
expect(label).toBeInTheDocument()
})
it('should handle string positionId', () => {
// Arrange & Act
render(<SegmentIndexTag positionId="7" />)
// Assert
expect(screen.getByText('Chunk-07')).toBeInTheDocument()
})
})
// Memoization tests
describe('Memoization', () => {
it('should compute localPositionId based on positionId and labelPrefix', () => {
// Arrange & Act
const { rerender } = render(<SegmentIndexTag positionId={1} />)
expect(screen.getByText('Chunk-01')).toBeInTheDocument()
// Act - change positionId
rerender(<SegmentIndexTag positionId={2} />)
// Assert
expect(screen.getByText('Chunk-02')).toBeInTheDocument()
})
it('should update when labelPrefix changes', () => {
// Arrange & Act
const { rerender } = render(<SegmentIndexTag positionId={1} labelPrefix="Chunk" />)
expect(screen.getByText('Chunk-01')).toBeInTheDocument()
// Act - change labelPrefix
rerender(<SegmentIndexTag positionId={1} labelPrefix="Part" />)
// Assert
expect(screen.getByText('Part-01')).toBeInTheDocument()
})
})
// Structure tests
describe('Structure', () => {
it('should render icon with tertiary text color', () => {
// Arrange & Act
const { container } = render(<SegmentIndexTag positionId={1} />)
// Assert
const icon = container.querySelector('.text-text-tertiary')
expect(icon).toBeInTheDocument()
})
it('should render label with xs medium font styling', () => {
// Arrange & Act
const { container } = render(<SegmentIndexTag positionId={1} />)
// Assert
const label = container.querySelector('.system-xs-medium')
expect(label).toBeInTheDocument()
})
it('should render icon with margin-right spacing', () => {
// Arrange & Act
const { container } = render(<SegmentIndexTag positionId={1} />)
// Assert
const icon = container.querySelector('.mr-0\\.5')
expect(icon).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle positionId of 0', () => {
// Arrange & Act
render(<SegmentIndexTag positionId={0} />)
// Assert
expect(screen.getByText('Chunk-00')).toBeInTheDocument()
})
it('should handle undefined positionId', () => {
// Arrange & Act
render(<SegmentIndexTag />)
// Assert - should display 'Chunk-undefined' or similar
expect(screen.getByText(/Chunk-/)).toBeInTheDocument()
})
it('should prioritize label over computed positionId', () => {
// Arrange & Act
render(<SegmentIndexTag positionId={99} label="Override" />)
// Assert
expect(screen.getByText('Override')).toBeInTheDocument()
expect(screen.queryByText('Chunk-99')).not.toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender, container } = render(<SegmentIndexTag positionId={1} />)
// Act
rerender(<SegmentIndexTag positionId={1} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
})
})

View File

@ -1,151 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Tag from './tag'
describe('Tag', () => {
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<Tag text="test" />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render the hash symbol', () => {
// Arrange & Act
render(<Tag text="test" />)
// Assert
expect(screen.getByText('#')).toBeInTheDocument()
})
it('should render the text content', () => {
// Arrange & Act
render(<Tag text="keyword" />)
// Assert
expect(screen.getByText('keyword')).toBeInTheDocument()
})
it('should render with correct base styling classes', () => {
// Arrange & Act
const { container } = render(<Tag text="test" />)
// Assert
const tagElement = container.firstChild as HTMLElement
expect(tagElement).toHaveClass('inline-flex')
expect(tagElement).toHaveClass('items-center')
expect(tagElement).toHaveClass('gap-x-0.5')
})
})
// Props tests
describe('Props', () => {
it('should apply custom className', () => {
// Arrange & Act
const { container } = render(<Tag text="test" className="custom-class" />)
// Assert
const tagElement = container.firstChild as HTMLElement
expect(tagElement).toHaveClass('custom-class')
})
it('should render different text values', () => {
// Arrange & Act
const { rerender } = render(<Tag text="first" />)
expect(screen.getByText('first')).toBeInTheDocument()
// Act
rerender(<Tag text="second" />)
// Assert
expect(screen.getByText('second')).toBeInTheDocument()
})
})
// Structure tests
describe('Structure', () => {
it('should render hash with quaternary text color', () => {
// Arrange & Act
const { container } = render(<Tag text="test" />)
// Assert
const hashSpan = container.querySelector('.text-text-quaternary')
expect(hashSpan).toBeInTheDocument()
expect(hashSpan).toHaveTextContent('#')
})
it('should render text with tertiary text color', () => {
// Arrange & Act
const { container } = render(<Tag text="test" />)
// Assert
const textSpan = container.querySelector('.text-text-tertiary')
expect(textSpan).toBeInTheDocument()
expect(textSpan).toHaveTextContent('test')
})
it('should have truncate class for text overflow', () => {
// Arrange & Act
const { container } = render(<Tag text="very-long-text-that-might-overflow" />)
// Assert
const textSpan = container.querySelector('.truncate')
expect(textSpan).toBeInTheDocument()
})
it('should have max-width constraint on text', () => {
// Arrange & Act
const { container } = render(<Tag text="test" />)
// Assert
const textSpan = container.querySelector('.max-w-12')
expect(textSpan).toBeInTheDocument()
})
})
// Memoization tests
describe('Memoization', () => {
it('should render consistently with same props', () => {
// Arrange & Act
const { container: container1 } = render(<Tag text="test" />)
const { container: container2 } = render(<Tag text="test" />)
// Assert
expect(container1.firstChild?.textContent).toBe(container2.firstChild?.textContent)
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle empty text', () => {
// Arrange & Act
render(<Tag text="" />)
// Assert - should still render the hash symbol
expect(screen.getByText('#')).toBeInTheDocument()
})
it('should handle special characters in text', () => {
// Arrange & Act
render(<Tag text="test-tag_1" />)
// Assert
expect(screen.getByText('test-tag_1')).toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<Tag text="test" />)
// Act
rerender(<Tag text="test" />)
// Assert
expect(screen.getByText('#')).toBeInTheDocument()
expect(screen.getByText('test')).toBeInTheDocument()
})
})
})

View File

@ -1,130 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DisplayToggle from './display-toggle'
describe('DisplayToggle', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
render(<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
it('should render button with proper styling', () => {
// Arrange & Act
render(<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />)
// Assert
const button = screen.getByRole('button')
expect(button).toHaveClass('flex')
expect(button).toHaveClass('items-center')
expect(button).toHaveClass('justify-center')
expect(button).toHaveClass('rounded-lg')
})
})
// Props tests
describe('Props', () => {
it('should render expand icon when isCollapsed is true', () => {
// Arrange & Act
const { container } = render(
<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />,
)
// Assert - RiLineHeight icon for expand
const icon = container.querySelector('.h-4.w-4')
expect(icon).toBeInTheDocument()
})
it('should render collapse icon when isCollapsed is false', () => {
// Arrange & Act
const { container } = render(
<DisplayToggle isCollapsed={false} toggleCollapsed={vi.fn()} />,
)
// Assert - Collapse icon
const icon = container.querySelector('.h-4.w-4')
expect(icon).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call toggleCollapsed when button is clicked', () => {
// Arrange
const mockToggle = vi.fn()
render(<DisplayToggle isCollapsed={true} toggleCollapsed={mockToggle} />)
// Act
fireEvent.click(screen.getByRole('button'))
// Assert
expect(mockToggle).toHaveBeenCalledTimes(1)
})
it('should call toggleCollapsed on multiple clicks', () => {
// Arrange
const mockToggle = vi.fn()
render(<DisplayToggle isCollapsed={true} toggleCollapsed={mockToggle} />)
// Act
const button = screen.getByRole('button')
fireEvent.click(button)
fireEvent.click(button)
fireEvent.click(button)
// Assert
expect(mockToggle).toHaveBeenCalledTimes(3)
})
})
// Tooltip tests
describe('Tooltip', () => {
it('should render with tooltip wrapper', () => {
// Arrange & Act
const { container } = render(
<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />,
)
// Assert - Tooltip renders a wrapper around button
expect(container.firstChild).toBeInTheDocument()
})
})
// Edge cases
describe('Edge Cases', () => {
it('should toggle icon when isCollapsed prop changes', () => {
// Arrange
const { rerender, container } = render(
<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />,
)
// Act
rerender(<DisplayToggle isCollapsed={false} toggleCollapsed={vi.fn()} />)
// Assert - icon should still be present
const icon = container.querySelector('.h-4.w-4')
expect(icon).toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(
<DisplayToggle isCollapsed={true} toggleCollapsed={vi.fn()} />,
)
// Act
rerender(<DisplayToggle isCollapsed={false} toggleCollapsed={vi.fn()} />)
// Assert
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
})

View File

@ -1,507 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NewChildSegmentModal from './new-child-segment'
// Mock next/navigation
vi.mock('next/navigation', () => ({
useParams: () => ({
datasetId: 'test-dataset-id',
documentId: 'test-document-id',
}),
}))
// Mock ToastContext
const mockNotify = vi.fn()
vi.mock('use-context-selector', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
useContext: () => ({ notify: mockNotify }),
}
})
// Mock document context
let mockParentMode = 'paragraph'
vi.mock('../context', () => ({
useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => {
return selector({ parentMode: mockParentMode })
},
}))
// Mock segment list context
let mockFullScreen = false
const mockToggleFullScreen = vi.fn()
vi.mock('./index', () => ({
useSegmentListContext: (selector: (state: { fullScreen: boolean, toggleFullScreen: () => void }) => unknown) => {
const state = {
fullScreen: mockFullScreen,
toggleFullScreen: mockToggleFullScreen,
}
return selector(state)
},
}))
// Mock useAddChildSegment
const mockAddChildSegment = vi.fn()
vi.mock('@/service/knowledge/use-segment', () => ({
useAddChildSegment: () => ({
mutateAsync: mockAddChildSegment,
}),
}))
// Mock app store
vi.mock('@/app/components/app/store', () => ({
useStore: () => ({ appSidebarExpand: 'expand' }),
}))
// Mock child components
vi.mock('./common/action-buttons', () => ({
default: ({ handleCancel, handleSave, loading, actionType, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string, isChildChunk?: boolean }) => (
<div data-testid="action-buttons">
<button onClick={handleCancel} data-testid="cancel-btn">Cancel</button>
<button onClick={handleSave} disabled={loading} data-testid="save-btn">
{loading ? 'Saving...' : 'Save'}
</button>
<span data-testid="action-type">{actionType}</span>
<span data-testid="is-child-chunk">{isChildChunk ? 'true' : 'false'}</span>
</div>
),
}))
vi.mock('./common/add-another', () => ({
default: ({ isChecked, onCheck, className }: { isChecked: boolean, onCheck: () => void, className?: string }) => (
<div data-testid="add-another" className={className}>
<input
type="checkbox"
checked={isChecked}
onChange={onCheck}
data-testid="add-another-checkbox"
/>
</div>
),
}))
vi.mock('./common/chunk-content', () => ({
default: ({ question, onQuestionChange, isEditMode }: { question: string, onQuestionChange: (v: string) => void, isEditMode: boolean }) => (
<div data-testid="chunk-content">
<input
data-testid="content-input"
value={question}
onChange={e => onQuestionChange(e.target.value)}
/>
<span data-testid="edit-mode">{isEditMode ? 'editing' : 'viewing'}</span>
</div>
),
}))
vi.mock('./common/dot', () => ({
default: () => <span data-testid="dot"></span>,
}))
vi.mock('./common/segment-index-tag', () => ({
SegmentIndexTag: ({ label }: { label: string }) => <span data-testid="segment-index-tag">{label}</span>,
}))
describe('NewChildSegmentModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFullScreen = false
mockParentMode = 'paragraph'
})
const defaultProps = {
chunkId: 'chunk-1',
onCancel: vi.fn(),
onSave: vi.fn(),
viewNewlyAddedChildChunk: vi.fn(),
}
// Rendering tests
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange & Act
const { container } = render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should render add child chunk title', () => {
// Arrange & Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByText(/segment\.addChildChunk/i)).toBeInTheDocument()
})
it('should render chunk content component', () => {
// Arrange & Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
})
it('should render segment index tag with new child chunk label', () => {
// Arrange & Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('segment-index-tag')).toBeInTheDocument()
})
it('should render add another checkbox', () => {
// Arrange & Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('add-another')).toBeInTheDocument()
})
})
// User Interactions
describe('User Interactions', () => {
it('should call onCancel when close button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
const { container } = render(
<NewChildSegmentModal {...defaultProps} onCancel={mockOnCancel} />,
)
// Act
const closeButtons = container.querySelectorAll('.cursor-pointer')
if (closeButtons.length > 1)
fireEvent.click(closeButtons[1])
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call toggleFullScreen when expand button is clicked', () => {
// Arrange
const { container } = render(<NewChildSegmentModal {...defaultProps} />)
// Act
const expandButtons = container.querySelectorAll('.cursor-pointer')
if (expandButtons.length > 0)
fireEvent.click(expandButtons[0])
// Assert
expect(mockToggleFullScreen).toHaveBeenCalled()
})
it('should update content when input changes', () => {
// Arrange
render(<NewChildSegmentModal {...defaultProps} />)
// Act
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'New content' },
})
// Assert
expect(screen.getByTestId('content-input')).toHaveValue('New content')
})
it('should toggle add another checkbox', () => {
// Arrange
render(<NewChildSegmentModal {...defaultProps} />)
const checkbox = screen.getByTestId('add-another-checkbox')
// Act
fireEvent.click(checkbox)
// Assert
expect(checkbox).toBeInTheDocument()
})
})
// Save validation
describe('Save Validation', () => {
it('should show error when content is empty', async () => {
// Arrange
render(<NewChildSegmentModal {...defaultProps} />)
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
})
})
// Successful save
describe('Successful Save', () => {
it('should call addChildSegment when valid content is provided', async () => {
// Arrange
mockAddChildSegment.mockImplementation((_params, options) => {
options.onSuccess({ data: { id: 'new-child-id' } })
options.onSettled()
return Promise.resolve()
})
render(<NewChildSegmentModal {...defaultProps} />)
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Valid content' },
})
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
await waitFor(() => {
expect(mockAddChildSegment).toHaveBeenCalledWith(
expect.objectContaining({
datasetId: 'test-dataset-id',
documentId: 'test-document-id',
segmentId: 'chunk-1',
body: expect.objectContaining({
content: 'Valid content',
}),
}),
expect.any(Object),
)
})
})
it('should show success notification after save', async () => {
// Arrange
mockAddChildSegment.mockImplementation((_params, options) => {
options.onSuccess({ data: { id: 'new-child-id' } })
options.onSettled()
return Promise.resolve()
})
render(<NewChildSegmentModal {...defaultProps} />)
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Valid content' },
})
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
})
})
})
// Full screen mode
describe('Full Screen Mode', () => {
it('should show action buttons in header when fullScreen', () => {
// Arrange
mockFullScreen = true
// Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('action-buttons')).toBeInTheDocument()
})
it('should show add another in header when fullScreen', () => {
// Arrange
mockFullScreen = true
// Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('add-another')).toBeInTheDocument()
})
})
// Props
describe('Props', () => {
it('should pass actionType add to ActionButtons', () => {
// Arrange & Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('action-type')).toHaveTextContent('add')
})
it('should pass isChildChunk true to ActionButtons', () => {
// Arrange & Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('is-child-chunk')).toHaveTextContent('true')
})
it('should pass isEditMode true to ChunkContent', () => {
// Arrange & Act
render(<NewChildSegmentModal {...defaultProps} />)
// Assert
expect(screen.getByTestId('edit-mode')).toHaveTextContent('editing')
})
})
// Edge cases
describe('Edge Cases', () => {
it('should handle undefined viewNewlyAddedChildChunk', () => {
// Arrange
const props = { ...defaultProps, viewNewlyAddedChildChunk: undefined }
// Act
const { container } = render(<NewChildSegmentModal {...props} />)
// Assert
expect(container.firstChild).toBeInTheDocument()
})
it('should maintain structure when rerendered', () => {
// Arrange
const { rerender } = render(<NewChildSegmentModal {...defaultProps} />)
// Act
rerender(<NewChildSegmentModal {...defaultProps} chunkId="chunk-2" />)
// Assert
expect(screen.getByTestId('chunk-content')).toBeInTheDocument()
})
})
// Add another behavior
describe('Add Another Behavior', () => {
it('should close modal when add another is unchecked after save', async () => {
// Arrange
const mockOnCancel = vi.fn()
mockAddChildSegment.mockImplementation((_params, options) => {
options.onSuccess({ data: { id: 'new-child-id' } })
options.onSettled()
return Promise.resolve()
})
render(<NewChildSegmentModal {...defaultProps} onCancel={mockOnCancel} />)
// Uncheck add another
fireEvent.click(screen.getByTestId('add-another-checkbox'))
// Enter valid content
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Valid content' },
})
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert - modal should close
await waitFor(() => {
expect(mockOnCancel).toHaveBeenCalled()
})
})
it('should not close modal when add another is checked after save', async () => {
// Arrange
const mockOnCancel = vi.fn()
mockAddChildSegment.mockImplementation((_params, options) => {
options.onSuccess({ data: { id: 'new-child-id' } })
options.onSettled()
return Promise.resolve()
})
render(<NewChildSegmentModal {...defaultProps} onCancel={mockOnCancel} />)
// Enter valid content (add another is checked by default)
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Valid content' },
})
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert - modal should not close, only content cleared
await waitFor(() => {
expect(screen.getByTestId('content-input')).toHaveValue('')
})
})
})
// View newly added chunk
describe('View Newly Added Chunk', () => {
it('should show custom button in full-doc mode after save', async () => {
// Arrange
mockParentMode = 'full-doc'
mockAddChildSegment.mockImplementation((_params, options) => {
options.onSuccess({ data: { id: 'new-child-id' } })
options.onSettled()
return Promise.resolve()
})
render(<NewChildSegmentModal {...defaultProps} />)
// Enter valid content
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Valid content' },
})
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert - success notification with custom component
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
customComponent: expect.anything(),
}),
)
})
})
it('should not show custom button in paragraph mode after save', async () => {
// Arrange
mockParentMode = 'paragraph'
const mockOnSave = vi.fn()
mockAddChildSegment.mockImplementation((_params, options) => {
options.onSuccess({ data: { id: 'new-child-id' } })
options.onSettled()
return Promise.resolve()
})
render(<NewChildSegmentModal {...defaultProps} onSave={mockOnSave} />)
// Enter valid content
fireEvent.change(screen.getByTestId('content-input'), {
target: { value: 'Valid content' },
})
// Act
fireEvent.click(screen.getByTestId('save-btn'))
// Assert - onSave should be called with data
await waitFor(() => {
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ id: 'new-child-id' }))
})
})
})
// Cancel behavior
describe('Cancel Behavior', () => {
it('should call onCancel when close button is clicked', () => {
// Arrange
const mockOnCancel = vi.fn()
render(<NewChildSegmentModal {...defaultProps} onCancel={mockOnCancel} />)
// Act
fireEvent.click(screen.getByTestId('cancel-btn'))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
})
})

Some files were not shown because too many files have changed in this diff Show More