Files

723 lines
29 KiB
Python

"""Complete coverage for expression-driven dimension tolerance chains."""
from __future__ import annotations
import json
import math
import unittest
import simplecadapi as scad
class TestDimensionToleranceDeclaration(unittest.TestCase):
def test_symmetric_and_asymmetric_tolerances(self):
symmetric = scad.var("width", 10.0, tolerance=0.2)
asymmetric = scad.var("shaft", 8.0, tolerance=(-0.05, 0.0))
self.assertEqual(symmetric.tolerance.lower_deviation, -0.2)
self.assertEqual(symmetric.tolerance.upper_deviation, 0.2)
self.assertEqual(asymmetric.tolerance.lower_deviation, -0.05)
self.assertEqual(asymmetric.tolerance.upper_deviation, 0.0)
def test_dimension_tolerance_roundtrip(self):
tolerance = scad.DimensionTolerance(-0.1, 0.3)
self.assertEqual(
scad.DimensionTolerance.from_dict(tolerance.to_dict()), tolerance
)
self.assertAlmostEqual(tolerance.width, 0.4)
def test_invalid_variable_and_tolerance_values_are_rejected(self):
invalid_calls = [
lambda: scad.var("x", True),
lambda: scad.var("x", math.inf),
lambda: scad.var("x", 1.0, tolerance=True),
lambda: scad.var("x", 1.0, tolerance=-0.1),
lambda: scad.var("x", 1.0, tolerance=(0.1, 0.2)),
lambda: scad.var("x", 1.0, tolerance=(-0.2, -0.1)),
lambda: scad.var("x", 1.0, tolerance=(-0.1, 0.1, 0.2)),
lambda: scad.DimensionTolerance(float("nan"), 0.1),
]
for call in invalid_calls:
with self.subTest(call=call), self.assertRaises((TypeError, ValueError)):
call()
def test_expression_graph_preserves_variable_tolerances_and_forward_refs(self):
width = scad.var("width", 10.0, tolerance=(-0.1, 0.2))
graph = scad.ExpressionGraph()
expression = width * 2.0
graph.register(expression)
payload = graph.to_dict()
payload["nodes"].reverse()
rebuilt = scad.ExpressionGraph.from_dict(payload)
rebuilt_width = rebuilt.get(width.expr_id)
self.assertIsInstance(rebuilt_width, scad.Var)
self.assertEqual(rebuilt_width.tolerance, width.tolerance)
self.assertAlmostEqual(rebuilt.get(expression.expr_id).evaluate(), 20.0)
def test_var_positional_expr_id_remains_compatible(self):
variable = scad.Var("width", 10.0, "plate width", "var_width")
self.assertEqual(variable.expr_id, "var_width")
self.assertIsNone(variable.tolerance)
def test_malformed_expression_graphs_are_rejected(self):
valid_var = {
"expr_id": "var_x",
"kind": "var",
"name": "x",
"default": 1.0,
"tolerance": {"lower_deviation": -0.1, "upper_deviation": 0.1},
}
payloads = [
{"nodes": [valid_var, dict(valid_var)]},
{
"nodes": [
{
"expr_id": "expr_a",
"kind": "expr",
"op": "neg",
"args": ["missing"],
}
]
},
{
"nodes": [
{
"expr_id": "expr_a",
"kind": "expr",
"op": "neg",
"args": ["expr_b"],
},
{
"expr_id": "expr_b",
"kind": "expr",
"op": "neg",
"args": ["expr_a"],
},
]
},
{
"nodes": [
valid_var,
{
"expr_id": "expr_bad",
"kind": "expr",
"op": "add",
"args": ["var_x"],
},
]
},
]
for payload in payloads:
with self.subTest(payload=payload), self.assertRaises(ValueError):
scad.ExpressionGraph.from_dict(payload)
def test_expression_registration_is_atomic_for_conflicting_ids(self):
left = scad.Var(
"left",
1.0,
expr_id="var_duplicate",
tolerance=scad.DimensionTolerance.symmetric(0.1),
)
right = scad.Var(
"right",
2.0,
expr_id="var_duplicate",
tolerance=scad.DimensionTolerance.symmetric(0.1),
)
expression = scad.Expr("add", (left, right))
graph = scad.ExpressionGraph()
with self.assertRaisesRegex(ValueError, "already registered"):
graph.register(expression)
self.assertEqual(graph.node_count, 0)
def test_expression_registration_rejects_cycles_without_mutation(self):
expression = scad.Expr("neg", (scad.const(1.0),), expr_id="expr_cycle")
object.__setattr__(expression, "args", (expression,))
graph = scad.ExpressionGraph()
with self.assertRaisesRegex(ValueError, "cycle"):
graph.register(expression)
self.assertEqual(graph.node_count, 0)
def test_expression_arguments_are_normalized_to_an_immutable_tuple(self):
arguments = [scad.const(1.0)]
expression = scad.Expr("neg", arguments)
arguments[0] = expression
self.assertIsInstance(expression.args, tuple)
self.assertIsNot(expression.args[0], expression)
def test_signed_zero_nodes_with_the_same_id_are_distinct(self):
positive = scad.Const(0.0, expr_id="const_zero")
negative = scad.Const(-0.0, expr_id="const_zero")
graph = scad.ExpressionGraph()
graph.register(positive)
with self.assertRaisesRegex(ValueError, "different node"):
graph.register(negative)
def test_expression_payload_missing_required_fields_is_rejected_cleanly(self):
payloads = [
{"nodes": [{"expr_id": "const_x", "kind": "const"}]},
{
"nodes": [
{"expr_id": "var_x", "kind": "var", "default": 1.0}
]
},
{
"nodes": [
{"expr_id": "var_x", "kind": "var", "name": "x"}
]
},
{
"nodes": [
{"expr_id": "expr_x", "kind": "expr", "args": []}
]
},
]
for payload in payloads:
with self.subTest(payload=payload), self.assertRaises(ValueError):
scad.ExpressionGraph.from_dict(payload)
def test_huge_numeric_inputs_are_rejected_as_non_finite(self):
with self.assertRaisesRegex(ValueError, "finite"):
scad.const(10**10000)
class TestWorstCaseTolerancePropagation(unittest.TestCase):
def test_addition_and_subtraction_preserve_asymmetric_deviations(self):
a = scad.var("a", 10.0, tolerance=(-0.1, 0.2))
b = scad.var("b", 5.0, tolerance=(-0.3, 0.4))
result = scad.analyze_tolerance(a - b)
self.assertAlmostEqual(result.nominal, 5.0)
self.assertAlmostEqual(result.lower_deviation, -0.5)
self.assertAlmostEqual(result.upper_deviation, 0.5)
self.assertEqual(len(result.contributions), 2)
def test_linear_scaling_and_repeated_variable_dependency_are_exact(self):
x = scad.var("x", 4.0, tolerance=(-0.2, 0.3))
scaled = scad.analyze_tolerance(-2.0 * x + 1.0)
cancelled = scad.analyze_tolerance(x - x)
self.assertAlmostEqual(scaled.lower_deviation, -0.6)
self.assertAlmostEqual(scaled.upper_deviation, 0.4)
self.assertAlmostEqual(cancelled.lower_deviation, 0.0)
self.assertAlmostEqual(cancelled.upper_deviation, 0.0)
def test_multiplication_handles_all_interval_corner_signs(self):
a = scad.var("a", -1.0, tolerance=(-1.0, 0.5))
b = scad.var("b", 3.0, tolerance=(-1.0, 1.0))
result = scad.analyze_tolerance(a * b)
self.assertAlmostEqual(result.nominal, -3.0)
self.assertAlmostEqual(result.lower_bound, -8.0)
self.assertAlmostEqual(result.upper_bound, -1.0)
def test_division_rejects_denominator_interval_containing_zero(self):
numerator = scad.var("numerator", 2.0, tolerance=0.1)
denominator = scad.var("denominator", 1.0, tolerance=1.0)
with self.assertRaisesRegex(
scad.ToleranceAnalysisError, "denominator.*contains zero"
):
scad.analyze_tolerance(numerator / denominator)
def test_integer_and_fractional_power_intervals(self):
signed = scad.var("signed", 0.0, tolerance=2.0)
positive = scad.var("positive", 4.0, tolerance=(-3.0, 5.0))
squared = scad.analyze_tolerance(signed**2)
rooted = scad.analyze_tolerance(scad.sqrt(positive))
self.assertLessEqual(squared.lower_bound, 0.0)
self.assertGreaterEqual(squared.upper_bound, 4.0)
self.assertAlmostEqual(squared.upper_bound, 4.0)
self.assertLessEqual(rooted.lower_bound, 1.0)
self.assertGreaterEqual(rooted.upper_bound, 3.0)
self.assertAlmostEqual(rooted.lower_bound, 1.0)
self.assertAlmostEqual(rooted.upper_bound, 3.0)
def test_power_domain_errors_are_explicit(self):
negative = scad.var("negative", -2.0, tolerance=0.1)
crosses_zero = scad.var("crosses_zero", 0.0, tolerance=0.1)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "negative base"):
scad.analyze_tolerance(negative**0.5)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "zero"):
scad.analyze_tolerance(crosses_zero ** -1)
def test_invalid_constant_subexpressions_use_analysis_errors(self):
expression = scad.sqrt(-1.0)
with self.assertRaisesRegex(
scad.ToleranceAnalysisError, "declared tolerance interval"
):
scad.analyze_tolerance(expression)
def test_trigonometric_extrema_and_discontinuities(self):
angle = scad.var("angle", 0.0, tolerance=(-math.pi, math.pi))
tangent_angle = scad.var(
"tangent_angle", math.pi / 2.0, tolerance=0.1
)
sine = scad.analyze_tolerance(scad.sin(angle))
cosine = scad.analyze_tolerance(scad.cos(angle))
self.assertEqual((sine.lower_bound, sine.upper_bound), (-1.0, 1.0))
self.assertEqual((cosine.lower_bound, cosine.upper_bound), (-1.0, 1.0))
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "discontinuity"):
scad.analyze_tolerance(scad.tan(tangent_angle))
def test_inverse_function_domains_are_checked_over_full_interval(self):
below_sqrt = scad.var("below_sqrt", 0.1, tolerance=0.2)
outside_unit = scad.var("outside_unit", 0.9, tolerance=0.2)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "below zero"):
scad.analyze_tolerance(scad.sqrt(below_sqrt))
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "outside"):
scad.analyze_tolerance(scad.asin(outside_unit))
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "outside"):
scad.analyze_tolerance(scad.acos(outside_unit))
def test_atan_and_atan2_propagation(self):
y = scad.var("y", 2.0, tolerance=0.1)
x = scad.var("x", 3.0, tolerance=0.2)
origin_y = scad.var("origin_y", 0.0, tolerance=0.1)
origin_x = scad.var("origin_x", 0.0, tolerance=0.1)
atan_result = scad.analyze_tolerance(scad.atan(y))
atan2_result = scad.analyze_tolerance(scad.atan2(y, x))
self.assertLess(atan_result.lower_bound, atan_result.upper_bound)
self.assertLess(atan2_result.lower_bound, atan2_result.upper_bound)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "undefined origin"):
scad.analyze_tolerance(scad.atan2(origin_y, origin_x))
def test_atan2_negative_x_branch_cut_is_not_underestimated(self):
y = scad.var("y", 0.0, tolerance=0.1)
x = scad.var("x", -2.0, tolerance=0.1)
expression = scad.atan2(y, x)
result = scad.analyze_tolerance(expression)
self.assertEqual(result.lower_bound, -math.pi)
self.assertEqual(result.upper_bound, math.pi)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "branch cut"):
scad.analyze_tolerance(expression, method="rss")
def test_atan2_signed_zero_branch_cut_is_rejected_for_rss(self):
y = scad.var("y", -0.0, tolerance=(0.0, 0.1))
x = scad.var("x", -2.0, tolerance=0.0)
expression = scad.atan2(y, x)
worst_case = scad.analyze_tolerance(expression)
self.assertEqual(worst_case.lower_bound, -math.pi)
self.assertEqual(worst_case.upper_bound, math.pi)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "branch cut"):
scad.analyze_tolerance(expression, method="rss")
def test_abs_crossing_zero_and_negation(self):
value = scad.var("value", 0.25, tolerance=0.5)
absolute = scad.analyze_tolerance(abs(value))
negated = scad.analyze_tolerance(-value)
self.assertEqual(absolute.lower_bound, 0.0)
self.assertAlmostEqual(absolute.upper_bound, 0.75)
self.assertAlmostEqual(negated.lower_deviation, -0.5)
self.assertAlmostEqual(negated.upper_deviation, 0.5)
def test_nonlinear_contributions_are_reported_in_target_units(self):
value = scad.var("value", 2.0, tolerance=0.1)
result = scad.analyze_tolerance(value**2)
self.assertIsNone(result.contributions[0].sensitivity)
self.assertAlmostEqual(result.contributions[0].lower_deviation, -0.39)
self.assertAlmostEqual(result.contributions[0].upper_deviation, 0.41)
def test_large_nominal_value_does_not_erase_small_declared_deviations(self):
value = scad.var("large", 1e16, tolerance=0.1)
result = scad.analyze_tolerance(value)
self.assertEqual(result.lower_deviation, -0.1)
self.assertEqual(result.upper_deviation, 0.1)
self.assertLess(result.lower_bound, result.nominal)
self.assertGreater(result.upper_bound, result.nominal)
def test_underflowed_nonlinear_range_remains_conservative(self):
value = scad.var("tiny", 0.0, tolerance=1e-200)
result = scad.analyze_tolerance(value**2)
check = scad.check_tolerance(value**2, 0.0)
self.assertEqual(result.lower_deviation, 0.0)
self.assertGreater(result.upper_deviation, 0.0)
self.assertFalse(check.passed)
def test_large_atan_input_keeps_nonzero_worst_case_range(self):
value = scad.var("large", 1e160, tolerance=1e100)
result = scad.analyze_tolerance(scad.atan(value))
self.assertLess(result.lower_deviation, 0.0)
self.assertGreater(result.upper_deviation, 0.0)
def test_zero_tolerance_large_periodic_input_remains_a_point(self):
angle = scad.var("angle", 1e16, tolerance=0.0)
tangent = scad.analyze_tolerance(scad.tan(angle))
sine = scad.analyze_tolerance(scad.sin(angle))
self.assertEqual(tangent.lower_deviation, 0.0)
self.assertEqual(tangent.upper_deviation, 0.0)
self.assertEqual(sine.lower_deviation, 0.0)
self.assertEqual(sine.upper_deviation, 0.0)
def test_exact_zero_absolute_value_is_not_spuriously_widened(self):
value = scad.var("zero", 0.0, tolerance=0.0)
result = scad.analyze_tolerance(abs(value))
self.assertEqual(result.lower_deviation, 0.0)
self.assertEqual(result.upper_deviation, 0.0)
class TestRssTolerancePropagation(unittest.TestCase):
def test_independent_sources_are_combined_by_root_sum_square(self):
a = scad.var("a", 10.0, tolerance=0.3)
b = scad.var("b", 4.0, tolerance=0.4)
result = scad.analyze_tolerance(a + b, method="rss")
self.assertAlmostEqual(result.lower_deviation, -0.5)
self.assertAlmostEqual(result.upper_deviation, 0.5)
def test_repeated_source_is_not_treated_as_independent(self):
x = scad.var("x", 2.0, tolerance=0.2)
cancelled = scad.analyze_tolerance(x - x, method="rss")
doubled = scad.analyze_tolerance(x + x, method="rss")
self.assertAlmostEqual(cancelled.lower_deviation, 0.0)
self.assertAlmostEqual(cancelled.upper_deviation, 0.0)
self.assertAlmostEqual(doubled.lower_deviation, -0.4)
self.assertAlmostEqual(doubled.upper_deviation, 0.4)
def test_nonlinear_sensitivity_is_analytic(self):
x = scad.var("x", 2.0, tolerance=0.1)
squared = scad.analyze_tolerance(x**2, method="rss")
sine = scad.analyze_tolerance(scad.sin(x), method="rss")
self.assertAlmostEqual(squared.lower_deviation, -0.4)
self.assertAlmostEqual(squared.upper_deviation, 0.4)
self.assertAlmostEqual(
sine.upper_deviation, abs(math.cos(2.0)) * 0.1
)
def test_rss_rejects_non_differentiable_and_invalid_intervals(self):
at_zero = scad.var("at_zero", 0.0, tolerance=0.1)
denominator = scad.var("denominator", 1.0, tolerance=1.0)
numerator = scad.var("numerator", 2.0, tolerance=0.1)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "abs"):
scad.analyze_tolerance(abs(at_zero), method="rss")
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "denominator"):
scad.analyze_tolerance(numerator / denominator, method="rss")
def test_all_unary_and_binary_derivative_paths_are_supported(self):
a = scad.var("a", 0.5, tolerance=0.01)
b = scad.var("b", 2.0, tolerance=0.02)
expressions = [
a + b,
a - b,
a * b,
a / b,
b**a,
-a,
abs(a),
scad.sin(a),
scad.cos(a),
scad.tan(a),
scad.sqrt(b),
scad.acos(a),
scad.asin(a),
scad.atan(a),
scad.atan2(a, b),
]
for expression in expressions:
with self.subTest(op=getattr(expression, "op", None)):
result = scad.analyze_tolerance(expression, method="rss")
self.assertTrue(math.isfinite(result.lower_bound))
self.assertTrue(math.isfinite(result.upper_bound))
def test_constant_boundary_functions_have_zero_rss_tolerance(self):
expressions = [scad.sqrt(0.0), scad.acos(1.0), scad.asin(-1.0)]
for expression in expressions:
with self.subTest(op=expression.op):
result = scad.analyze_tolerance(expression, method="rss")
self.assertEqual(result.lower_deviation, 0.0)
self.assertEqual(result.upper_deviation, 0.0)
def test_rss_uses_overflow_safe_root_sum_square(self):
a = scad.var("a", 0.0, tolerance=1e200)
b = scad.var("b", 0.0, tolerance=1e200)
result = scad.analyze_tolerance(a + b, method="rss")
expected = math.hypot(1e200, 1e200)
self.assertTrue(math.isfinite(result.upper_deviation))
self.assertLessEqual(result.lower_deviation, -expected)
self.assertGreaterEqual(result.upper_deviation, expected)
self.assertAlmostEqual(result.upper_deviation / expected, 1.0)
def test_rss_atan2_derivative_is_stable_for_large_coordinates(self):
y = scad.var("y", 1e200, tolerance=1e190)
x = scad.var("x", 1e200, tolerance=1e190)
result = scad.analyze_tolerance(scad.atan2(y, x), method="rss")
self.assertTrue(math.isfinite(result.upper_deviation))
self.assertGreater(result.upper_deviation, 0.0)
def test_rss_atan_derivative_does_not_underflow_before_contribution(self):
value = scad.var("large", 1e160, tolerance=1e100)
result = scad.analyze_tolerance(scad.atan(value), method="rss")
self.assertGreaterEqual(result.upper_deviation, 1e-220)
def test_ill_conditioned_affine_coefficients_preserve_source_effect(self):
value = scad.var("value", 1.0, tolerance=0.1)
expression = (1e16 * value + value) - 1e16 * value
worst_case = scad.analyze_tolerance(expression)
rss = scad.analyze_tolerance(expression, method="rss")
self.assertLessEqual(worst_case.lower_deviation, -0.1)
self.assertGreaterEqual(worst_case.upper_deviation, 0.1)
self.assertLessEqual(rss.lower_deviation, -0.1)
self.assertGreaterEqual(rss.upper_deviation, 0.1)
class TestToleranceRequirementsAndPersistence(unittest.TestCase):
def test_check_tolerance_returns_margins_without_raising(self):
a = scad.var("a", 10.0, tolerance=0.2)
b = scad.var("b", 5.0, tolerance=0.1)
passing = scad.check_tolerance(a - b, 0.3, name="clearance")
failing = scad.check_tolerance(a - b, 0.2, name="clearance")
self.assertTrue(passing.passed)
self.assertAlmostEqual(passing.lower_margin, 0.0)
self.assertAlmostEqual(passing.upper_margin, 0.0)
self.assertFalse(failing.passed)
def test_requirement_comparison_does_not_scale_epsilon_by_nominal(self):
value = scad.var("large", 1e16, tolerance=0.1)
check = scad.check_tolerance(value, 0.0)
self.assertFalse(check.passed)
self.assertLess(check.lower_margin, 0.0)
self.assertLess(check.upper_margin, 0.0)
def test_analysis_rejects_conflicting_expression_ids_before_caching(self):
left = scad.Var(
"left",
1.0,
expr_id="var_duplicate",
tolerance=scad.DimensionTolerance.symmetric(0.1),
)
right = scad.Var(
"right",
2.0,
expr_id="var_duplicate",
tolerance=scad.DimensionTolerance.symmetric(0.2),
)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "multiple"):
scad.analyze_tolerance(scad.Expr("add", (left, right)))
def test_every_variable_requires_an_explicit_source_tolerance(self):
declared = scad.var("declared", 2.0, tolerance=0.1)
missing = scad.var("missing", 1.0)
with self.assertRaisesRegex(scad.ToleranceAnalysisError, "missing"):
scad.analyze_tolerance(declared + missing)
def test_tolerance_graph_validates_all_requirements(self):
a = scad.var("a", 10.0, tolerance=0.2)
b = scad.var("b", 5.0, tolerance=0.1)
expression_graph = scad.ExpressionGraph()
tolerance_graph = scad.ToleranceGraph(expression_graph)
tolerance_graph.require(a + b, 0.3, name="overall")
tolerance_graph.require(a - b, 0.2, name="clearance")
report = tolerance_graph.validate()
self.assertFalse(report.passed)
self.assertEqual(report.checks[0].requirement.name, "overall")
with self.assertRaises(scad.ToleranceValidationError) as context:
tolerance_graph.validate(raise_on_failure=True)
self.assertIsNotNone(context.exception.report)
def test_tolerance_graph_roundtrip_and_dangling_refs(self):
width = scad.var("width", 10.0, tolerance=0.1)
expression_graph = scad.ExpressionGraph()
tolerance_graph = scad.ToleranceGraph(expression_graph)
requirement = tolerance_graph.require(
width * 2.0, 0.2, name="overall_width", requirement_id="req_width"
)
expression_payload = expression_graph.to_dict()
rebuilt_expression_graph = scad.ExpressionGraph.from_dict(expression_payload)
rebuilt = scad.ToleranceGraph.from_dict(
tolerance_graph.to_dict(), rebuilt_expression_graph
)
self.assertEqual(rebuilt.requirement_count, 1)
self.assertEqual(rebuilt.requirements[0], requirement)
broken = tolerance_graph.to_dict()
broken["requirements"][0]["target_expr_id"] = "missing"
with self.assertRaisesRegex(ValueError, "Unknown tolerance target"):
scad.ToleranceGraph.from_dict(broken, rebuilt_expression_graph)
def test_tolerance_graph_rejects_malformed_requirement_types(self):
width = scad.var("width", 10.0, tolerance=0.1)
expression_graph = scad.ExpressionGraph()
expression_graph.register(width)
base = {
"requirements": [
{
"requirement_id": "req_width",
"target_expr_id": width.expr_id,
"tolerance": {
"lower_deviation": -0.1,
"upper_deviation": 0.1,
},
"method": "worst_case",
"name": "width",
}
]
}
for field, invalid in (
("requirement_id", 1),
("target_expr_id", 1),
("method", []),
("name", 1),
("name", ""),
):
payload = json.loads(json.dumps(base))
payload["requirements"][0][field] = invalid
with self.subTest(field=field), self.assertRaises((TypeError, ValueError)):
scad.ToleranceGraph.from_dict(payload, expression_graph)
def test_duplicate_requirement_ids_are_rejected(self):
width = scad.var("width", 10.0, tolerance=0.1)
graph = scad.ToleranceGraph(scad.ExpressionGraph())
graph.require(width, 0.1, requirement_id="same")
with self.assertRaisesRegex(ValueError, "Duplicate"):
graph.require(width, 0.1, requirement_id="same")
def test_explicit_invalid_requirement_ids_and_names_are_rejected(self):
width = scad.var("width", 10.0, tolerance=0.1)
graph = scad.ToleranceGraph(scad.ExpressionGraph())
for requirement_id in ("", 0, []):
with self.subTest(requirement_id=requirement_id), self.assertRaises(
(TypeError, ValueError)
):
graph.require(width, 0.1, requirement_id=requirement_id)
with self.assertRaises(ValueError):
graph.require(width, 0.1, name="")
def test_session_and_model_json_include_tolerance_graph(self):
width = scad.var("width", 10.0, tolerance=(-0.1, 0.2))
with scad.GraphSession() as session:
session.require_tolerance(width * 2.0, (-0.2, 0.4), name="overall")
session_payload = scad.import_session_json(scad.export_session_json(session))
model_payload = scad.import_model_json(scad.export_model_json(session))
self.assertEqual(session_payload["tolerance_graph"].requirement_count, 1)
self.assertTrue(session_payload["tolerance_graph"].validate().passed)
self.assertEqual(model_payload["tolerance_graph"].requirement_count, 1)
def test_legacy_session_and_model_payloads_default_to_empty_tolerance_graph(self):
with scad.GraphSession() as session:
pass
raw_session = json.loads(scad.export_session_json(session))
raw_model = json.loads(scad.export_model_json(session))
raw_session.pop("tolerance_graph")
raw_model.pop("tolerance_graph")
imported_session = scad.import_session_json(json.dumps(raw_session))
imported_model = scad.import_model_json(json.dumps(raw_model))
self.assertEqual(imported_session["tolerance_graph"].requirement_count, 0)
self.assertEqual(imported_model["tolerance_graph"].requirement_count, 0)
def test_failed_requirement_blocks_session_and_model_export(self):
width = scad.var("width", 10.0, tolerance=0.2)
with scad.GraphSession() as session:
session.require_tolerance(width, 0.1, name="width")
with self.assertRaises(scad.ToleranceValidationError):
scad.export_session_json(session)
with self.assertRaises(scad.SimpleCADError):
scad.export_model_json(session)
def test_failed_requirement_in_hand_edited_model_blocks_replay(self):
width = scad.var("width", 10.0, tolerance=0.2)
with scad.GraphSession() as session:
scad.make_box_rsolid(width, 1.0, 1.0)
session.require_tolerance(width, 0.2, name="width")
payload = json.loads(scad.export_model_json(session))
payload["tolerance_graph"]["requirements"][0]["tolerance"] = {
"lower_deviation": -0.1,
"upper_deviation": 0.1,
}
with self.assertRaises(scad.SimpleCADError):
scad.replay_model_json(json.dumps(payload))
def test_analysis_and_report_payloads_are_json_serializable(self):
width = scad.var("width", 10.0, tolerance=0.1)
check = scad.check_tolerance(width * 2.0, 0.2, name="overall")
serialized = json.dumps(check.to_dict())
self.assertIn('"passed": true', serialized)
self.assertIn('"variable_name": "width"', serialized)
def test_unknown_method_is_rejected(self):
width = scad.var("width", 10.0, tolerance=0.1)
with self.assertRaisesRegex(ValueError, "Unsupported"):
scad.analyze_tolerance(width, method="monte_carlo")
if __name__ == "__main__":
unittest.main()