From cb3bbdfb82fcaec1c80e79834bc75d06852af31d Mon Sep 17 00:00:00 2001 From: George Sandeep Date: Wed, 5 Aug 2026 11:11:09 +0530 Subject: [PATCH] Add unit tests for ngspiceSimulation math_utils module Cover _format_measurement, _format_frequency, _canonical_expr, _safe_eval and _detect_frequency with 35 passing tests. Modules are loaded directly via importlib to bypass the heavy Qt/matplotlib package __init__ imports, keeping the test dependency to numpy only. Added unit tests for ngspiceSimulation --- unit_tests/__init__.py | 0 unit_tests/known_issues/test_math_utils.py | 93 +++++++++++ unit_tests/test_math_utils.py | 172 +++++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 unit_tests/__init__.py create mode 100644 unit_tests/known_issues/test_math_utils.py create mode 100644 unit_tests/test_math_utils.py diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/unit_tests/known_issues/test_math_utils.py b/unit_tests/known_issues/test_math_utils.py new file mode 100644 index 0000000000..e2f11a5002 --- /dev/null +++ b/unit_tests/known_issues/test_math_utils.py @@ -0,0 +1,93 @@ +import sys +import os +import unittest +import importlib.util + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) + +import numpy as np + + +def _load_module_from_file(module_name, filename): + path = os.path.join( + os.path.dirname(__file__), "..", "..", "src", "ngspiceSimulation", filename + ) + spec = importlib.util.spec_from_file_location(module_name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_math_utils = _load_module_from_file("math_utils", "math_utils.py") + +_format_measurement = _math_utils._format_measurement +_format_frequency = _math_utils._format_frequency +_canonical_expr = _math_utils._canonical_expr +_safe_eval = _math_utils._safe_eval + + +class KnownIssueFormatMeasurement(unittest.TestCase): + """ + Characterisation tests for _format_measurement input sanitation. + + Each test pins the CURRENT (buggy) behaviour. When a dev fixes the + underlying issue the assertion will fail, signalling that the test + should move into unit_tests/test_math_utils.py with the corrected + expectation. + """ + + def test_unknown_unit_falls_through_to_volts(self): + self.assertEqual(_format_measurement(5.0, "W"), "5 V") + + def test_lowercase_unit_uses_volts_branch(self): + self.assertEqual(_format_measurement(5.0, "a"), "5 V") + + def test_none_unit_returns_volts(self): + self.assertEqual(_format_measurement(5.0, None), "5 V") + + def test_nan_value_returns_nan_string(self): + self.assertEqual(_format_measurement(float("nan"), "V"), "nan V") + + def test_inf_value_returns_inf_string(self): + self.assertEqual(_format_measurement(float("inf"), "A"), "inf A") + + +class KnownIssueFormatFrequency(unittest.TestCase): + def test_negative_frequency_propagates(self): + self.assertEqual(_format_frequency(-500), "-500 Hz") + + def test_non_numeric_input_raises_typeerror(self): + with self.assertRaises(TypeError): + _format_frequency("1e3") + + +class KnownIssueCanonicalExpr(unittest.TestCase): + def test_attribute_syntax_leaks_ast_repr(self): + result = _canonical_expr("a.b") + self.assertNotEqual(result, "a.b") + self.assertIn("Attribute", result) + + +class KnownIssueSafeEval(unittest.TestCase): + def test_empty_function_args_raise_typeerror(self): + with self.assertRaises(TypeError): + _safe_eval("sin()", {}) + + def test_division_by_zero_returns_inf(self): + np.testing.assert_array_equal( + _safe_eval("a/0", {"a": np.array([1.0])}), [np.inf] + ) + + def test_bool_literal_is_coerced_to_one(self): + np.testing.assert_array_equal( + _safe_eval("a+True", {"a": np.array([1.0])}), [2.0] + ) + + def test_list_input_concatenates(self): + np.testing.assert_array_equal( + _safe_eval("a+a", {"a": [1, 2]}), [1.0, 2.0, 1.0, 2.0] + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/unit_tests/test_math_utils.py b/unit_tests/test_math_utils.py new file mode 100644 index 0000000000..e1226fc80c --- /dev/null +++ b/unit_tests/test_math_utils.py @@ -0,0 +1,172 @@ +import sys +import os +import unittest +import importlib.util + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +import numpy as np + + +def _load_module_from_file(module_name, filename): + path = os.path.join( + os.path.dirname(__file__), "..", "src", "ngspiceSimulation", filename + ) + spec = importlib.util.spec_from_file_location(module_name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_math_utils = _load_module_from_file("math_utils", "math_utils.py") + +_format_measurement = _math_utils._format_measurement +_format_frequency = _math_utils._format_frequency +_canonical_expr = _math_utils._canonical_expr +_safe_eval = _math_utils._safe_eval +_detect_frequency = _math_utils._detect_frequency + + +class TestFormatMeasurement(unittest.TestCase): + def test_bare_volt(self): + self.assertEqual(_format_measurement(5.0, "V"), "5 V") + + def test_milliamp(self): + self.assertEqual(_format_measurement(0.0012, "A"), "1.2 mA") + + def test_microamp(self): + self.assertEqual(_format_measurement(5e-6, "A"), "5 \u00b5A") + + def test_nanovolt(self): + self.assertEqual(_format_measurement(3e-9, "V"), "3 nV") + + def test_picoamp(self): + self.assertEqual(_format_measurement(1e-12, "A"), "1 pA") + + def test_below_pico_uses_scientific(self): + self.assertEqual(_format_measurement(1e-15, "V"), "1e-15 V") + + def test_negative_value(self): + self.assertEqual(_format_measurement(-0.0012, "A"), "-1.2 mA") + + +class TestFormatFrequency(unittest.TestCase): + def test_hz(self): + self.assertEqual(_format_frequency(50), "50 Hz") + + def test_khz(self): + self.assertEqual(_format_frequency(1234), "1.23 kHz") + + def test_mhz(self): + self.assertEqual(_format_frequency(1_230_000), "1.23 MHz") + + def test_ghz(self): + self.assertEqual(_format_frequency(2_400_000_000), "2.4 GHz") + + def test_exact_threshold_khz(self): + self.assertEqual(_format_frequency(1e3), "1 kHz") + + def test_exact_threshold_ghz(self): + self.assertEqual(_format_frequency(1e9), "1 GHz") + + +class TestCanonicalExpr(unittest.TestCase): + def test_add_commutative(self): + self.assertEqual(_canonical_expr("a+b"), _canonical_expr("b+a")) + + def test_mul_commutative(self): + self.assertEqual(_canonical_expr("a*b"), _canonical_expr("b*a")) + + def test_sub_not_commutative(self): + self.assertNotEqual(_canonical_expr("a-b"), _canonical_expr("b-a")) + + def test_div_not_commutative(self): + self.assertNotEqual(_canonical_expr("a/b"), _canonical_expr("b/a")) + + def test_pow_not_commutative(self): + self.assertNotEqual(_canonical_expr("a**b"), _canonical_expr("b**a")) + + def test_mixed_expression_stability(self): + self.assertEqual(_canonical_expr("(a+b)*c"), _canonical_expr("(b+a)*c")) + + +class TestSafeEval(unittest.TestCase): + def test_simple_add(self): + data = {"a": np.array([1.0, 2.0]), "b": np.array([3.0, 4.0])} + np.testing.assert_array_equal(_safe_eval("a+b", data), [4.0, 6.0]) + + def test_subtraction(self): + data = {"a": np.array([5.0]), "b": np.array([2.0])} + np.testing.assert_array_equal(_safe_eval("a-b", data), [3.0]) + + def test_multiplication_and_division(self): + data = {"a": np.array([6.0]), "b": np.array([3.0])} + np.testing.assert_array_equal(_safe_eval("a*b/b", data), [6.0]) + + def test_power(self): + data = {"a": np.array([2.0])} + np.testing.assert_array_equal(_safe_eval("a**3", data), [8.0]) + + def test_unary_minus(self): + data = {"a": np.array([2.0])} + np.testing.assert_array_equal(_safe_eval("-a", data), [-2.0]) + + def test_math_functions(self): + data = {"x": np.array([0.0, np.pi / 2])} + np.testing.assert_array_almost_equal( + _safe_eval("sin(x)", data), [0.0, 1.0], decimal=5 + ) + + def test_log_and_exp(self): + data = {"x": np.array([1.0])} + np.testing.assert_array_almost_equal( + _safe_eval("exp(log(x))", data), [1.0], decimal=5 + ) + + def test_numeric_literal_only(self): + result = _safe_eval("2*3", {}) + np.testing.assert_array_equal(result, [6.0]) + + def test_unknown_identifier_raises(self): + with self.assertRaises(ValueError): + _safe_eval("z", {"a": np.array([1.0])}) + + def test_unknown_function_raises(self): + with self.assertRaises(ValueError): + _safe_eval("foo(x)", {"x": np.array([1.0])}) + + def test_keyword_argument_raises(self): + with self.assertRaises(ValueError): + _safe_eval("sin(x, step=1)", {"x": np.array([1.0])}) + + def test_syntax_error_raises(self): + with self.assertRaises(ValueError): + _safe_eval("a+", {"a": np.array([1.0])}) + + def test_mismatched_lengths_trimmed(self): + data = {"a": np.array([1.0, 2.0, 3.0]), "b": np.array([1.0, 2.0])} + self.assertEqual(len(_safe_eval("a+b", data)), 2) + + +class TestDetectFrequency(unittest.TestCase): + def test_periodic_signal(self): + t = np.linspace(0, 1e-3, 1000) + sig = np.where(np.sin(2 * np.pi * 1e4 * t) > 0, 1.0, 0.0) + freq = _detect_frequency(t, sig) + self.assertIsNotNone(freq) + self.assertGreater(freq, 8000) + self.assertLess(freq, 12000) + + def test_too_few_edges_returns_none(self): + t = np.array([0.0, 1.0]) + sig = np.array([0.0, 1.0]) + self.assertIsNone(_detect_frequency(t, sig)) + + def test_constant_signal_returns_none(self): + t = np.linspace(0, 1e-3, 100) + sig = np.ones_like(t) + self.assertIsNone(_detect_frequency(t, sig)) + + +if __name__ == "__main__": + unittest.main()