#!/usr/bin/env python # -*- coding: utf-8 -*- # # Copyright 2026 Jan Sebastian Götte # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # Based on https://github.com/tracespace/tracespace # import math import operator as op from contextlib import contextmanager from PIL import Image import pytest from gerbonara.rs274x import GerberFile from gerbonara.graphic_objects import Line, Arc, Flash, Region from gerbonara.apertures import * from gerbonara import aperture_macros as am from gerbonara.aperture_macros import ( ConstantExpression, ParameterExpression, OperatorExpression, NegatedExpression, VariableExpression, UnitExpression, ) from gerbonara.aperture_macros.expression import expr from gerbonara.aperture_macros.parse import _parse_expression from gerbonara.cam import FileSettings from gerbonara.utils import MM, Inch, MILLIMETERS_PER_INCH from .image_support import svg_soup from .utils import * # Short aliases used throughout expression tests C = ConstantExpression P = ParameterExpression @contextmanager def run_aperture_macro_test(tmpfile, img_support, inst: ApertureMacroInstance, epsilon=1e-3): gbr = GerberFile() inst_rot_90 = inst.rotated(math.pi/2) inst_rot_45 = inst.rotated(math.pi/4) inst_rot_neg90 = inst.rotated(-math.pi/2) for x, y in [(0, 0), (0, 10), (10, 0), (10, 10)]: gbr.objects.append(Flash(x=x, y=y, aperture=inst, unit=MM)) gbr.objects.append(Flash(x=x, y=20+y, aperture=inst_rot_90, unit=MM)) gbr.objects.append(Flash(x=20+x, y=y, aperture=inst_rot_neg90, unit=MM)) gbr.objects.append(Flash(x=20+x, y=20+y, aperture=inst_rot_45, unit=MM)) # inches, to pixel align our SVG output with gerbv's! bounds = (-.5, -.5), (2.0, 2.0) # bottom left, top right # The below code is mostly copy-pasted from test_rs274x.py. out_svg = tmpfile('SVG Output', '.svg') with open(out_svg, 'w') as f: # Use inch units here to make sure we and gerbv agree on the exact pixel size of the output since both calculate # it from the DPI setting. f.write(str(gbr.to_svg(force_bounds=bounds, arg_unit='inch', fg='black', bg='white'))) # Reference export via gerber through GerbV out_gbr = tmpfile('GBR Output', '.gbr') gbr.save(out_gbr) # NOTE: Instead of having gerbv directly export a PNG, we ask gerbv to output SVG which we then rasterize using # resvg. We have to do this since gerbv's built-in cairo-based PNG export has severe aliasing issues. In contrast, # using resvg for both allows an apples-to-apples comparison of both results. ref_svg = tmpfile('Reference export', '.svg') w, h = bounds[1][0] - bounds[0][0], bounds[1][1] - bounds[0][1] img_support.gerbv_export(out_gbr, ref_svg, origin=bounds[0], size=(w, h), fg='#000000', bg='#ffffff') with svg_soup(ref_svg) as soup: img_support.cleanup_gerbv_svg(soup) ref_png = tmpfile('Reference render', '.png') img_support.svg_to_png(ref_svg, ref_png, dpi=300, bg='white') out_png = tmpfile('Output render', '.png') img_support.svg_to_png(out_svg, out_png, dpi=300, bg='white') mean, _max, hist = img_support.image_difference(ref_png, out_png, diff_out=tmpfile('Difference', '.png')) assert hist[9] < 1 assert mean < epsilon assert hist[3:].sum() < epsilon*hist.size @pytest.mark.parametrize('aperture_type', [ lambda: CircleAperture(4.0, unit=MM), lambda: CircleAperture(4.0, hole_dia=1.5, unit=MM), lambda: RectangleAperture(4.0, 3.0, unit=MM), lambda: ObroundAperture(4.0, 2.5, unit=MM), lambda: PolygonAperture(4.0, 6, unit=MM), ]) def test_macro_conversions(tmpfile, img_support, aperture_type): ap = aperture_type() inst = ap.to_macro() run_aperture_macro_test(tmpfile, img_support, inst) @pytest.mark.parametrize('params', [(10, 0), (7, 0), (10, 5)]) def test_generic_macro_circle(tmpfile, img_support, params): ap = am.GenericMacros.circle(*params) # epsilon changed since gerbv approximates circles with cubic splines which ends up pretty wrong at this scale run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2) @pytest.mark.parametrize('params', [ (10, 10, 0, 0), (10, 5, 0, 0), ( 5, 10, 0, 0), (10, 10, 5, 0), (10, 7, 3, 0), (10, 10, 0, math.pi/2), (10, 10, 0, math.pi/3), (10, 5, 0, math.pi/3), ( 7, 10, 3, math.pi/3)]) def test_generic_macro_rect(tmpfile, img_support, params): ap = am.GenericMacros.rect(*params) run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2) @pytest.mark.parametrize('params', [ (10, 10, 0, 0, 0), (10, 5, 0, 0, 0), ( 5, 10, 0, 0, 0), (10, 10, 0, 5, 0), (10, 7, 0, 3, 0), (10, 10, 0, 0, math.pi/2), (10, 10, 0, 0, math.pi/3), (10, 5, 0, 0, math.pi/3), ( 7, 10, 0, 3, math.pi/3), (10, 10, 2, 0, 0), (10, 5, 2, 0, 0), ( 5, 10, 2, 0, 0), (10, 10, 2, 5, 0), (10, 7, 2, 3, 0), (10, 10, 2, 0, math.pi/2), (10, 10, 2, 0, math.pi/3), (10, 5, 2, 0, math.pi/3), ( 7, 10, 2, 3, math.pi/3), ]) def test_generic_macro_rounded_rect(tmpfile, img_support, params): ap = am.GenericMacros.rounded_rect(*params) run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2) @pytest.mark.parametrize('params', [ (10, 8, 2, 0, 0), (10, 8, 4, 0, 0), ( 8, 10, 2, 0, 0), (10, 8, 2, 3, 0), (10, 8, 2, 0, math.pi/2), (10, 8, 2, 0, math.pi/3), (10, 8, 2, 3, math.pi/3), (10, 8, 0, 0, 0), # d=0: degenerate case (rectangle) ]) def test_generic_macro_isosceles_trapezoid(tmpfile, img_support, params): ap = am.GenericMacros.isosceles_trapezoid(*params) run_aperture_macro_test(tmpfile, img_support, ap) @pytest.mark.parametrize('params', [ # (w, h, d, margin, hole_dia, rotation) (10, 8, 2, 1, 0, 0), (10, 8, 4, 1, 0, 0), (10, 8, 2, 1, 3, 0), (10, 8, 2, 1, 0, math.pi/2), (10, 8, 2, 1, 0, math.pi/3), (10, 8, 2, 1, 3, math.pi/3), ]) def test_generic_macro_rounded_isosceles_trapezoid(tmpfile, img_support, params): ap = am.GenericMacros.rounded_isosceles_trapezoid(*params) run_aperture_macro_test(tmpfile, img_support, ap) @pytest.mark.parametrize('params', [ # (w, h, hole_dia, rotation), w >= h required (10, 5, 0, 0), ( 8, 4, 0, 0), (10, 5, 2, 0), ( 7, 7, 0, 0), # w == h: circle (10, 5, 0, math.pi/2), (10, 5, 0, math.pi/3), (10, 5, 2, math.pi/3), ]) def test_generic_macro_obround(tmpfile, img_support, params): ap = am.GenericMacros.obround(*params) run_aperture_macro_test(tmpfile, img_support, ap, epsilon=1e-2) @pytest.mark.parametrize('params', [ # (n, diameter, hole_dia, rotation) (3, 10, 0, 0), (4, 10, 0, 0), (5, 10, 0, 0), (6, 10, 0, 0), (6, 10, 3, 0), (6, 10, 0, math.pi/6), (5, 10, 0, math.pi/4), (5, 10, 3, math.pi/4), (3, 10, 3, math.pi/3), ]) def test_generic_macro_polygon(tmpfile, img_support, params): ap = am.GenericMacros.polygon(*params) run_aperture_macro_test(tmpfile, img_support, ap) @pytest.mark.parametrize('abc', [(2.0, 1.6, 2.3), (2.2, 1.6, 2.3), (2.1, 1.7, 2.4)]) def test_macro_formulas(tmpfile, img_support, abc): @am.ApertureMacro.map() class test_macro: a: float b: float c: float def draw(self): d = 1.3 yield am.Circle('mm', 0, d, 0, 0) yield am.Circle('mm', 0, d, 2, 0) yield am.Circle('mm', 0, d, 4, 0) yield am.Circle('mm', 0, d, 2, self.a) yield am.Circle('mm', 0, d, 2, self.a+self.b) yield am.Circle('mm', 0, d, 2, self.a+self.b+self.c) yield am.Circle('mm', 0, d, 4, self.a * 1.1) yield am.Circle('mm', 0, d, 4, self.b * 1.9) yield am.Circle('mm', 0, d, 4, self.c * 2.2) yield am.Circle('mm', 0, d, 6, 2 * self.a / self.b) yield am.Circle('mm', 0, d, 6, 4 * self.b / self.c) yield am.Circle('mm', 0, d, 6, 6 * self.c / self.a) yield am.Circle('mm', 0, d, 8, self.a - self.b * self.a / self.c) yield am.Circle('mm', 0, d, 8, 2 + self.a - self.b * self.a / self.c) yield am.Circle('mm', 0, d, 8, self.a - 2 * self.b * self.a / self.c) inst = test_macro(*abc) run_aperture_macro_test(tmpfile, img_support, inst) # ============================================================================= # Expression language unit tests # ============================================================================= class TestConstantExpression: def test_value_stored(self): assert C(5).value == 5 def test_float_conversion(self): assert float(C(3.14)) == pytest.approx(3.14) def test_calculate_no_binding(self): assert C(42.0).calculate() == pytest.approx(42.0) def test_calculate_ignores_binding(self): assert C(7.0).calculate({1: 99.0}) == pytest.approx(7.0) def test_to_gerber_integer(self): assert C(5).to_gerber() == '5' def test_to_gerber_float(self): assert C(1.5).to_gerber() == '1.5' def test_to_gerber_trailing_zeros_stripped(self): assert C(1.500000).to_gerber() == '1.5' assert C(2.0).to_gerber() == '2' def test_to_gerber_zero(self): assert C(0).to_gerber() == '0' def test_to_gerber_negative_zero_avoided(self): # -0.0 must not serialize as '-0' assert C(-0.0).to_gerber() == '0' def test_equality_exact(self): assert C(3.0) == C(3.0) def test_equality_within_tolerance(self): assert C(1.0) == C(1.0 + 1e-10) def test_inequality_outside_tolerance(self): assert not (C(1.0) == C(2.0)) def test_equality_with_plain_number(self): assert C(0) == 0 assert C(1) == 1 assert C(-1) == -1 def test_parameters_empty(self): assert list(C(5).parameters()) == [] class TestParameterExpression: def test_to_gerber(self): assert P(1).to_gerber() == '$1' assert P(3).to_gerber() == '$3' assert P(42).to_gerber() == '$42' def test_calculate_with_binding(self): assert P(1).calculate({1: 5.0}) == pytest.approx(5.0) assert P(2).calculate({1: 10.0, 2: 20.0}) == pytest.approx(20.0) def test_calculate_unresolved_raises(self): with pytest.raises(IndexError): P(1).calculate({}) def test_calculate_missing_param_raises(self): with pytest.raises(IndexError): P(2).calculate({1: 5.0}) def test_parameters_yields_self(self): p = P(1) assert list(p.parameters()) == [p] def test_optimized_with_binding_resolves(self): assert P(1).optimized({1: 7.5}) == C(7.5) def test_optimized_without_binding_is_identity(self): p = P(1) assert p.optimized({}) is p class TestArithmeticOperators: @pytest.mark.parametrize('a,b', [(3.0, 7.0), (-1.5, 4.2), (0.5, 0.25), (0.0, 5.0)]) def test_add(self, a, b): assert (P(1) + P(2)).calculate({1: a, 2: b}) == pytest.approx(a + b) @pytest.mark.parametrize('a,b', [(3.0, 7.0), (-1.5, 4.2), (0.5, 0.25), (5.0, 5.0)]) def test_sub(self, a, b): assert (P(1) - P(2)).calculate({1: a, 2: b}) == pytest.approx(a - b) @pytest.mark.parametrize('a,b', [(3.0, 7.0), (-1.5, 4.2), (0.5, 0.25), (0.0, 5.0)]) def test_mul(self, a, b): assert (P(1) * P(2)).calculate({1: a, 2: b}) == pytest.approx(a * b) @pytest.mark.parametrize('a,b', [(6.0, 3.0), (-4.5, 1.5), (1.0, 4.0)]) def test_div(self, a, b): assert (P(1) / P(2)).calculate({1: a, 2: b}) == pytest.approx(a / b) def test_radd(self): assert (5.0 + P(1)).calculate({1: 3.0}) == pytest.approx(8.0) def test_rsub(self): assert (10.0 - P(1)).calculate({1: 3.0}) == pytest.approx(7.0) def test_rmul(self): assert (2.0 * P(1)).calculate({1: 4.0}) == pytest.approx(8.0) def test_rdiv(self): assert (10.0 / P(1)).calculate({1: 2.0}) == pytest.approx(5.0) def test_neg(self): assert (-P(1)).calculate({1: 5.0}) == pytest.approx(-5.0) def test_pos_is_identity(self): p = P(1) assert +p is p # Cross-check expression evaluation against Python's own arithmetic. # A single lambda serves both roles: called with P() objects it builds an expression tree; # called with plain numbers it computes the Python reference value (ints/floats auto-convert). @pytest.mark.parametrize('f,binding', [ (lambda p1, p2, p3: p1 + p2, {1: 3, 2: 7, 3: 0}), (lambda p1, p2, p3: p1 - p2, {1: 10, 2: 3, 3: 0}), (lambda p1, p2, p3: p1 * p2, {1: 3, 2: 4, 3: 0}), (lambda p1, p2, p3: p1 / p2, {1: 9, 2: 3, 3: 0}), (lambda p1, p2, p3: p1 + p2 + p3, {1: 1, 2: 2, 3: 3}), (lambda p1, p2, p3: p1 * p2 + p3, {1: 2, 2: 3, 3: 4}), (lambda p1, p2, p3: p1 + p2 * p3, {1: 2, 2: 3, 3: 4}), (lambda p1, p2, p3: (p1 + p2) * p3, {1: 2, 2: 3, 3: 4}), (lambda p1, p2, p3: p1 / p2 + p3, {1: 6, 2: 3, 3: 1}), (lambda p1, p2, p3: p1 - p2 * p3, {1: 10, 2: 2, 3: 3}), (lambda p1, p2, p3: (p1 + p2) / p3, {1: 3, 2: 5, 3: 4}), (lambda p1, p2, p3: p1 * (p2 - p3), {1: 3, 2: 7, 3: 2}), (lambda p1, p2, p3: p1 * 2 + 3, {1: 5, 2: 0, 3: 0}), (lambda p1, p2, p3: 10 - p1 * p2, {1: 2, 2: 3, 3: 0}), (lambda p1, p2, p3: p1 / 2 + p2, {1: 6, 2: 1, 3: 0}), (lambda p1, p2, p3: -p1 + p2, {1: 3, 2: 7, 3: 0}), (lambda p1, p2, p3: p1 + (-p2), {1: 10, 2: 3, 3: 0}), (lambda p1, p2, p3: p1 * (-p2), {1: 3, 2: 4, 3: 0}), (lambda p1, p2, p3: (-p1) * (-p2), {1: 3, 2: 4, 3: 0}), (lambda p1, p2, p3: (-p1) / (-p2), {1: 6, 2: 3, 3: 0}), (lambda p1, p2, p3: p1 - (-p2), {1: 5, 2: 3, 3: 0}), (lambda p1, p2, p3: (p1+p2) * (p1-p3), {1: 5, 2: 3, 3: 2}), (lambda p1, p2, p3: p1 / p2 * p3, {1: 6, 2: 2, 3: 5}), ]) def test_expression_against_python(f, binding): """Build a gerbonara expression and compare its result to Python's evaluation.""" a, b, c = binding.get(1, 0), binding.get(2, 0), binding.get(3, 0) assert f(P(1), P(2), P(3)).calculate(binding) == pytest.approx(f(a, b, c), rel=1e-9, abs=1e-12) class TestConstantFolding: """Operations on two ConstantExpressions must immediately produce a ConstantExpression.""" def test_add(self): result = C(3) + C(4) assert isinstance(result, ConstantExpression) and result.value == pytest.approx(7) def test_sub(self): result = C(10) - C(4) assert isinstance(result, ConstantExpression) and result.value == pytest.approx(6) def test_mul(self): result = C(3) * C(4) assert isinstance(result, ConstantExpression) and result.value == pytest.approx(12) def test_div(self): result = C(10) / C(4) assert isinstance(result, ConstantExpression) and result.value == pytest.approx(2.5) def test_neg_of_constant(self): result = -C(5) assert isinstance(result, ConstantExpression) and result.value == pytest.approx(-5) def test_nested(self): result = (C(3) + C(4)) * C(2) assert isinstance(result, ConstantExpression) and result.value == pytest.approx(14) def test_deeply_nested(self): result = C(2) * C(3) + C(4) * C(5) assert isinstance(result, ConstantExpression) and result.value == pytest.approx(26) class TestAlgebraicOptimizations: """Each algebraic simplification rule in OperatorExpression.optimized().""" def test_zero_plus_x(self): assert C(0) + P(1) == P(1) def test_x_plus_zero(self): assert P(1) + C(0) == P(1) def test_zero_times_x(self): assert C(0) * P(1) == C(0) def test_x_times_zero(self): assert P(1) * C(0) == C(0) def test_one_times_x(self): assert C(1) * P(1) == P(1) def test_x_times_one(self): assert P(1) * C(1) == P(1) def test_x_times_neg_one_negates(self): assert (P(1) * C(-1)).calculate({1: 5.0}) == pytest.approx(-5.0) def test_neg_one_times_x_negates(self): assert (C(-1) * P(1)).calculate({1: 5.0}) == pytest.approx(-5.0) def test_x_minus_zero(self): assert P(1) - C(0) == P(1) def test_zero_minus_x_is_neg_x(self): assert (C(0) - P(1)).calculate({1: 5.0}) == pytest.approx(-5.0) def test_x_minus_x_is_zero(self): p = P(1) assert p - p == C(0) def test_x_minus_neg_y_is_x_plus_y(self): assert (P(1) - (-P(2))).calculate({1: 3.0, 2: 4.0}) == pytest.approx(7.0) def test_x_div_one(self): assert P(1) / C(1) == P(1) def test_x_div_neg_one_negates(self): assert (P(1) / C(-1)).calculate({1: 5.0}) == pytest.approx(-5.0) def test_x_div_x_is_one(self): p = P(1) assert p / p == C(1) def test_neg_x_times_neg_y_cancels(self): assert ((-P(1)) * (-P(2))).calculate({1: 3.0, 2: 4.0}) == pytest.approx(12.0) def test_neg_x_div_neg_y_cancels(self): assert ((-P(1)) / (-P(2))).calculate({1: 6.0, 2: 3.0}) == pytest.approx(2.0) def test_x_plus_neg_y_becomes_subtraction(self): assert (P(1) + (-P(2))).calculate({1: 10.0, 2: 3.0}) == pytest.approx(7.0) def test_neg_x_plus_y_reverses_subtraction(self): assert ((-P(1)) + P(2)).calculate({1: 3.0, 2: 10.0}) == pytest.approx(7.0) def test_x_mul_neg_y_pulls_negation_out(self): e = P(1) * (-P(2)) assert isinstance(e, NegatedExpression) assert e.calculate({1: 3.0, 2: 4.0}) == pytest.approx(-12.0) def test_neg_x_mul_y_pulls_negation_out(self): e = (-P(1)) * P(2) assert isinstance(e, NegatedExpression) assert e.calculate({1: 3.0, 2: 4.0}) == pytest.approx(-12.0) def test_x_div_neg_y_pulls_negation_out(self): e = P(1) / (-P(2)) assert isinstance(e, NegatedExpression) assert e.calculate({1: 6.0, 2: 3.0}) == pytest.approx(-2.0) def test_neg_x_div_y_pulls_negation_out(self): e = (-P(1)) / P(2) assert isinstance(e, NegatedExpression) assert e.calculate({1: 6.0, 2: 3.0}) == pytest.approx(-2.0) class TestNegatedExpression: def test_double_negation(self): p = P(1) assert -(-p) == p def test_double_negation_evaluates_correctly(self): assert (-(-P(1))).calculate({1: 5.0}) == pytest.approx(5.0) def test_negation_of_constant_folds(self): assert -C(5) == C(-5) def test_negation_of_subtraction_flips_operands(self): # -(a - b) == b - a assert (-(P(1) - P(2))).calculate({1: 3.0, 2: 7.0}) == pytest.approx(4.0) def test_negation_of_zero_is_zero(self): assert -C(0) == C(0) def test_to_gerber_parameter_no_parens(self): assert NegatedExpression(P(1)).to_gerber() == '-$1' def test_to_gerber_operator_uses_parens(self): inner = OperatorExpression(op.add, P(1), P(2)) assert NegatedExpression(inner).to_gerber() == '-($1+$2)' class TestToGerber: def test_constant_integer(self): assert C(5).to_gerber() == '5' def test_constant_float(self): assert C(1.5).to_gerber() == '1.5' def test_parameter(self): assert P(1).to_gerber() == '$1' assert P(99).to_gerber() == '$99' def test_add_operator(self): assert OperatorExpression(op.add, P(1), P(2)).to_gerber() == '$1+$2' def test_sub_operator(self): assert OperatorExpression(op.sub, P(1), P(2)).to_gerber() == '$1-$2' def test_mul_uses_x(self): # Gerber spec uses 'x' for multiplication, not '*' assert OperatorExpression(op.mul, P(1), P(2)).to_gerber() == '$1x$2' def test_div_operator(self): assert OperatorExpression(op.truediv, P(1), P(2)).to_gerber() == '$1/$2' def test_lhs_operator_gets_parens(self): lhs = OperatorExpression(op.add, P(1), P(2)) e = OperatorExpression(op.mul, lhs, P(3)) assert e.to_gerber() == '($1+$2)x$3' def test_rhs_operator_gets_parens(self): rhs = OperatorExpression(op.add, P(2), P(3)) e = OperatorExpression(op.mul, P(1), rhs) assert e.to_gerber() == '$1x($2+$3)' def test_nested_lhs_and_rhs_parens(self): lhs = OperatorExpression(op.add, P(1), P(2)) outer = OperatorExpression(op.add, lhs, P(3)) assert outer.to_gerber() == '($1+$2)+$3' def test_negated_mul_to_gerber(self): # P(1) * (-P(2)) optimises to -(P(1)*P(2)); NegatedExpression wraps the product assert (P(1) * (-P(2))).to_gerber() == '-($1x$2)' def test_negated_div_to_gerber(self): assert (P(1) / (-P(2))).to_gerber() == '-($1/$2)' def test_negative_constant(self): assert C(-5).to_gerber() == '-5' class TestParsing: def test_constant_integer(self): assert _parse_expression('5', {}, set()) == C(5) def test_constant_float(self): assert _parse_expression('1.5', {}, set()) == C(1.5) def test_parameter_reference(self): params = set() assert _parse_expression('$1', {}, params) == P(1) assert 1 in params def test_multiple_parameters_tracked(self): params = set() _parse_expression('$1+$3', {}, params) assert params == {1, 3} def test_add(self): assert _parse_expression('$1+$2', {}, set()).calculate({1: 3, 2: 4}) == pytest.approx(7) def test_sub(self): assert _parse_expression('$1-$2', {}, set()).calculate({1: 10, 2: 4}) == pytest.approx(6) def test_mul_gerber_x_syntax(self): assert _parse_expression('$1x$2', {}, set()).calculate({1: 3, 2: 4}) == pytest.approx(12) def test_mul_uppercase_x(self): assert _parse_expression('$1X$2', {}, set()).calculate({1: 3, 2: 4}) == pytest.approx(12) def test_div(self): assert _parse_expression('$1/$2', {}, set()).calculate({1: 10, 2: 4}) == pytest.approx(2.5) def test_negation(self): assert _parse_expression('-$1', {}, set()).calculate({1: 5}) == pytest.approx(-5) def test_parenthesized(self): assert _parse_expression('($1+$2)x$3', {}, set()).calculate({1: 3, 2: 4, 3: 2}) == pytest.approx(14) def test_known_variable_becomes_variable_expression(self): e = _parse_expression('$1', {1: C(10)}, set()) assert isinstance(e, VariableExpression) @pytest.mark.parametrize('gerber_str,py_str,binding', [ ('$1+$2', 'a+b', {1: 5, 2: 3 }), ('$1-$2', 'a-b', {1: 5, 2: 3 }), ('$1x$2', 'a*b', {1: 5, 2: 3 }), ('$1/$2', 'a/b', {1: 6, 2: 3 }), ('($1+$2)x$3', '(a+b)*c', {1: 2, 2: 3, 3: 4 }), ('$1x$2+$3', 'a*b+c', {1: 2, 2: 3, 3: 4 }), ('-$1+$2', '-a+b', {1: 2, 2: 7 }), ('$1/$2+$1x$2', 'a/b+a*b', {1: 6, 2: 2 }), ]) def test_parse_and_evaluate(self, gerber_str, py_str, binding): e = _parse_expression(gerber_str, {}, set()) a = binding.get(1, 0) b = binding.get(2, 0) c = binding.get(3, 0) expected = eval(py_str) # noqa: S307 – controlled test literals only assert e.calculate(binding) == pytest.approx(expected) @pytest.mark.parametrize('make_expr,binding', [ (lambda p1, p2, p3: p1 + p2, {1: 3, 2: 7, 3: 0}), (lambda p1, p2, p3: p1 - p2, {1: 10, 2: 3, 3: 0}), (lambda p1, p2, p3: p1 * p2, {1: 3, 2: 4, 3: 0}), (lambda p1, p2, p3: p1 / p2, {1: 9, 2: 3, 3: 0}), (lambda p1, p2, p3: (p1 + p2) * p3, {1: 2, 2: 3, 3: 4}), (lambda p1, p2, p3: p1 * p2 - p3, {1: 5, 2: 2, 3: 3}), (lambda p1, p2, p3: p1 / p2 + p3, {1: 6, 2: 3, 3: 1}), (lambda p1, p2, p3: -p1 + p2, {1: 3, 2: 7, 3: 0}), (lambda p1, p2, p3: p1 * (-p2), {1: 3, 2: 4, 3: 0}), (lambda p1, p2, p3: (-p1) * p2, {1: 3, 2: 4, 3: 0}), (lambda p1, p2, p3: p1 / (-p2), {1: 9, 2: 3, 3: 0}), (lambda p1, p2, p3: (-p1) / p2, {1: 9, 2: 3, 3: 0}), (lambda p1, p2, p3: (p1 + p2) / p3 - p1, {1: 3, 2: 5, 3: 4}), (lambda p1, p2, p3: p1 * p2 + p3 / p1, {1: 3, 2: 4, 3: 6}), (lambda p1, p2, p3: -(p1 + p2) * p3, {1: 2, 2: 3, 3: 4}), ]) def test_to_gerber_round_trip(self, make_expr, binding): """to_gerber() followed by _parse_expression() must preserve the evaluated value.""" original = make_expr(P(1), P(2), P(3)) gerber = original.to_gerber() parsed = _parse_expression(gerber, {}, set()) assert parsed.calculate(binding) == pytest.approx(original.calculate(binding)) class TestUnitExpression: def test_mm_to_mm_unchanged(self): assert UnitExpression(C(25.4), MM).calculate(unit=MM) == pytest.approx(25.4) def test_inch_to_mm(self): assert UnitExpression(C(1.0), Inch).calculate(unit=MM) == pytest.approx(MILLIMETERS_PER_INCH) def test_mm_to_inch(self): assert UnitExpression(C(25.4), MM).calculate(unit=Inch) == pytest.approx(25.4 / MILLIMETERS_PER_INCH) def test_inch_to_inch_unchanged(self): assert UnitExpression(C(2.0), Inch).calculate(unit=Inch) == pytest.approx(2.0) def test_none_unit_passes_through(self): assert UnitExpression(C(5.0), None).calculate(unit=MM) == pytest.approx(5.0) def test_negation_preserves_unit(self): neg = -UnitExpression(C(5.0), MM) assert isinstance(neg, UnitExpression) and neg.unit == MM assert neg.calculate(unit=MM) == pytest.approx(-5.0) def test_add_same_unit(self): result = UnitExpression(C(3.0), MM) + UnitExpression(C(4.0), MM) assert isinstance(result, UnitExpression) assert result.calculate(unit=MM) == pytest.approx(7.0) def test_add_mixed_units_converts(self): # 1 inch + 1 mm, result held in Inch result = UnitExpression(C(1.0), Inch) + UnitExpression(C(1.0), MM) assert result.calculate(unit=Inch) == pytest.approx(1.0 + 1.0 / MILLIMETERS_PER_INCH) def test_add_scalar_raises(self): with pytest.raises(ValueError): UnitExpression(C(5.0), MM) + C(3.0) def test_radd_scalar_raises(self): # BUG: asymmetric unit safety — C(3.0) + UnitExpression(...) does NOT raise because # Python dispatches to Expression.__add__ first, which has no unit awareness. # Only plain Python scalars (not Expression subclasses) trigger __radd__ on UnitExpression. # There is no really nice fix for this, so we just leave it in for now. with pytest.raises(ValueError): 5.0 + UnitExpression(C(5.0), MM) def test_mul_by_scalar(self): result = UnitExpression(C(3.0), MM) * C(2) assert isinstance(result, UnitExpression) assert result.calculate(unit=MM) == pytest.approx(6.0) def test_div_by_scalar(self): result = UnitExpression(C(6.0), MM) / C(2) assert isinstance(result, UnitExpression) assert result.calculate(unit=MM) == pytest.approx(3.0) def test_nested_unit_expression_flattens(self): # Wrapping a UnitExpression in another converts rather than double-wrapping inner = UnitExpression(C(1.0), Inch) outer = UnitExpression(inner, MM) assert not isinstance(outer.expr, UnitExpression) assert outer.calculate(unit=MM) == pytest.approx(MILLIMETERS_PER_INCH) def test_parameters_forwarded(self): assert list(UnitExpression(P(1), MM).parameters()) == [P(1)] class TestExprHelper: def test_passthrough_expression(self): p = P(1) assert expr(p) is p def test_wraps_int(self): result = expr(5) assert isinstance(result, ConstantExpression) and result.value == 5 def test_wraps_float(self): result = expr(3.14) assert isinstance(result, ConstantExpression) and result.value == pytest.approx(3.14) class TestVariableExpression: def test_optimized_non_operator_unwraps(self): # A VariableExpression wrapping something that simplifies to a non-OperatorExpression # should unwrap and return the simplified value directly. result = VariableExpression(C(5)).optimized() assert result == C(5) def test_optimized_keeps_operator_expression(self): ve = VariableExpression(OperatorExpression(op.add, P(1), P(2))) assert isinstance(ve.optimized(), VariableExpression) def test_to_gerber_without_register_uses_inner(self): assert VariableExpression(C(42)).to_gerber(register_variable=None) == '42' def test_to_gerber_with_register_allocates_dollar_variable(self): allocated = {} def register(e): key = e.to_gerber() if key not in allocated: allocated[key] = len(allocated) + 1 return allocated[key] inner = OperatorExpression(op.add, P(1), P(2)) result = VariableExpression(inner).to_gerber(register_variable=register) assert result.startswith('$') and int(result[1:]) >= 1