Files
ausboss-ComfyUI-AusBoss/tests/test_math_helpers.py
ausboss 3f715a4af8 Ship the crush-list build-out: 7 new node files, replace button, video and LoRA polish
- New nodes: Image Resize plus the Utility group (Text, Integer, Float,
  Show Text, Math Expression, Select Every Nth, Split Batch, Merge
  Batches, Free Memory) - the dependency-free gaps that let shared
  workflows drop heavy packs.
- Replace with AusBoss nodes (prototype): preview-then-confirm graph
  rewrite swapping known third-party nodes for pack equivalents, with a
  tested translation map, per-node opt-out, and refuse-with-reason for
  nodes the pack cannot stand in for.
- Load Video: single-frame FRAME picker, an honest "what will load"
  label from the probed source rate, drop-a-video-to-restore. Save
  Video: format-aware widgets and a file_path output. Stitch Inpaint
  gained a blend_mask output, Select Frame negative from-the-end
  indexing, Image Size a batch count - all appended, saved workflows
  load unchanged.
- LoRA Loader: drag-to-reorder rows, hover thumbnails on rows, file
  size and date in the info card.
- Appearance: flagship scheme deepened to slate-teal; the retired
  bright-teal pair upgrades automatically via LEGACY_SCHEME_PAIRS.
  Optional queue-completion chime, off by default.
- Image Resize invariant: pixels are only invented on an explicit
  width+height pad request - scale-derived modes resolve divisible_by
  snaps with a sub-half-step resize, never bars.
- Docs refreshed for six stale pages, stale text and metadata fixed
  across the pack, the panel-height rule codified in AGENTS.md and the
  brand skill, and ausboss_node_tour.json added - a model-free example
  exercising 17 node types in one graph.

Validated: scripts/validate_nodes.py (24 files), 300 JS tests, all
Python suites under the ComfyUI embedded interpreter.
2026-08-27 13:30:35 -05:00

166 lines
6.7 KiB
Python

