From fa98505e0911be4f6c9f1710e08ca54ef901f966 Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Fri, 1 Aug 2025 15:25:09 +0800 Subject: [PATCH] fix(api): enhance compatibility with existing workflow Fix issues flagged by unit tests. --- api/core/app/apps/base_app_generator.py | 29 ++-- .../workflow/utils/condition/processor.py | 25 ++- .../core/variables/test_segment_type.py | 2 + .../core/workflow/nodes/test_if_else.py | 161 ++++++++++-------- .../core/workflow/nodes/test_list_operator.py | 3 +- 5 files changed, 129 insertions(+), 91 deletions(-) diff --git a/api/core/app/apps/base_app_generator.py b/api/core/app/apps/base_app_generator.py index a8a6a33bf4..42634fc48b 100644 --- a/api/core/app/apps/base_app_generator.py +++ b/api/core/app/apps/base_app_generator.py @@ -103,18 +103,23 @@ class BaseAppGenerator: f"(type '{variable_entity.type}') {variable_entity.variable} in input form must be a string" ) - if variable_entity.type == VariableEntityType.NUMBER and isinstance(value, str): - # handle empty string case - if not value.strip(): - return None - # may raise ValueError if user_input_value is not a valid number - try: - if "." in value: - return float(value) - else: - return int(value) - except ValueError: - raise ValueError(f"{variable_entity.variable} in input form must be a valid number") + if variable_entity.type == VariableEntityType.NUMBER: + if isinstance(value, (int, float)): + return value + elif isinstance(value, str): + # handle empty string case + if not value.strip(): + return None + # may raise ValueError if user_input_value is not a valid number + try: + if "." in value: + return float(value) + else: + return int(value) + except ValueError: + raise ValueError(f"{variable_entity.variable} in input form must be a valid number") + else: + raise TypeError(f"expected value type int, float or str, got {type(value)}, value: {value}") match variable_entity.type: case VariableEntityType.SELECT: diff --git a/api/core/workflow/utils/condition/processor.py b/api/core/workflow/utils/condition/processor.py index d74dca25af..6554e4c396 100644 --- a/api/core/workflow/utils/condition/processor.py +++ b/api/core/workflow/utils/condition/processor.py @@ -1,14 +1,27 @@ +import json from collections.abc import Sequence from typing import Any, Literal, Union from core.file import FileAttribute, file_manager from core.variables import ArrayFileSegment -from core.variables.segments import BooleanSegment +from core.variables.segments import ArrayBooleanSegment, BooleanSegment from core.workflow.entities.variable_pool import VariablePool from .entities import Condition, SubCondition, SupportedComparisonOperator +def _convert_to_bool(value: Any) -> bool: + if isinstance(value, int): + return bool(value) + + if isinstance(value, str): + loaded = json.loads(value) + if isinstance(loaded, (int, bool)): + return bool(loaded) + + raise TypeError(f"unexpected value: type={type(value)}, value={value}") + + class ConditionProcessor: def process_conditions( self, @@ -53,8 +66,12 @@ class ConditionProcessor: if isinstance(expected_value, str): expected_value = variable_pool.convert_template(expected_value).text # Here we need to explicit convet the input string to boolean. - if isinstance(variable, BooleanSegment) and not variable.value_type.is_valid(expected_value): - raise TypeError(f"unexpected value: type={type(expected_value)}, value={expected_value}") + if isinstance(variable, (BooleanSegment, ArrayBooleanSegment)) and expected_value is not None: + # The following two lines is for compatibility with existing workflows. + if isinstance(expected_value, list): + expected_value = [_convert_to_bool(i) for i in expected_value] + else: + expected_value = _convert_to_bool(expected_value) input_conditions.append( { "actual_value": actual_value, @@ -81,7 +98,7 @@ def _evaluate_condition( *, operator: SupportedComparisonOperator, value: Any, - expected: Union[str, Sequence[str], bool, None], + expected: Union[str, Sequence[str], bool | Sequence[bool], None], ) -> bool: match operator: case "contains": diff --git a/api/tests/unit_tests/core/variables/test_segment_type.py b/api/tests/unit_tests/core/variables/test_segment_type.py index 64d0d8c7e7..700bf55b6c 100644 --- a/api/tests/unit_tests/core/variables/test_segment_type.py +++ b/api/tests/unit_tests/core/variables/test_segment_type.py @@ -24,6 +24,7 @@ class TestSegmentTypeIsArrayType: SegmentType.ARRAY_NUMBER, SegmentType.ARRAY_OBJECT, SegmentType.ARRAY_FILE, + SegmentType.ARRAY_BOOLEAN, ] expected_non_array_types = [ SegmentType.INTEGER, @@ -35,6 +36,7 @@ class TestSegmentTypeIsArrayType: SegmentType.FILE, SegmentType.NONE, SegmentType.GROUP, + SegmentType.BOOLEAN, ] for seg_type in expected_array_types: diff --git a/api/tests/unit_tests/core/workflow/nodes/test_if_else.py b/api/tests/unit_tests/core/workflow/nodes/test_if_else.py index 5f222440be..36a6fbb53e 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_if_else.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_if_else.py @@ -2,6 +2,8 @@ import time import uuid from unittest.mock import MagicMock, Mock +import pytest + from core.app.entities.app_invoke_entities import InvokeFrom from core.file import File, FileTransferMethod, FileType from core.variables import ArrayFileSegment @@ -274,7 +276,36 @@ def test_array_file_contains_file_name(): assert result.outputs["result"] is True -def test_execute_if_else_boolean_conditions(): +def _get_test_conditions() -> list: + conditions = [ + # Test boolean "is" operator + {"comparison_operator": "is", "variable_selector": ["start", "bool_true"], "value": "true"}, + # Test boolean "is not" operator + {"comparison_operator": "is not", "variable_selector": ["start", "bool_false"], "value": "true"}, + # Test boolean "=" operator + {"comparison_operator": "=", "variable_selector": ["start", "bool_true"], "value": "1"}, + # Test boolean "≠" operator + {"comparison_operator": "≠", "variable_selector": ["start", "bool_false"], "value": "1"}, + # Test boolean "not null" operator + {"comparison_operator": "not null", "variable_selector": ["start", "bool_true"]}, + # Test boolean array "contains" operator + {"comparison_operator": "contains", "variable_selector": ["start", "bool_array"], "value": "true"}, + # Test boolean "in" operator + { + "comparison_operator": "in", + "variable_selector": ["start", "bool_true"], + "value": ["true", "false"], + }, + ] + return [Condition.model_validate(i) for i in conditions] + + +def _get_condition_test_id(c: Condition): + return c.comparison_operator + + +@pytest.mark.parametrize("condition", _get_test_conditions(), ids=_get_condition_test_id) +def test_execute_if_else_boolean_conditions(condition: Condition): """Test IfElseNode with boolean conditions using various operators""" graph_config = {"edges": [], "nodes": [{"data": {"type": "start"}, "id": "start"}]} @@ -294,47 +325,27 @@ def test_execute_if_else_boolean_conditions(): # construct variable pool with boolean values pool = VariablePool( - system_variables={SystemVariableKey.FILES: [], SystemVariableKey.USER_ID: "aaa"}, user_inputs={} + system_variables=SystemVariable(files=[], user_id="aaa"), ) pool.add(["start", "bool_true"], True) pool.add(["start", "bool_false"], False) pool.add(["start", "bool_array"], [True, False, True]) pool.add(["start", "mixed_array"], [True, "false", 1, 0]) + node_data = { + "title": "Boolean Test", + "type": "if-else", + "logical_operator": "and", + "conditions": [condition.model_dump()], + } node = IfElseNode( id=str(uuid.uuid4()), graph_init_params=init_params, graph=graph, graph_runtime_state=GraphRuntimeState(variable_pool=pool, start_at=time.perf_counter()), - config={ - "id": "if-else", - "data": { - "title": "Boolean Test", - "type": "if-else", - "logical_operator": "and", - "conditions": [ - # Test boolean "is" operator - {"comparison_operator": "is", "variable_selector": ["start", "bool_true"], "value": "true"}, - # Test boolean "is not" operator - {"comparison_operator": "is not", "variable_selector": ["start", "bool_false"], "value": "true"}, - # Test boolean "=" operator - {"comparison_operator": "=", "variable_selector": ["start", "bool_true"], "value": "1"}, - # Test boolean "≠" operator - {"comparison_operator": "≠", "variable_selector": ["start", "bool_false"], "value": "1"}, - # Test boolean "not null" operator - {"comparison_operator": "not null", "variable_selector": ["start", "bool_true"]}, - # Test boolean array "contains" operator - {"comparison_operator": "contains", "variable_selector": ["start", "bool_array"], "value": "true"}, - # Test boolean "in" operator - { - "comparison_operator": "in", - "variable_selector": ["start", "bool_true"], - "value": ["true", "false"], - }, - ], - }, - }, + config={"id": "if-else", "data": node_data}, ) + node.init_node_data(node_data) # Mock db.session.close() db.session.close = MagicMock() @@ -367,12 +378,30 @@ def test_execute_if_else_boolean_false_conditions(): # construct variable pool with boolean values pool = VariablePool( - system_variables={SystemVariableKey.FILES: [], SystemVariableKey.USER_ID: "aaa"}, user_inputs={} + system_variables=SystemVariable(files=[], user_id="aaa"), ) pool.add(["start", "bool_true"], True) pool.add(["start", "bool_false"], False) pool.add(["start", "bool_array"], [True, False, True]) + node_data = { + "title": "Boolean False Test", + "type": "if-else", + "logical_operator": "or", + "conditions": [ + # Test boolean "is" operator (should be false) + {"comparison_operator": "is", "variable_selector": ["start", "bool_true"], "value": "false"}, + # Test boolean "=" operator (should be false) + {"comparison_operator": "=", "variable_selector": ["start", "bool_false"], "value": "1"}, + # Test boolean "not contains" operator (should be false) + { + "comparison_operator": "not contains", + "variable_selector": ["start", "bool_array"], + "value": "true", + }, + ], + } + node = IfElseNode( id=str(uuid.uuid4()), graph_init_params=init_params, @@ -380,25 +409,10 @@ def test_execute_if_else_boolean_false_conditions(): graph_runtime_state=GraphRuntimeState(variable_pool=pool, start_at=time.perf_counter()), config={ "id": "if-else", - "data": { - "title": "Boolean False Test", - "type": "if-else", - "logical_operator": "or", - "conditions": [ - # Test boolean "is" operator (should be false) - {"comparison_operator": "is", "variable_selector": ["start", "bool_true"], "value": "false"}, - # Test boolean "=" operator (should be false) - {"comparison_operator": "=", "variable_selector": ["start", "bool_false"], "value": "1"}, - # Test boolean "not contains" operator (should be false) - { - "comparison_operator": "not contains", - "variable_selector": ["start", "bool_array"], - "value": "true", - }, - ], - }, + "data": node_data, }, ) + node.init_node_data(node_data) # Mock db.session.close() db.session.close = MagicMock() @@ -431,42 +445,41 @@ def test_execute_if_else_boolean_cases_structure(): # construct variable pool with boolean values pool = VariablePool( - system_variables={SystemVariableKey.FILES: [], SystemVariableKey.USER_ID: "aaa"}, user_inputs={} + system_variables=SystemVariable(files=[], user_id="aaa"), ) pool.add(["start", "bool_true"], True) pool.add(["start", "bool_false"], False) + node_data = { + "title": "Boolean Cases Test", + "type": "if-else", + "cases": [ + { + "case_id": "true", + "logical_operator": "and", + "conditions": [ + { + "comparison_operator": "is", + "variable_selector": ["start", "bool_true"], + "value": "true", + }, + { + "comparison_operator": "is not", + "variable_selector": ["start", "bool_false"], + "value": "true", + }, + ], + } + ], + } node = IfElseNode( id=str(uuid.uuid4()), graph_init_params=init_params, graph=graph, graph_runtime_state=GraphRuntimeState(variable_pool=pool, start_at=time.perf_counter()), - config={ - "id": "if-else", - "data": { - "title": "Boolean Cases Test", - "type": "if-else", - "cases": [ - { - "case_id": "true", - "logical_operator": "and", - "conditions": [ - { - "comparison_operator": "is", - "variable_selector": ["start", "bool_true"], - "value": "true", - }, - { - "comparison_operator": "is not", - "variable_selector": ["start", "bool_false"], - "value": "true", - }, - ], - } - ], - }, - }, + config={"id": "if-else", "data": node_data}, ) + node.init_node_data(node_data) # Mock db.session.close() db.session.close = MagicMock() diff --git a/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py b/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py index b03ce3e1c6..d4d6aa0387 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py @@ -12,6 +12,7 @@ from core.workflow.nodes.list_operator.entities import ( Limit, ListOperatorNodeData, Order, + OrderByConfig, ) from core.workflow.nodes.list_operator.exc import InvalidKeyError from core.workflow.nodes.list_operator.node import ListOperatorNode, _get_file_extract_string_func @@ -27,7 +28,7 @@ def list_operator_node(): FilterCondition(key="type", comparison_operator="in", value=[FileType.IMAGE, FileType.DOCUMENT]) ], ), - "order_by": Order(enabled=False, value="asc"), + "order_by": OrderByConfig(enabled=False, value=Order.ASC), "limit": Limit(enabled=False, size=0), "extract_by": ExtractConfig(enabled=False, serial="1"), "title": "Test Title",