"""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()