mirror of
https://github.com/langgenius/dify.git
synced 2026-01-28 15:56:00 +08:00
Compare commits
10 Commits
refactor/c
...
test/devel
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f6befd0a2 | |||
| 0c1ffe8eea | |||
| 69eed7b6e5 | |||
| 5b960eb0d7 | |||
| 031c0134c5 | |||
| a8ecd540b4 | |||
| b66bd5f5a8 | |||
| c8abe1c306 | |||
| eca26a9b9b | |||
| febc9b930d |
@ -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
|
||||
from core.workflow.graph_events import GraphEngineEvent, GraphNodeEventBase
|
||||
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: # noqa: B027
|
||||
def on_node_run_start(self, node: Node) -> None:
|
||||
"""
|
||||
Called immediately before a node begins execution.
|
||||
|
||||
@ -109,9 +109,11 @@ class GraphEngineLayer(ABC):
|
||||
Args:
|
||||
node: The node instance about to be executed
|
||||
"""
|
||||
pass
|
||||
return
|
||||
|
||||
def on_node_run_end(self, node: Node, error: Exception | None) -> None: # noqa: B027
|
||||
def on_node_run_end(
|
||||
self, node: Node, error: Exception | None, result_event: GraphNodeEventBase | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Called after a node finishes execution.
|
||||
|
||||
@ -121,5 +123,6 @@ 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
|
||||
"""
|
||||
pass
|
||||
return
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
"""
|
||||
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))
|
||||
@ -18,12 +18,15 @@ 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_engine.layers.node_parsers import (
|
||||
from core.workflow.graph_events import GraphNodeEventBase
|
||||
from core.workflow.nodes.base.node import Node
|
||||
from extensions.otel.parser 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__)
|
||||
@ -72,6 +75,8 @@ 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:
|
||||
@ -119,7 +124,9 @@ 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) -> None:
|
||||
def on_node_run_end(
|
||||
self, node: Node, error: Exception | None, result_event: GraphNodeEventBase | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Called when a node finishes execution.
|
||||
|
||||
@ -139,7 +146,7 @@ class ObservabilityLayer(GraphEngineLayer):
|
||||
span = node_context.span
|
||||
parser = self._get_parser(node)
|
||||
try:
|
||||
parser.parse(node=node, span=span, error=error)
|
||||
parser.parse(node=node, span=span, error=error, result_event=result_event)
|
||||
span.end()
|
||||
finally:
|
||||
token = node_context.token
|
||||
|
||||
@ -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
|
||||
from core.workflow.graph_events import GraphNodeEventBase, NodeRunFailedEvent, is_node_result_event
|
||||
from core.workflow.nodes.base.node import Node
|
||||
|
||||
from .ready_queue import ReadyQueue
|
||||
@ -131,6 +131,7 @@ 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:
|
||||
@ -140,22 +141,26 @@ 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)
|
||||
self._invoke_node_run_end_hooks(node, error, result_event)
|
||||
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)
|
||||
self._invoke_node_run_end_hooks(node, error, result_event)
|
||||
|
||||
def _invoke_node_run_start_hooks(self, node: Node) -> None:
|
||||
"""Invoke on_node_run_start hooks for all layers."""
|
||||
@ -166,11 +171,13 @@ 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) -> None:
|
||||
def _invoke_node_run_end_hooks(
|
||||
self, node: Node, error: Exception | None, result_event: GraphNodeEventBase | None = None
|
||||
) -> None:
|
||||
"""Invoke on_node_run_end hooks for all layers."""
|
||||
for layer in self._layers:
|
||||
try:
|
||||
layer.on_node_run_end(node, error)
|
||||
layer.on_node_run_end(node, error, result_event)
|
||||
except Exception:
|
||||
# Silently ignore layer errors to prevent disrupting node execution
|
||||
continue
|
||||
|
||||
@ -44,6 +44,7 @@ from .node import (
|
||||
NodeRunStartedEvent,
|
||||
NodeRunStreamChunkEvent,
|
||||
NodeRunSucceededEvent,
|
||||
is_node_result_event,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@ -73,4 +74,5 @@ __all__ = [
|
||||
"NodeRunStartedEvent",
|
||||
"NodeRunStreamChunkEvent",
|
||||
"NodeRunSucceededEvent",
|
||||
"is_node_result_event",
|
||||
]
|
||||
|
||||
@ -56,3 +56,26 @@ 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,
|
||||
),
|
||||
)
|
||||
|
||||
20
api/extensions/otel/parser/__init__.py
Normal file
20
api/extensions/otel/parser/__init__.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""
|
||||
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",
|
||||
]
|
||||
117
api/extensions/otel/parser/base.py
Normal file
117
api/extensions/otel/parser/base.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""
|
||||
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))
|
||||
155
api/extensions/otel/parser/llm.py
Normal file
155
api/extensions/otel/parser/llm.py
Normal file
@ -0,0 +1,155 @@
|
||||
"""
|
||||
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)
|
||||
105
api/extensions/otel/parser/retrieval.py
Normal file
105
api/extensions/otel/parser/retrieval.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""
|
||||
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)
|
||||
47
api/extensions/otel/parser/tool.py
Normal file
47
api/extensions/otel/parser/tool.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""
|
||||
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))
|
||||
@ -1,6 +1,13 @@
|
||||
"""Semantic convention shortcuts for Dify-specific spans."""
|
||||
|
||||
from .dify import DifySpanAttributes
|
||||
from .gen_ai import GenAIAttributes
|
||||
from .gen_ai import ChainAttributes, GenAIAttributes, LLMAttributes, RetrieverAttributes, ToolAttributes
|
||||
|
||||
__all__ = ["DifySpanAttributes", "GenAIAttributes"]
|
||||
__all__ = [
|
||||
"ChainAttributes",
|
||||
"DifySpanAttributes",
|
||||
"GenAIAttributes",
|
||||
"LLMAttributes",
|
||||
"RetrieverAttributes",
|
||||
"ToolAttributes",
|
||||
]
|
||||
|
||||
@ -62,3 +62,37 @@ 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."""
|
||||
|
||||
@ -99,3 +99,38 @@ 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,
|
||||
)
|
||||
|
||||
@ -4,7 +4,8 @@ Tests for ObservabilityLayer.
|
||||
Test coverage:
|
||||
- Initialization and enable/disable logic
|
||||
- Node span lifecycle (start, end, error handling)
|
||||
- Parser integration (default and tool-specific)
|
||||
- Parser integration (default, tool, LLM, and retrieval parsers)
|
||||
- Result event parameter extraction (inputs/outputs)
|
||||
- Graph lifecycle management
|
||||
- Disabled mode behavior
|
||||
"""
|
||||
@ -134,9 +135,101 @@ class TestObservabilityLayerParserIntegration:
|
||||
assert len(spans) == 1
|
||||
attrs = spans[0].attributes
|
||||
assert attrs["node.id"] == mock_tool_node.id
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
class TestObservabilityLayerGraphLifecycle:
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
'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
|
||||
@ -1,737 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,599 +0,0 @@
|
||||
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' }),
|
||||
],
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,6 @@
|
||||
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,
|
||||
@ -41,7 +40,13 @@ const DebugWithMultipleModel = () => {
|
||||
if (checkCanSend && !checkCanSend())
|
||||
return
|
||||
|
||||
eventEmitter?.emit({ type: APP_CHAT_WITH_MULTIPLE_MODEL, payload: { message, files } } as any) // eslint-disable-line ts/no-explicit-any
|
||||
eventEmitter?.emit({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL,
|
||||
payload: {
|
||||
message,
|
||||
files,
|
||||
},
|
||||
} as any)
|
||||
}, [eventEmitter, checkCanSend])
|
||||
|
||||
const twoLine = multipleModelConfigs.length === 2
|
||||
@ -142,7 +147,7 @@ const DebugWithMultipleModel = () => {
|
||||
showFileUpload={false}
|
||||
onFeatureBarClick={setShowAppConfigureFeaturesModal}
|
||||
onSend={handleSend}
|
||||
speechToTextConfig={speech2text as EnableType}
|
||||
speechToTextConfig={speech2text as any}
|
||||
visionConfig={file}
|
||||
inputs={inputs}
|
||||
inputsForm={inputsForm}
|
||||
|
||||
@ -1,436 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,621 +0,0 @@
|
||||
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 },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -6,26 +6,18 @@ 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'
|
||||
@ -170,111 +162,3 @@ 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
|
||||
}
|
||||
|
||||
@ -3,39 +3,54 @@ 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, PromptVariable } from '@/models/debug'
|
||||
import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
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 { 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 { IS_CE_EDITION } from '@/config'
|
||||
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG, IS_CE_EDITION } from '@/config'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
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 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
|
||||
@ -56,17 +71,33 @@ 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 () => {
|
||||
@ -74,77 +105,226 @@ const Debug: FC<IDebug> = ({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// UI state
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
|
||||
const [isShowFormattingChangeConfirm, setIsShowFormattingChangeConfirm] = useState(false)
|
||||
const [isShowCannotQueryDataset, setShowCannotQueryDataset] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const debugWithSingleModelRef = React.useRef<DebugWithSingleModelRefType>(null!)
|
||||
|
||||
// Hooks
|
||||
const { checkCanSend } = useInputValidation()
|
||||
const { isShowFormattingChangeConfirm, handleConfirm, handleCancel } = useFormattingChangeConfirm()
|
||||
const modalWidth = useModalWidth(containerRef)
|
||||
|
||||
// Wrapper for checkCanSend that uses current completionFiles
|
||||
const [completionFilesForValidation, setCompletionFilesForValidation] = useState<VisionFile[]>([])
|
||||
const checkCanSendWithFiles = useCallback(() => {
|
||||
return checkCanSend(inputs, completionFilesForValidation)
|
||||
}, [checkCanSend, inputs, completionFilesForValidation])
|
||||
|
||||
const {
|
||||
isResponding,
|
||||
completionRes,
|
||||
messageId,
|
||||
completionFiles,
|
||||
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])
|
||||
if (formattingChanged)
|
||||
setIsShowFormattingChangeConfirm(true)
|
||||
}, [formattingChanged])
|
||||
|
||||
// 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()
|
||||
|
||||
// Computed values
|
||||
const varList = modelConfig.configs.prompt_variables.map((item: PromptVariable) => ({
|
||||
label: item.key,
|
||||
value: inputs[item.key],
|
||||
}))
|
||||
|
||||
// Handlers
|
||||
const handleClearConversation = useCallback(() => {
|
||||
const debugWithSingleModelRef = React.useRef<DebugWithSingleModelRefType>(null!)
|
||||
const handleClearConversation = () => {
|
||||
debugWithSingleModelRef.current?.handleRestart()
|
||||
}, [])
|
||||
|
||||
const clearConversation = useCallback(async () => {
|
||||
}
|
||||
const clearConversation = async () => {
|
||||
if (debugWithMultipleModel) {
|
||||
eventEmitter?.emit({ type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART } as any) // eslint-disable-line ts/no-explicit-any
|
||||
eventEmitter?.emit({
|
||||
type: APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
|
||||
} as any)
|
||||
return
|
||||
}
|
||||
|
||||
handleClearConversation()
|
||||
}, [debugWithMultipleModel, eventEmitter, handleClearConversation])
|
||||
}
|
||||
|
||||
const handleFormattingConfirm = useCallback(() => {
|
||||
handleConfirm(clearConversation)
|
||||
}, [handleConfirm, clearConversation])
|
||||
const handleConfirm = () => {
|
||||
clearConversation()
|
||||
setIsShowFormattingChangeConfirm(false)
|
||||
setFormattingChanged(false)
|
||||
}
|
||||
|
||||
const handleChangeToSingleModel = useCallback((item: ModelAndParameter) => {
|
||||
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
|
||||
}, [
|
||||
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],
|
||||
}
|
||||
})
|
||||
|
||||
const { textGenerationModelList } = useProviderContext()
|
||||
const handleChangeToSingleModel = (item: ModelAndParameter) => {
|
||||
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === item.provider)
|
||||
const currentModel = currentProvider?.models.find(model => model.model === item.model)
|
||||
|
||||
@ -155,18 +335,26 @@ const Debug: FC<IDebug> = ({
|
||||
features: currentModel?.features,
|
||||
})
|
||||
modelParameterParams.onCompletionParamsChange(item.parameters)
|
||||
onMultipleModelConfigsChange(false, [])
|
||||
}, [modelParameterParams, onMultipleModelConfigsChange, textGenerationModelList])
|
||||
onMultipleModelConfigsChange(
|
||||
false,
|
||||
[],
|
||||
)
|
||||
}
|
||||
|
||||
const handleVisionConfigInMultipleModel = useCallback(() => {
|
||||
if (debugWithMultipleModel && mode) {
|
||||
const supportedVision = multipleModelConfigs.some((config) => {
|
||||
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === config.provider)
|
||||
const currentModel = currentProvider?.models.find(model => model.model === config.model)
|
||||
const supportedVision = multipleModelConfigs.some((modelConfig) => {
|
||||
const currentProvider = textGenerationModelList.find(modelItem => modelItem.provider === modelConfig.provider)
|
||||
const currentModel = currentProvider?.models.find(model => model.model === modelConfig.model)
|
||||
|
||||
return currentModel?.features?.includes(ModelFeatureEnum.vision)
|
||||
})
|
||||
const { features: storeFeatures, setFeatures } = featuresStore!.getState()
|
||||
const newFeatures = produce(storeFeatures, (draft) => {
|
||||
const {
|
||||
features,
|
||||
setFeatures,
|
||||
} = featuresStore!.getState()
|
||||
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft.file = {
|
||||
...draft.file,
|
||||
enabled: supportedVision,
|
||||
@ -180,131 +368,210 @@ const Debug: FC<IDebug> = ({
|
||||
handleVisionConfigInMultipleModel()
|
||||
}, [multipleModelConfigs, mode, handleVisionConfigInMultipleModel])
|
||||
|
||||
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 { 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 handleAddModel = useCallback(() => {
|
||||
onMultipleModelConfigsChange(true, [...multipleModelConfigs, { id: `${Date.now()}`, model: '', provider: '', parameters: {} }])
|
||||
}, [multipleModelConfigs, onMultipleModelConfigsChange])
|
||||
const adjustModalWidth = () => {
|
||||
if (ref.current)
|
||||
setWidth(document.body.clientWidth - (ref.current?.clientWidth + 16) - 8)
|
||||
}
|
||||
|
||||
const handleClosePromptLogModal = useCallback(() => {
|
||||
setCurrentLogItem()
|
||||
setShowPromptLogModal(false)
|
||||
}, [setCurrentLogItem, setShowPromptLogModal])
|
||||
useEffect(() => {
|
||||
adjustModalWidth()
|
||||
}, [])
|
||||
|
||||
const handleCloseAgentLogModal = useCallback(() => {
|
||||
setCurrentLogItem()
|
||||
setShowAgentLogModal(false)
|
||||
}, [setCurrentLogItem, setShowAgentLogModal])
|
||||
|
||||
const isShowTextToSpeech = features.text2speech?.enabled && !!text2speechDefaultModel
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0">
|
||||
<DebugHeader
|
||||
readonly={readonly}
|
||||
mode={mode}
|
||||
debugWithMultipleModel={debugWithMultipleModel}
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
varListLength={varList.length}
|
||||
expanded={expanded}
|
||||
onExpandedChange={setExpanded}
|
||||
onClearConversation={clearConversation}
|
||||
onAddModel={handleAddModel}
|
||||
/>
|
||||
<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>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
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={containerRef}>
|
||||
<DebugWithMultipleModel
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
|
||||
onDebugWithMultipleModelChange={handleChangeToSingleModel}
|
||||
checkCanSend={checkCanSendWithFiles}
|
||||
/>
|
||||
{showPromptLogModal && (
|
||||
<PromptLogModal
|
||||
width={modalWidth}
|
||||
currentLogItem={currentLogItem}
|
||||
onCancel={handleClosePromptLogModal}
|
||||
{
|
||||
debugWithMultipleModel && (
|
||||
<div className="mt-3 grow overflow-hidden" ref={ref}>
|
||||
<DebugWithMultipleModel
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onMultipleModelConfigsChange={onMultipleModelConfigsChange}
|
||||
onDebugWithMultipleModelChange={handleChangeToSingleModel}
|
||||
checkCanSend={checkCanSend}
|
||||
/>
|
||||
)}
|
||||
{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}
|
||||
{showPromptLogModal && (
|
||||
<PromptLogModal
|
||||
width={width}
|
||||
currentLogItem={currentLogItem}
|
||||
onCancel={() => {
|
||||
setCurrentLogItem()
|
||||
setShowPromptLogModal(false)
|
||||
}}
|
||||
/>
|
||||
</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} />
|
||||
)}
|
||||
)}
|
||||
{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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{!isAPIKeySet && !readonly && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Debug)
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
'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
|
||||
@ -1,187 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,20 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { createReactI18nextMock } from '@/test/i18n-mock'
|
||||
import InputWithCopy from './index'
|
||||
|
||||
// Mock navigator.clipboard for foxact/use-clipboard
|
||||
const mockWriteText = vi.fn(() => Promise.resolve())
|
||||
// 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 the i18n hook with custom translations for test assertions
|
||||
vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
@ -17,13 +27,9 @@ vi.mock('react-i18next', () => createReactI18nextMock({
|
||||
describe('InputWithCopy component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWriteText.mockClear()
|
||||
// Setup navigator.clipboard mock
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
})
|
||||
mockCopy.mockClear()
|
||||
mockReset.mockClear()
|
||||
mockCopied = false
|
||||
})
|
||||
|
||||
it('renders correctly with default props', () => {
|
||||
@ -44,31 +50,27 @@ describe('InputWithCopy component', () => {
|
||||
expect(copyButton).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies input value when copy button is clicked', async () => {
|
||||
it('calls copy function with input value when copy button is clicked', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith('test value')
|
||||
})
|
||||
expect(mockCopy).toHaveBeenCalledWith('test value')
|
||||
})
|
||||
|
||||
it('copies custom value when copyValue prop is provided', async () => {
|
||||
it('calls copy function with custom value when copyValue prop is provided', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<InputWithCopy value="display value" onChange={mockOnChange} copyValue="custom copy value" />)
|
||||
|
||||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith('custom copy value')
|
||||
})
|
||||
expect(mockCopy).toHaveBeenCalledWith('custom copy value')
|
||||
})
|
||||
|
||||
it('calls onCopy callback when copy button is clicked', async () => {
|
||||
it('calls onCopy callback when copy button is clicked', () => {
|
||||
const onCopyMock = vi.fn()
|
||||
const mockOnChange = vi.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} onCopy={onCopyMock} />)
|
||||
@ -76,25 +78,21 @@ describe('InputWithCopy component', () => {
|
||||
const copyButton = screen.getByRole('button')
|
||||
fireEvent.click(copyButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onCopyMock).toHaveBeenCalledWith('test value')
|
||||
})
|
||||
expect(onCopyMock).toHaveBeenCalledWith('test value')
|
||||
})
|
||||
|
||||
it('shows copied state after successful copy', async () => {
|
||||
it('shows copied state when copied is true', () => {
|
||||
mockCopied = true
|
||||
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)
|
||||
|
||||
// Check if the tooltip shows "Copied" state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Copied')).toBeInTheDocument()
|
||||
}, { timeout: 2000 })
|
||||
// The icon should change to filled version when copied
|
||||
// We verify the component renders without error in copied state
|
||||
expect(copyButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes through all input props correctly', () => {
|
||||
@ -117,22 +115,22 @@ describe('InputWithCopy component', () => {
|
||||
expect(input).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('handles empty value correctly', async () => {
|
||||
it('handles empty value correctly', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<InputWithCopy value="" onChange={mockOnChange} />)
|
||||
const input = screen.getByDisplayValue('')
|
||||
const input = screen.getByRole('textbox')
|
||||
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)
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith('')
|
||||
})
|
||||
expect(mockCopy).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('maintains focus on input after copy', async () => {
|
||||
it('maintains focus on input after copy', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<InputWithCopy value="test value" onChange={mockOnChange} />)
|
||||
|
||||
|
||||
24
web/app/components/datasets/api/index.spec.tsx
Normal file
24
web/app/components/datasets/api/index.spec.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
111
web/app/components/datasets/chunk.spec.tsx
Normal file
111
web/app/components/datasets/chunk.spec.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
426
web/app/components/datasets/common/check-rerank-model.spec.ts
Normal file
426
web/app/components/datasets/common/check-rerank-model.spec.ts
Normal file
@ -0,0 +1,426 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,61 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
136
web/app/components/datasets/common/credential-icon.spec.tsx
Normal file
136
web/app/components/datasets/common/credential-icon.spec.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
115
web/app/components/datasets/common/document-file-icon.spec.tsx
Normal file
115
web/app/components/datasets/common/document-file-icon.spec.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,166 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,280 @@
|
||||
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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,175 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
252
web/app/components/datasets/common/image-list/index.spec.tsx
Normal file
252
web/app/components/datasets/common/image-list/index.spec.tsx
Normal file
@ -0,0 +1,252 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
144
web/app/components/datasets/common/image-list/more.spec.tsx
Normal file
144
web/app/components/datasets/common/image-list/more.spec.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,525 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,922 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,107 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,198 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,167 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,125 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,149 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,238 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
305
web/app/components/datasets/common/image-uploader/store.spec.tsx
Normal file
305
web/app/components/datasets/common/image-uploader/store.spec.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
310
web/app/components/datasets/common/image-uploader/utils.spec.ts
Normal file
310
web/app/components/datasets/common/image-uploader/utils.spec.ts
Normal file
@ -0,0 +1,310 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,154 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,93 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,121 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,112 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,205 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
224
web/app/components/datasets/create-from-pipeline/footer.spec.tsx
Normal file
224
web/app/components/datasets/create-from-pipeline/footer.spec.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,71 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
101
web/app/components/datasets/create-from-pipeline/index.spec.tsx
Normal file
101
web/app/components/datasets/create-from-pipeline/index.spec.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,276 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,190 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,151 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,70 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,154 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,199 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,182 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,138 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,360 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,665 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,722 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,144 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,407 @@
|
||||
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¶m=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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,701 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,405 @@
|
||||
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¶m=2',
|
||||
includes: 'docs/**/*.md',
|
||||
})
|
||||
render(<Options payload={payload} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByDisplayValue('path/*/file?query=1¶m=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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -70,6 +70,11 @@ const createDefaultProps = (overrides: Partial<Parameters<typeof JinaReader>[0]>
|
||||
describe('JinaReader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -158,7 +163,7 @@ describe('JinaReader', () => {
|
||||
describe('Props', () => {
|
||||
it('should call onCrawlOptionsChange when options change', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
const onCrawlOptionsChange = vi.fn()
|
||||
const props = createDefaultProps({ onCrawlOptionsChange })
|
||||
|
||||
@ -237,9 +242,10 @@ describe('JinaReader', () => {
|
||||
// Arrange
|
||||
const mockCreateTask = createJinaReaderTask as Mock
|
||||
let resolvePromise: () => void
|
||||
mockCreateTask.mockImplementation(() => new Promise((resolve) => {
|
||||
const taskPromise = new Promise((resolve) => {
|
||||
resolvePromise = () => resolve({ data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' } })
|
||||
}))
|
||||
})
|
||||
mockCreateTask.mockImplementation(() => taskPromise)
|
||||
|
||||
const props = createDefaultProps()
|
||||
|
||||
@ -257,8 +263,11 @@ describe('JinaReader', () => {
|
||||
expect(screen.getByText(/totalPageScraped/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Cleanup - resolve the promise
|
||||
// Cleanup - resolve the promise and wait for component to finish
|
||||
resolvePromise!()
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should transition to finished state after successful crawl', async () => {
|
||||
@ -394,7 +403,11 @@ describe('JinaReader', () => {
|
||||
it('should update controlFoldOptions when step changes', async () => {
|
||||
// Arrange
|
||||
const mockCreateTask = createJinaReaderTask as Mock
|
||||
mockCreateTask.mockImplementation(() => new Promise((_resolve) => { /* pending */ }))
|
||||
let resolvePromise: () => void
|
||||
const taskPromise = new Promise((resolve) => {
|
||||
resolvePromise = () => resolve({ data: { title: 'T', content: 'C', description: 'D', url: 'https://example.com' } })
|
||||
})
|
||||
mockCreateTask.mockImplementation(() => taskPromise)
|
||||
|
||||
const props = createDefaultProps()
|
||||
|
||||
@ -412,6 +425,12 @@ describe('JinaReader', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/totalPageScraped/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Cleanup - resolve the promise
|
||||
resolvePromise!()
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/totalPageScraped/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1073,9 +1092,13 @@ 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(() => new Promise(() => { /* never resolves */ }))
|
||||
mockCheckStatus.mockImplementation(() => checkStatusPromise)
|
||||
|
||||
const props = createDefaultProps({
|
||||
crawlOptions: createDefaultCrawlOptions({ limit: 10 }),
|
||||
@ -1091,15 +1114,25 @@ 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(() => new Promise(() => { /* never resolves */ }))
|
||||
mockCheckStatus.mockImplementation(() => checkStatusPromise)
|
||||
|
||||
const props = createDefaultProps({
|
||||
crawlOptions: createDefaultCrawlOptions({ limit: '0' }),
|
||||
@ -1115,6 +1148,12 @@ 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 () => {
|
||||
@ -1150,9 +1189,13 @@ 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(() => new Promise(() => { /* never resolves */ }))
|
||||
mockCheckStatus.mockImplementation(() => checkStatusPromise)
|
||||
|
||||
const props = createDefaultProps({
|
||||
crawlOptions: createDefaultCrawlOptions({ limit: 15 }),
|
||||
@ -1168,12 +1211,22 @@ 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
|
||||
@ -1183,7 +1236,7 @@ describe('JinaReader', () => {
|
||||
total: 0,
|
||||
data: [],
|
||||
})
|
||||
.mockImplementationOnce(() => new Promise(() => { /* never resolves */ }))
|
||||
.mockImplementationOnce(() => checkStatusPromise)
|
||||
|
||||
const props = createDefaultProps({
|
||||
crawlOptions: createDefaultCrawlOptions({ limit: 5 }),
|
||||
@ -1199,6 +1252,12 @@ 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 () => {
|
||||
@ -1437,9 +1496,13 @@ 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(() => new Promise((_resolve) => { /* pending */ })) // Never resolves
|
||||
mockCheckStatus.mockImplementation(() => checkStatusPromise)
|
||||
|
||||
const props = createDefaultProps({
|
||||
crawlOptions: createDefaultCrawlOptions({ limit: 10 }),
|
||||
@ -1455,6 +1518,12 @@ 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 () => {
|
||||
|
||||
@ -0,0 +1,214 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,95 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,81 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,643 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,183 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,38 @@
|
||||
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'))
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,279 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,413 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,97 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,136 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,243 @@
|
||||
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(['问题', '答案'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,485 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,232 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,330 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,499 +1,430 @@
|
||||
import type { DocumentContextValue } from '@/app/components/datasets/documents/detail/context'
|
||||
import type { ChildChunkDetail, ChunkingMode, ParentMode } from '@/models/datasets'
|
||||
import type { ChildChunkDetail } from '@/models/datasets'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
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: (value: DocumentContextValue) => unknown) => {
|
||||
const value: DocumentContextValue = {
|
||||
datasetId: 'test-dataset-id',
|
||||
documentId: 'test-document-id',
|
||||
docForm: 'text' as ChunkingMode,
|
||||
parentMode: mockParentMode.current,
|
||||
}
|
||||
return selector(value)
|
||||
useDocumentContext: (selector: (state: { parentMode: string }) => unknown) => {
|
||||
return selector({ parentMode: mockParentMode })
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock segment list context
|
||||
let mockCurrChildChunk: { childChunkInfo: { id: string } } | null = null
|
||||
vi.mock('./index', () => ({
|
||||
useSegmentListContext: (selector: (value: { currChildChunk: { childChunkInfo?: ChildChunkDetail, showModal: boolean } }) => unknown) => {
|
||||
return selector({ currChildChunk: mockCurrChildChunk.current })
|
||||
useSegmentListContext: (selector: (state: { currChildChunk: { childChunkInfo: { id: string } } | null }) => unknown) => {
|
||||
return selector({ currChildChunk: mockCurrChildChunk })
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock skeleton component
|
||||
vi.mock('./skeleton/full-doc-list-skeleton', () => ({
|
||||
default: () => <div data-testid="full-doc-list-skeleton">Loading...</div>,
|
||||
}))
|
||||
|
||||
// Mock Empty component
|
||||
// Mock child components
|
||||
vi.mock('./common/empty', () => ({
|
||||
default: ({ onClearFilter }: { onClearFilter: () => void }) => (
|
||||
<div data-testid="empty-component">
|
||||
<button onClick={onClearFilter}>Clear Filter</button>
|
||||
<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>
|
||||
),
|
||||
}))
|
||||
|
||||
// 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', () => {
|
||||
const defaultProps = {
|
||||
childChunks: [] as ChildChunkDetail[],
|
||||
parentChunkId: 'parent-1',
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockParentMode.current = 'paragraph'
|
||||
mockCurrChildChunk.current = { childChunkInfo: undefined, showModal: false }
|
||||
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')],
|
||||
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
|
||||
describe('Rendering', () => {
|
||||
it('should render with empty child chunks', () => {
|
||||
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
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText(/child chunks/i)).toBeInTheDocument()
|
||||
// Assert
|
||||
expect(screen.getByText(/segment\.childChunks/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
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' }),
|
||||
]
|
||||
it('should render add button', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
|
||||
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()
|
||||
// Assert
|
||||
expect(screen.getByText(/operation\.add/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Paragraph mode tests
|
||||
describe('Paragraph Mode', () => {
|
||||
beforeEach(() => {
|
||||
mockParentMode.current = 'paragraph'
|
||||
mockParentMode = 'paragraph'
|
||||
})
|
||||
|
||||
it('should show collapse icon in paragraph mode', () => {
|
||||
const childChunks = [createMockChildChunk()]
|
||||
it('should render collapsed by default in paragraph mode', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
|
||||
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
|
||||
// Assert - collapsed icon should be present
|
||||
expect(screen.queryByTestId('formatted-text')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find and click the toggle area
|
||||
const toggleArea = screen.getByText(/1 child chunk/i).closest('div')
|
||||
it('should expand when clicking toggle in paragraph mode', () => {
|
||||
// Arrange
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
|
||||
// Click to expand
|
||||
// Act - click on the collapse toggle
|
||||
const toggleArea = screen.getByText(/segment\.childChunks/i).closest('div')
|
||||
if (toggleArea)
|
||||
fireEvent.click(toggleArea)
|
||||
|
||||
// After expansion, content should be visible
|
||||
// Assert - child chunks should be visible
|
||||
expect(screen.getByTestId('formatted-text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply opacity when disabled', () => {
|
||||
const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} />)
|
||||
it('should collapse when clicking toggle again', () => {
|
||||
// Arrange
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
|
||||
const wrapper = container.firstChild
|
||||
expect(wrapper).toHaveClass('opacity-50')
|
||||
})
|
||||
// Act - click twice
|
||||
const toggleArea = screen.getByText(/segment\.childChunks/i).closest('div')
|
||||
if (toggleArea) {
|
||||
fireEvent.click(toggleArea)
|
||||
fireEvent.click(toggleArea)
|
||||
}
|
||||
|
||||
it('should not apply opacity when enabled', () => {
|
||||
const { container } = render(<ChildSegmentList {...defaultProps} enabled={true} />)
|
||||
|
||||
const wrapper = container.firstChild
|
||||
expect(wrapper).not.toHaveClass('opacity-50')
|
||||
// Assert - child chunks should be hidden
|
||||
expect(screen.queryByTestId('formatted-text')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Full-Doc Mode', () => {
|
||||
// Full doc mode tests
|
||||
describe('Full Doc Mode', () => {
|
||||
beforeEach(() => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
mockParentMode = 'full-doc'
|
||||
})
|
||||
|
||||
it('should show content by default in full-doc mode', () => {
|
||||
const childChunks = [createMockChildChunk({ content: 'Full doc content' })]
|
||||
it('should render input field in full-doc mode', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
|
||||
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')
|
||||
// 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', () => {
|
||||
const handleInputChange = vi.fn()
|
||||
// Arrange
|
||||
const mockHandleInputChange = vi.fn()
|
||||
render(<ChildSegmentList {...defaultProps} handleInputChange={mockHandleInputChange} />)
|
||||
|
||||
render(<ChildSegmentList {...defaultProps} inputValue="" handleInputChange={handleInputChange} />)
|
||||
// Act
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'search term' } })
|
||||
|
||||
const input = document.querySelector('input')
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
expect(handleInputChange).toHaveBeenCalledWith('test search')
|
||||
}
|
||||
// Assert
|
||||
expect(mockHandleInputChange).toHaveBeenCalledWith('search term')
|
||||
})
|
||||
|
||||
it('should show search results text when searching', () => {
|
||||
render(<ChildSegmentList {...defaultProps} inputValue="search term" total={3} />)
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentList {...defaultProps} inputValue="search" total={5} />)
|
||||
|
||||
expect(screen.getByText(/3 search results/i)).toBeInTheDocument()
|
||||
// Assert
|
||||
expect(screen.getByText(/segment\.searchResults/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show empty component when no results and searching', () => {
|
||||
render(
|
||||
<ChildSegmentList
|
||||
{...defaultProps}
|
||||
childChunks={[]}
|
||||
inputValue="search term"
|
||||
onClearFilter={vi.fn()}
|
||||
isLoading={false}
|
||||
/>,
|
||||
)
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentList {...defaultProps} inputValue="search" childChunks={[]} total={0} />)
|
||||
|
||||
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
|
||||
// Assert
|
||||
expect(screen.getByTestId('empty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClearFilter when clear button clicked in empty state', () => {
|
||||
const onClearFilter = vi.fn()
|
||||
it('should show loading skeleton when isLoading is true', () => {
|
||||
// Arrange & Act
|
||||
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
|
||||
|
||||
render(
|
||||
<ChildSegmentList
|
||||
{...defaultProps}
|
||||
childChunks={[]}
|
||||
inputValue="search term"
|
||||
onClearFilter={onClearFilter}
|
||||
isLoading={false}
|
||||
/>,
|
||||
)
|
||||
// Assert
|
||||
expect(screen.getByTestId('full-doc-skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const clearButton = screen.getByText('Clear Filter')
|
||||
fireEvent.click(clearButton)
|
||||
it('should handle undefined total in full-doc mode', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<ChildSegmentList {...defaultProps} total={undefined} />)
|
||||
|
||||
expect(onClearFilter).toHaveBeenCalled()
|
||||
// Assert - component should render without crashing
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
// 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} />)
|
||||
|
||||
render(<ChildSegmentList {...defaultProps} childChunks={[editedChunk]} isLoading={false} />)
|
||||
// Act
|
||||
fireEvent.click(screen.getByText(/operation\.add/i))
|
||||
|
||||
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)
|
||||
// Assert
|
||||
expect(mockHandleAddNewChildChunk).toHaveBeenCalledWith('parent-1')
|
||||
})
|
||||
|
||||
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' })
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
const mockOnDelete = vi.fn()
|
||||
render(<ChildSegmentList {...defaultProps} onDelete={mockOnDelete} />)
|
||||
|
||||
render(
|
||||
<ChildSegmentList
|
||||
{...defaultProps}
|
||||
childChunks={[chunk]}
|
||||
onDelete={onDelete}
|
||||
isLoading={false}
|
||||
/>,
|
||||
)
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('delete-slice-btn'))
|
||||
|
||||
const deleteButton = screen.getByTestId('delete-button')
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(onDelete).toHaveBeenCalledWith('seg-1', 'deletable-chunk')
|
||||
// Assert
|
||||
expect(mockOnDelete).toHaveBeenCalledWith('seg-1', 'child-1')
|
||||
})
|
||||
|
||||
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 }
|
||||
it('should call onClickSlice when slice is clicked', () => {
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
const mockOnClickSlice = vi.fn()
|
||||
render(<ChildSegmentList {...defaultProps} onClickSlice={mockOnClickSlice} />)
|
||||
|
||||
render(<ChildSegmentList {...defaultProps} childChunks={[chunk]} isLoading={false} />)
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('click-slice-btn'))
|
||||
|
||||
const label = screen.getByTestId('edit-slice-label')
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add Button', () => {
|
||||
it('should call handleAddNewChildChunk when Add button is clicked', () => {
|
||||
const handleAddNewChildChunk = vi.fn()
|
||||
it('should not apply focused style when currChildChunk does not match', () => {
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
mockCurrChildChunk = { childChunkInfo: { id: 'other-child' } }
|
||||
|
||||
render(
|
||||
<ChildSegmentList
|
||||
{...defaultProps}
|
||||
handleAddNewChildChunk={handleAddNewChildChunk}
|
||||
parentChunkId="parent-123"
|
||||
/>,
|
||||
)
|
||||
// Act
|
||||
render(<ChildSegmentList {...defaultProps} />)
|
||||
|
||||
const addButton = screen.getByText('Add')
|
||||
fireEvent.click(addButton)
|
||||
|
||||
expect(handleAddNewChildChunk).toHaveBeenCalledWith('parent-123')
|
||||
})
|
||||
|
||||
it('should disable Add button when loading in full-doc mode', () => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
|
||||
render(<ChildSegmentList {...defaultProps} isLoading={true} />)
|
||||
|
||||
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
|
||||
// Assert
|
||||
const label = screen.getByTestId('slice-label')
|
||||
expect(label).not.toHaveClass('bg-state-accent-solid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeTotalInfo function', () => {
|
||||
it('should return search results when searching in full-doc mode', () => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
// Enabled/Disabled state
|
||||
describe('Enabled State', () => {
|
||||
it('should apply opacity when enabled is false', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<ChildSegmentList {...defaultProps} enabled={false} />)
|
||||
|
||||
render(<ChildSegmentList {...defaultProps} inputValue="search" total={10} />)
|
||||
|
||||
expect(screen.getByText(/10 search results/i)).toBeInTheDocument()
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('opacity-50')
|
||||
})
|
||||
|
||||
it('should return "--" when total is 0 in full-doc mode', () => {
|
||||
mockParentMode.current = 'full-doc'
|
||||
it('should not apply opacity when enabled is true', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<ChildSegmentList {...defaultProps} enabled={true} />)
|
||||
|
||||
render(<ChildSegmentList {...defaultProps} total={0} />)
|
||||
|
||||
// When total is 0, displayText is '--'
|
||||
expect(screen.getByText(/--/)).toBeInTheDocument()
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).not.toHaveClass('opacity-50')
|
||||
})
|
||||
|
||||
it('should use childChunks length in paragraph mode', () => {
|
||||
mockParentMode.current = 'paragraph'
|
||||
const childChunks = [
|
||||
createMockChildChunk(),
|
||||
createMockChildChunk(),
|
||||
createMockChildChunk(),
|
||||
]
|
||||
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} />)
|
||||
|
||||
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
|
||||
// Assert
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
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()
|
||||
// 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)
|
||||
|
||||
render(
|
||||
<ChildSegmentList
|
||||
{...defaultProps}
|
||||
inputValue="test"
|
||||
handleInputChange={handleInputChange}
|
||||
/>,
|
||||
)
|
||||
// Act
|
||||
render(<ChildSegmentList {...defaultProps} childChunks={[editedChunk]} />)
|
||||
|
||||
// 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)
|
||||
}
|
||||
// Assert
|
||||
const label = screen.getByTestId('slice-label')
|
||||
expect(label.textContent).toContain('segment.edited')
|
||||
})
|
||||
})
|
||||
|
||||
// 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'),
|
||||
]
|
||||
|
||||
// Act
|
||||
render(<ChildSegmentList {...defaultProps} childChunks={chunks} total={3} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getAllByTestId('edit-slice')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty childChunks array', () => {
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
|
||||
// Act
|
||||
const { container } = render(<ChildSegmentList {...defaultProps} childChunks={[]} />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should maintain structure when rerendered', () => {
|
||||
// Arrange
|
||||
mockParentMode = 'full-doc'
|
||||
const { rerender } = render(<ChildSegmentList {...defaultProps} />)
|
||||
|
||||
// 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)
|
||||
expect(addButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,523 @@
|
||||
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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,194 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,277 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,317 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,60 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,129 +1,153 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Empty from './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(),
|
||||
}
|
||||
|
||||
describe('Empty', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render empty state message', () => {
|
||||
render(<Empty {...defaultProps} />)
|
||||
it('should render without crashing', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Empty onClearFilter={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('No results found')).toBeInTheDocument()
|
||||
// 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()
|
||||
})
|
||||
|
||||
it('should render clear filter button', () => {
|
||||
render(<Empty {...defaultProps} />)
|
||||
// Arrange & Act
|
||||
render(<Empty onClearFilter={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Clear Filter')).toBeInTheDocument()
|
||||
// Assert
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render icon', () => {
|
||||
const { container } = render(<Empty {...defaultProps} />)
|
||||
it('should render background empty cards', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<Empty onClearFilter={vi.fn()} />)
|
||||
|
||||
// Check for the icon container
|
||||
// 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
|
||||
const iconContainer = container.querySelector('.shadow-lg')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render decorative lines', () => {
|
||||
const { container } = render(<Empty {...defaultProps} />)
|
||||
it('should render clear filter button with accent text styling', () => {
|
||||
// Arrange & Act
|
||||
render(<Empty onClearFilter={vi.fn()} />)
|
||||
|
||||
// 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()
|
||||
// Assert
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('text-text-accent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should call onClearFilter when clear filter button is clicked', () => {
|
||||
const onClearFilter = vi.fn()
|
||||
// Props tests
|
||||
describe('Props', () => {
|
||||
it('should accept onClearFilter callback prop', () => {
|
||||
// Arrange
|
||||
const mockCallback = vi.fn()
|
||||
|
||||
render(<Empty onClearFilter={onClearFilter} />)
|
||||
// Act
|
||||
render(<Empty onClearFilter={mockCallback} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
const clearButton = screen.getByText('Clear Filter')
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(onClearFilter).toHaveBeenCalledTimes(1)
|
||||
// Assert
|
||||
expect(mockCallback).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memoization', () => {
|
||||
it('should be memoized', () => {
|
||||
// Empty is wrapped with React.memo
|
||||
const { rerender } = render(<Empty {...defaultProps} />)
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple clicks on clear filter button', () => {
|
||||
// Arrange
|
||||
const mockOnClearFilter = vi.fn()
|
||||
render(<Empty onClearFilter={mockOnClearFilter} />)
|
||||
|
||||
// Same props should not cause re-render issues
|
||||
rerender(<Empty {...defaultProps} />)
|
||||
// Act
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(screen.getByText('No results found')).toBeInTheDocument()
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,262 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,317 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,327 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,215 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,151 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,130 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,507 @@
|
||||
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
Reference in New Issue
Block a user