from __future__ import annotations
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
del sys.modules["nodes"]
from nodes._math_helpers import (
MAX_EXPRESSION_LENGTH,
evaluate_expression,
round_half_away_from_zero,
)
class EvaluateExpressionTests(unittest.TestCase):
def test_arithmetic_and_precedence(self):
self.assertEqual(evaluate_expression("1 + 2 * 3"), 7.0)
self.assertEqual(evaluate_expression("(1 + 2) * 3"), 9.0)
self.assertEqual(evaluate_expression("7 // 2"), 3.0)
self.assertEqual(evaluate_expression("7 % 2"), 1.0)
self.assertEqual(evaluate_expression("2 ** 8"), 256.0)
self.assertEqual(evaluate_expression("-(2 + 3)"), -5.0)
self.assertEqual(evaluate_expression("+4"), 4.0)
def test_variables_and_defaults(self):
values = {"a": 2.0, "b": 3.0, "c": 4.0}
self.assertEqual(evaluate_expression("a * b + c", values), 10.0)
# A variable the caller never supplied evaluates as 0.0.
self.assertEqual(evaluate_expression("a + c", {"a": 1.0}), 1.0)
def test_function_whitelist_works(self):
self.assertEqual(evaluate_expression("min(3, 1, 2)"), 1.0)
self.assertEqual(evaluate_expression("max(3, 1, 2)"), 3.0)
self.assertEqual(evaluate_expression("abs(-2)"), 2.0)
self.assertEqual(evaluate_expression("round(2.567, 2)"), 2.57)
self.assertEqual(evaluate_expression("round(2.4)"), 2.0)
self.assertEqual(evaluate_expression("floor(2.9)"), 2.0)
self.assertEqual(evaluate_expression("ceil(2.1)"), 3.0)
self.assertEqual(evaluate_expression("sqrt(16)"), 4.0)
def test_results_are_floats_even_from_int_literals(self):
result = evaluate_expression("1 + 2")
self.assertIsInstance(result, float)
def test_empty_and_blank_expressions_are_refused(self):
for expression in ("", " "):
with self.assertRaisesRegex(ValueError, "empty"):
evaluate_expression(expression)
def test_division_by_zero_reports_clearly(self):
for expression in ("1 / 0", "1 // 0", "1 % 0"):
with self.assertRaisesRegex(ValueError, "divided by zero"):
evaluate_expression(expression)
def test_huge_powers_overflow_instead_of_hanging(self):
# Float arithmetic turns a tower of powers into OverflowError where
# integer power would build a number with hundreds of millions of
# digits before anyone could stop it.
with self.assertRaisesRegex(ValueError, "too large"):
evaluate_expression("9 ** 9 ** 9")
def test_negative_sqrt_and_fractional_power_are_refused(self):
with self.assertRaisesRegex(ValueError, "sqrt|cannot take"):
evaluate_expression("sqrt(-1)")
with self.assertRaisesRegex(ValueError, "fractional power"):
evaluate_expression("(-2) ** 0.5")
def test_function_arity_is_checked(self):
with self.assertRaisesRegex(ValueError, "min\\(\\) takes"):
evaluate_expression("min(1)")
with self.assertRaisesRegex(ValueError, "abs\\(\\) takes"):
evaluate_expression("abs(1, 2)")
def test_unparseable_expressions_name_the_problem(self):
with self.assertRaisesRegex(ValueError, "parsed"):
evaluate_expression("1 +")
class ExpressionSafetyTests(unittest.TestCase):
"""The expression is attacker-controlled; everything here must be refused."""
def test_rejects_import(self):
with self.assertRaisesRegex(ValueError, "__import__"):
evaluate_expression("__import__('os')")
# The full attack chain dies on whichever wall it hits first.
with self.assertRaises(ValueError):
evaluate_expression("__import__('os').system('true')")
with self.assertRaisesRegex(ValueError, "a, b and c"):
evaluate_expression("__import__")
def test_rejects_attribute_access(self):
with self.assertRaisesRegex(ValueError, "attribute access"):
evaluate_expression("a.__class__")
with self.assertRaisesRegex(ValueError, "attribute access"):
evaluate_expression("(1).__class__.__bases__")
def test_rejects_subscripts(self):
with self.assertRaisesRegex(ValueError, "subscripts"):
evaluate_expression("a[0]")
def test_rejects_names_beyond_abc(self):
for expression in ("d + 1", "eval", "open", "globals"):
with self.assertRaisesRegex(ValueError, "a, b and c"):
evaluate_expression(expression)
def test_rejects_functions_outside_the_whitelist(self):
with self.assertRaisesRegex(ValueError, "does not allow the function"):
evaluate_expression("pow(2, 3)")
def test_rejects_non_numeric_literals(self):
with self.assertRaisesRegex(ValueError, "only numbers"):
evaluate_expression("'x' * 3")
with self.assertRaisesRegex(ValueError, "only numbers"):
evaluate_expression("True + 1")
def test_rejects_non_arithmetic_syntax(self):
for expression in (
"(lambda: 1)()",
"[i for i in (1, 2)]",
"1 if a else 2",
"a < b",
"a and b",
"f'{a}'",
"min(*a)",
"{1: 2}",
):
with self.assertRaises(ValueError, msg=expression):
evaluate_expression(expression)
def test_rejects_keyword_arguments(self):
with self.assertRaisesRegex(ValueError, "keyword"):
evaluate_expression("round(1.5, ndigits=1)")
def test_rejects_oversized_expressions(self):
expression = "1" + " + 1" * (MAX_EXPRESSION_LENGTH // 3)
with self.assertRaisesRegex(ValueError, "limited to"):
evaluate_expression(expression)
def test_deep_nesting_is_an_error_not_a_crash(self):
depth = 4000
expression = "(" * 0 + "-" * depth + "1"
if len(expression) > MAX_EXPRESSION_LENGTH:
expression = "-" * (MAX_EXPRESSION_LENGTH - 1) + "1"
with self.assertRaisesRegex(ValueError, "nested too deeply|limited to"):
evaluate_expression(expression)
class RoundHalfAwayTests(unittest.TestCase):
def test_halves_round_away_from_zero(self):
self.assertEqual(round_half_away_from_zero(2.5), 3)
self.assertEqual(round_half_away_from_zero(-2.5), -3)
def test_ordinary_values_round_to_nearest(self):
self.assertEqual(round_half_away_from_zero(2.4), 2)
self.assertEqual(round_half_away_from_zero(2.6), 3)
self.assertEqual(round_half_away_from_zero(-0.4), 0)
self.assertEqual(round_half_away_from_zero(-0.5), -1)
if __name__ == "__main__":
unittest.main()