gerbonara/tests/test_aperture_macro.py
2026-03-22 13:22:11 +01:00

774 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2026 Jan Sebastian Götte <gerbonara@jaseg.de>
#
# 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