mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-03-08 06:39:56 +00:00
* feat: add EagerEval dataclass for frontend-side node evaluation Add EagerEval to the V3 API schema, enabling nodes to declare frontend-evaluated JSONata expressions. The frontend uses this to display computation results as badges without a backend round-trip. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Math Expression node with JSONata evaluation Add ComfyMathExpression node that evaluates JSONata expressions against dynamically-grown numeric inputs using Autogrow + MatchType. Sends input context via ui output so the frontend can re-evaluate when the expression changes without a backend round-trip. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: register nodes_math.py in extras_files loader list Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address CodeRabbit review feedback - Harden EagerEval.validate with type checks and strip() for empty strings - Add _positional_alias for spreadsheet-style names beyond z (aa, ab...) - Validate JSONata result is numeric before returning - Add jsonata to requirements.txt Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: remove EagerEval, scope PR to math node only Remove EagerEval dataclass from _io.py and eager_eval usage from nodes_math.py. Eager execution will be designed as a general-purpose system in a separate effort. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use TemplateNames, cap inputs at 26, improve error message Address Kosinkadink review feedback: - Switch from Autogrow.TemplatePrefix to Autogrow.TemplateNames so input slots are named a-z, matching expression variables directly - Cap max inputs at 26 (a-z) instead of 100 - Simplify execute() by removing dual-mapping hack - Include expression and result value in error message Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add unit tests for Math Expression node Add tests for _positional_alias (a-z mapping) and execute() covering arithmetic operations, float inputs, $sum(values), and error cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: replace jsonata with simpleeval for math evaluation jsonata PyPI package has critical issues: no Python 3.12/3.13 wheels, no ARM/Apple Silicon wheels, abandoned (last commit 2023), C extension. Replace with simpleeval (pure Python, 3.4M downloads/month, MIT, AST-based security). Add math module functions (sqrt, ceil, floor, log, sin, cos, tan) and variadic sum() supporting both sum(values) and sum(a, b, c). Pin version to >=1.0,<2.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: update tests for simpleeval migration Update JSONata syntax to Python syntax ($sum -> sum, $string -> str), add tests for math functions (sqrt, ceil, floor, sin, log10) and variadic sum(a, b, c). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: replace MatchType with MultiType inputs and dual FLOAT/INT outputs Allow mixing INT and FLOAT connections on the same node by switching from MatchType (which forces all inputs to the same type) to MultiType. Output both FLOAT and INT so users can pick the type they need. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: update tests for mixed INT/FLOAT inputs and dual outputs Add assertions for both FLOAT (result[0]) and INT (result[1]) outputs. Add test_mixed_int_float_inputs and test_mixed_resolution_scale to verify the primary use case of multiplying resolutions by a float factor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: make expression input multiline and validate empty expression - Add multiline=True to expression input for better UX with longer expressions - Add empty expression validation with clear "Expression cannot be empty." message Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add tests for empty expression validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review feedback — safe pow, isfinite guard, test coverage - Wrap pow() with _safe_pow to prevent DoS via huge exponents (pow() bypasses simpleeval's safe_power guard on **) - Add math.isfinite() check to catch inf/nan before int() conversion - Add int/float converters to MATH_FUNCTIONS for explicit casting - Add "calculator" search alias - Replace _positional_alias helper with string.ascii_lowercase - Narrow test assertions and add error path + function coverage tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update requirements.txt --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com> Co-authored-by: Christian Byrne <abolkonsky.rem@gmail.com>
198 lines
5.9 KiB
Python
198 lines
5.9 KiB
Python
import math
|
|
|
|
import pytest
|
|
from collections import OrderedDict
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
mock_nodes = MagicMock()
|
|
mock_nodes.MAX_RESOLUTION = 16384
|
|
mock_server = MagicMock()
|
|
|
|
with patch.dict("sys.modules", {"nodes": mock_nodes, "server": mock_server}):
|
|
from comfy_extras.nodes_math import MathExpressionNode
|
|
|
|
|
|
class TestMathExpressionExecute:
|
|
@staticmethod
|
|
def _exec(expression: str, **kwargs) -> object:
|
|
values = OrderedDict(kwargs)
|
|
return MathExpressionNode.execute(expression, values)
|
|
|
|
def test_addition(self):
|
|
result = self._exec("a + b", a=3, b=4)
|
|
assert result[0] == 7.0
|
|
assert result[1] == 7
|
|
|
|
def test_subtraction(self):
|
|
result = self._exec("a - b", a=10, b=3)
|
|
assert result[0] == 7.0
|
|
assert result[1] == 7
|
|
|
|
def test_multiplication(self):
|
|
result = self._exec("a * b", a=3, b=5)
|
|
assert result[0] == 15.0
|
|
assert result[1] == 15
|
|
|
|
def test_division(self):
|
|
result = self._exec("a / b", a=10, b=4)
|
|
assert result[0] == 2.5
|
|
assert result[1] == 2
|
|
|
|
def test_single_input(self):
|
|
result = self._exec("a * 2", a=5)
|
|
assert result[0] == 10.0
|
|
assert result[1] == 10
|
|
|
|
def test_three_inputs(self):
|
|
result = self._exec("a + b + c", a=1, b=2, c=3)
|
|
assert result[0] == 6.0
|
|
assert result[1] == 6
|
|
|
|
def test_float_inputs(self):
|
|
result = self._exec("a + b", a=1.5, b=2.5)
|
|
assert result[0] == 4.0
|
|
assert result[1] == 4
|
|
|
|
def test_mixed_int_float_inputs(self):
|
|
result = self._exec("a * b", a=1024, b=1.5)
|
|
assert result[0] == 1536.0
|
|
assert result[1] == 1536
|
|
|
|
def test_mixed_resolution_scale(self):
|
|
result = self._exec("a * b", a=512, b=0.75)
|
|
assert result[0] == 384.0
|
|
assert result[1] == 384
|
|
|
|
def test_sum_values_array(self):
|
|
result = self._exec("sum(values)", a=1, b=2, c=3)
|
|
assert result[0] == 6.0
|
|
|
|
def test_sum_variadic(self):
|
|
result = self._exec("sum(a, b, c)", a=1, b=2, c=3)
|
|
assert result[0] == 6.0
|
|
|
|
def test_min_values(self):
|
|
result = self._exec("min(values)", a=5, b=2, c=8)
|
|
assert result[0] == 2.0
|
|
|
|
def test_max_values(self):
|
|
result = self._exec("max(values)", a=5, b=2, c=8)
|
|
assert result[0] == 8.0
|
|
|
|
def test_abs_function(self):
|
|
result = self._exec("abs(a)", a=-7)
|
|
assert result[0] == 7.0
|
|
assert result[1] == 7
|
|
|
|
def test_sqrt(self):
|
|
result = self._exec("sqrt(a)", a=16)
|
|
assert result[0] == 4.0
|
|
assert result[1] == 4
|
|
|
|
def test_ceil(self):
|
|
result = self._exec("ceil(a)", a=2.3)
|
|
assert result[0] == 3.0
|
|
assert result[1] == 3
|
|
|
|
def test_floor(self):
|
|
result = self._exec("floor(a)", a=2.7)
|
|
assert result[0] == 2.0
|
|
assert result[1] == 2
|
|
|
|
def test_sin(self):
|
|
result = self._exec("sin(a)", a=0)
|
|
assert result[0] == 0.0
|
|
|
|
def test_log10(self):
|
|
result = self._exec("log10(a)", a=100)
|
|
assert result[0] == 2.0
|
|
assert result[1] == 2
|
|
|
|
def test_float_output_type(self):
|
|
result = self._exec("a + b", a=1, b=2)
|
|
assert isinstance(result[0], float)
|
|
|
|
def test_int_output_type(self):
|
|
result = self._exec("a + b", a=1, b=2)
|
|
assert isinstance(result[1], int)
|
|
|
|
def test_non_numeric_result_raises(self):
|
|
with pytest.raises(ValueError, match="must evaluate to a numeric result"):
|
|
self._exec("'hello'", a=42)
|
|
|
|
def test_undefined_function_raises(self):
|
|
with pytest.raises(Exception, match="not defined"):
|
|
self._exec("str(a)", a=42)
|
|
|
|
def test_boolean_result_raises(self):
|
|
with pytest.raises(ValueError, match="got bool"):
|
|
self._exec("a > b", a=5, b=3)
|
|
|
|
def test_empty_expression_raises(self):
|
|
with pytest.raises(ValueError, match="Expression cannot be empty"):
|
|
self._exec("", a=1)
|
|
|
|
def test_whitespace_only_expression_raises(self):
|
|
with pytest.raises(ValueError, match="Expression cannot be empty"):
|
|
self._exec(" ", a=1)
|
|
|
|
# --- Missing function coverage (round, pow, log, log2, cos, tan) ---
|
|
|
|
def test_round(self):
|
|
result = self._exec("round(a)", a=2.7)
|
|
assert result[0] == 3.0
|
|
assert result[1] == 3
|
|
|
|
def test_round_with_ndigits(self):
|
|
result = self._exec("round(a, 2)", a=3.14159)
|
|
assert result[0] == pytest.approx(3.14)
|
|
|
|
def test_pow(self):
|
|
result = self._exec("pow(a, b)", a=2, b=10)
|
|
assert result[0] == 1024.0
|
|
assert result[1] == 1024
|
|
|
|
def test_log(self):
|
|
result = self._exec("log(a)", a=math.e)
|
|
assert result[0] == pytest.approx(1.0)
|
|
|
|
def test_log2(self):
|
|
result = self._exec("log2(a)", a=8)
|
|
assert result[0] == pytest.approx(3.0)
|
|
|
|
def test_cos(self):
|
|
result = self._exec("cos(a)", a=0)
|
|
assert result[0] == 1.0
|
|
|
|
def test_tan(self):
|
|
result = self._exec("tan(a)", a=0)
|
|
assert result[0] == 0.0
|
|
|
|
# --- int/float converter functions ---
|
|
|
|
def test_int_converter(self):
|
|
result = self._exec("int(a / b)", a=7, b=2)
|
|
assert result[1] == 3
|
|
|
|
def test_float_converter(self):
|
|
result = self._exec("float(a)", a=5)
|
|
assert result[0] == 5.0
|
|
|
|
# --- Error path tests ---
|
|
|
|
def test_division_by_zero_raises(self):
|
|
with pytest.raises(ZeroDivisionError):
|
|
self._exec("a / b", a=1, b=0)
|
|
|
|
def test_sqrt_negative_raises(self):
|
|
with pytest.raises(ValueError, match="math domain error"):
|
|
self._exec("sqrt(a)", a=-1)
|
|
|
|
def test_overflow_inf_raises(self):
|
|
with pytest.raises(ValueError, match="non-finite result"):
|
|
self._exec("a * b", a=1e308, b=10)
|
|
|
|
def test_pow_huge_exponent_raises(self):
|
|
with pytest.raises(ValueError, match="Exponent .* exceeds maximum"):
|
|
self._exec("pow(a, b)", a=10, b=10000000)
|