"""Metamorphic assembly tests. No SKU or company-BOM answer constants.""" import copy import random import unittest from structure_graph import ( GraphError, ParameterResolver, SCHEMA_VERSION, _axis_packing_feasibility, apply_graph_patch, build_dimension_ledger, build_source_topology_contract, compile_graph, normalize_graph_patch_payload, ) def param(value, unit="mm"): return {"kind": "length", "origin": "observed", "value": value, "unit": unit, "evidence_ids": ["view"], "confidence": 0.95} def frame(width, depth, height, thickness): return { "schema_version": SCHEMA_VERSION, "uncertainties": [], "observations": [{"id": "view", "source_type": "image", "image_id": "original-front", "confidence": 0.95}], "parameters": {"w": param(width), "d": param(depth), "h": param(height), "t": param(thickness), "inner": {"kind": "length", "origin": "derived", "expression": "w - 2*t"}}, "envelope_mm": ["w", "d", "h"], "parts": [{"id": "A", "name": "任意名称A", "shape": "box", "functional_role": "side", "thickness_axis": "x", "size_mm": ["t", "d", "h"], "observed_quantity": 2}, {"id": "B", "name": "任意名称B", "shape": "box", "functional_role": "shelf", "thickness_axis": "z", "size_mm": ["inner", "d", "t"], "observed_quantity": 1}], "instances": [{"id": "i1", "part_id": "A", "assembly_id": "root", "position_mm": [0, 0, 0]}, {"id": "i2", "part_id": "A", "assembly_id": "root", "position_mm": ["w-t", 0, 0]}, {"id": "i3", "part_id": "B", "assembly_id": "root", "position_mm": ["t", 0, "h-t"]}], "connections": [{"id": "join1", "kind": "fixed", "a": "i1", "b": "i3", "face_a": "x+", "face_b": "x-"}, {"id": "join2", "kind": "fixed", "a": "i3", "b": "i2", "face_a": "x+", "face_b": "x-"}], } def compile_case(graph): return compile_graph(graph, source_image_ids={"original-front"}) def anchored_frame(width=800, depth=400, height=1100, thickness=18): graph = frame(width, depth, height, thickness) graph["placement_solver"] = "anchored_axes_v1" graph["contact_derivation"] = "aabb_within_assembly_v1" graph["connections"] = [] placements = { "i1": { "x": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, "y": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, "z": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, }, "i2": { "x": {"mode": "align", "self_anchor": "max", "target": "envelope", "target_anchor": "max"}, "y": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, "z": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, }, "i3": { "x": {"mode": "align", "self_anchor": "min", "target": "i1", "target_anchor": "max"}, "y": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, "z": {"mode": "align", "self_anchor": "max", "target": "envelope", "target_anchor": "max"}, }, } for instance in graph["instances"]: instance.pop("position_mm") instance["placement"] = placements[instance["id"]] return graph def anchored_frame_with_drawer_assembly(): graph = anchored_frame() graph["assemblies"] = [ {"id": "root", "role": "carcass", "evidence_ids": ["view"], "bounds_mm": {"x": [0, "w"], "y": [0, "d"], "z": [0, "h"]}}, {"id": "drawer-1", "role": "drawer", "evidence_ids": ["view"], "bounds_mm": {"x": ["t+10", "w-t-10"], "y": [20, 320], "z": [500, 600]}}, ] graph["parts"].append({ "id": "D", "name": "独立运动总成代表体", "shape": "box", "functional_role": "other", "thickness_axis": None, "size_mm": ["inner - 20", 300, 100], "observed_quantity": 1, "evidence_ids": ["view"], }) graph["instances"].append({ "id": "drawer-solid", "part_id": "D", "assembly_id": "drawer-1", "axis_order": [0, 1, 2], "placement": { "x": {"mode": "align", "self_anchor": "min", "target": "assembly", "target_anchor": "min"}, "y": {"mode": "align", "self_anchor": "min", "target": "assembly", "target_anchor": "min"}, "z": {"mode": "align", "self_anchor": "min", "target": "assembly", "target_anchor": "min"}, }, }) graph["assembly_connections"] = [{ "id": "drawer-slide", "kind": "sliding", "a": "drawer-1", "b": "root", "axis": "y", "travel_limits_mm": [0, 250], "evidence_ids": ["view"], }] return graph def anchored_door_with_hardware(*, kind="surface", embed_depth_mm=0, hardware_x=285): """A product-independent moving panel with one explicitly mounted item.""" hardware_y = 0 if kind == "surface" else 5 return { "schema_version": SCHEMA_VERSION, "placement_solver": "anchored_axes_v1", "orientation_solver": "role_axis_templates_v1", "contact_derivation": "aabb_within_assembly_v1", "observations": [{ "id": "view", "source_type": "image", "image_id": "original-front", "confidence": 0.95, }], "parameters": {}, "envelope_mm": [700, 100, 700], "assemblies": [{ "id": "moving-panel", "role": "door", "evidence_ids": ["view"], "bounds_mm": {"x": [0, 600], "y": [20, 38], "z": [0, 700]}, }], "parts": [ {"id": "panel", "name": "任意运动板", "shape": "box", "functional_role": "door", "thickness_axis": "y", "size_mm": [600, 18, 700], "observed_quantity": 1}, {"id": "fitting", "name": "任意五金", "shape": "box", "functional_role": "hardware", "thickness_axis": None, "size_mm": [30, 20, 30], "observed_quantity": 1}, ], "instances": [ {"id": "panel-instance", "part_id": "panel", "assembly_id": "moving-panel", "placement": { "x": {"mode": "absolute", "value_mm": 0}, "y": {"mode": "absolute", "value_mm": 20}, "z": {"mode": "absolute", "value_mm": 0}, }}, {"id": "fitting-instance", "part_id": "fitting", "assembly_id": "moving-panel", "placement": { "x": {"mode": "absolute", "value_mm": hardware_x}, "y": {"mode": "absolute", "value_mm": hardware_y}, "z": {"mode": "absolute", "value_mm": 335}, }, "mountings": [{ "host_instance_id": "panel-instance", "kind": kind, "face": "y-", "embed_depth_mm": embed_depth_mm, "evidence_ids": ["view"], }]}, ], "connections": [], "assembly_connections": [], "uncertainties": [], } class StructureGraphTest(unittest.TestCase): def test_surface_mounted_hardware_can_protrude_beyond_structural_assembly_bounds(self): result = compile_case(anchored_door_with_hardware()) self.assertTrue(result["ok"], result["issues"]) self.assertEqual(1, len(result["mounting_audit"])) self.assertEqual("surface", result["mounting_audit"][0]["kind"]) self.assertEqual(0, result["mounting_audit"][0]["actual_embed_depth_mm"]) self.assertNotIn( "assembly_bounds_not_tight", {row["code"] for row in result["issues"]} ) def test_hardware_on_wrong_panel_position_is_still_blocking(self): result = compile_case(anchored_door_with_hardware(hardware_x=650)) codes = {row["code"] for row in result["issues"]} self.assertIn("hardware_mounting_geometry", codes) self.assertIn("assembly_internal_disconnected", codes) self.assertIn("disconnected_instance", codes) self.assertFalse(result["mounting_audit"]) def test_mortised_hardware_allows_only_declared_host_penetration(self): result = compile_case(anchored_door_with_hardware(kind="mortised", embed_depth_mm=5)) self.assertTrue(result["ok"], result["issues"]) self.assertEqual(5, result["mounting_audit"][0]["actual_embed_depth_mm"]) accepted = [row for row in result["collision_audit"] if row.get("accepted_as_mounting")] self.assertEqual(1, len(accepted)) too_deep = anchored_door_with_hardware(kind="mortised", embed_depth_mm=2) broken = compile_case(too_deep) self.assertIn("hardware_mounting_geometry", {row["code"] for row in broken["issues"]}) self.assertIn("solid_interference", {row["code"] for row in broken["issues"]}) def test_hardware_mounting_is_required_and_non_hardware_cannot_claim_it(self): missing = anchored_door_with_hardware() missing["instances"][1].pop("mountings") self.assertIn("hardware_mounting_missing", {row["code"] for row in compile_case(missing)["issues"]}) invalid = anchored_door_with_hardware() invalid["instances"][0]["mountings"] = copy.deepcopy(invalid["instances"][1]["mountings"]) self.assertIn("mounting_on_non_hardware", {row["code"] for row in compile_case(invalid)["issues"]}) def test_panel_hardware_feedback_only_suggests_real_broad_faces(self): graph = anchored_door_with_hardware() graph["instances"][1]["mountings"][0]["face"] = "x+" result = compile_case(graph) problem = next( row for row in result["issues"] if row["code"] == "hardware_mounting_geometry" ) self.assertIn("厚度轴y的广面", problem["detail"]) self.assertTrue(problem["compatible_mounting_values"]) self.assertEqual( {"y-", "y+"}, {row["face"] for row in problem["compatible_mounting_values"]} ) def test_role_axis_mode_accepts_direct_product_axes_without_instance_mapping(self): graph = anchored_frame() graph["orientation_solver"] = "role_axis_templates_v1" result = compile_case(graph) self.assertTrue(result["ok"], result["issues"]) self.assertEqual("role_axis_templates_v1", result["orientation_solver"]) self.assertTrue(all(row["accepted"] for row in result["orientation_audit"])) sizes = {row["id"]: row["size_mm"] for row in result["instances"]} self.assertEqual([18.0, 400.0, 1100.0], sizes["i1"]) self.assertEqual([764.0, 400.0, 18.0], sizes["i3"]) def test_role_axis_mode_blocks_role_thickness_mismatch(self): graph = anchored_frame() graph["orientation_solver"] = "role_axis_templates_v1" graph["parts"][0]["functional_role"] = "door" result = compile_case(graph) issue = next(row for row in result["issues"] if row["code"] == "role_axis_template") self.assertEqual("x", issue["actual"]) self.assertEqual(["y"], issue["expected"]) def test_role_axis_mode_blocks_second_instance_axis_swap(self): graph = anchored_frame() graph["orientation_solver"] = "role_axis_templates_v1" graph["instances"][0]["axis_order"] = [2, 1, 0] result = compile_case(graph) issue = next( row for row in result["issues"] if row["code"] == "role_axis_template" and row["path"].endswith("/axis_order") ) self.assertEqual([0, 1, 2], issue["expected"]) def test_role_axis_mode_is_invariant_to_display_names(self): graph = anchored_frame() graph["orientation_solver"] = "role_axis_templates_v1" first = compile_case(graph) graph["parts"][0]["name"] = "unrelated-display-name" graph["parts"][1]["name"] = "另一个完全无关的显示名" second = compile_case(graph) self.assertEqual(first["orientation_audit"], second["orientation_audit"]) structural = lambda rows: [ {key: value for key, value in row.items() if key != "name"} for row in rows ] self.assertEqual(structural(first["bom"]), structural(second["bom"])) def test_independent_multiview_role_count_blocks_silent_graph_omission(self): graph = anchored_frame() source_structure = { "parts": [ {"source_structure_id": "S1", "component_role": "side", "quantity": 2, "confidence": 0.96, "image_ids": [1, 2]}, {"source_structure_id": "S2", "component_role": "shelf", "quantity": 2, "confidence": 0.91, "image_ids": [1, 3]}, {"source_structure_id": "S3", "component_role": "door", "quantity": 5, "confidence": 0.51, "image_ids": [1]}, ] } contract = build_source_topology_contract(source_structure) self.assertEqual({"shelf": 2, "side": 2}, contract["role_counts"]) result = compile_graph( graph, source_image_ids={"original-front"}, topology_contract=contract ) self.assertFalse(result["ok"]) mismatch = next( row for row in result["issues"] if row["code"] == "source_topology_count_conflict" ) self.assertEqual( ("shelf", 2, 1), (mismatch["role"], mismatch["expected"], mismatch["actual"]), ) def test_random_dimensions_and_thickness_recompute_all_dependencies(self): rng = random.Random(80721) for _ in range(100): w, d, h, t = rng.randint(360, 2200), rng.randint(180, 800), rng.randint(500, 2200), rng.randint(9, 35) graph = frame(w, d, h, t) first = compile_case(graph) self.assertTrue(first["ok"], first["issues"]) new_w, new_t = w + rng.randint(5, 180), t + 3 graph["parameters"]["w"]["value"] = new_w graph["parameters"]["t"]["value"] = new_t second = compile_case(graph) self.assertTrue(second["ok"], second["issues"]) self.assertEqual(new_w - 2 * new_t, second["bom"][1]["size_mm"][0]) self.assertEqual([2, 1], [row["quantity"] for row in second["bom"]]) self.assertNotEqual(first["graph_sha256"], second["graph_sha256"]) def test_names_do_not_change_dimensions_role_or_count(self): graph = frame(840, 420, 1150, 16) before = compile_case(graph) graph["parts"][0]["name"] = "抽屉面板中竖板" graph["parts"][1]["name"] = "抽屉底板封边减板厚" after = compile_case(graph) self.assertTrue(after["ok"]) self.assertEqual(before["instances"], after["instances"]) def test_cm_and_mm_evidence_resolve_to_same_geometry(self): graph = frame(800, 400, 1100, 18) original = compile_case(graph) for value in graph["parameters"].values(): if value.get("origin") == "observed": value["value"] /= 10 value["unit"] = "cm" self.assertEqual(original["instances"], compile_case(graph)["instances"]) def test_connection_gap_returned_to_ai_not_silently_clamped(self): graph = frame(800, 400, 1100, 18) graph["instances"][2]["position_mm"][0] = "t*2" result = compile_case(graph) self.assertFalse(result["ok"]) self.assertIn("connection_gap", {row["code"] for row in result["issues"]}) self.assertEqual(36, result["instances"][2]["position_mm"][0]) def test_duplicate_solids_and_missing_instances_are_not_hidden(self): graph = frame(800, 400, 1100, 18) graph["instances"][1]["position_mm"] = [0, 0, 0] result = compile_case(graph) self.assertIn("duplicate_solid", {row["code"] for row in result["issues"]}) graph["instances"].pop() self.assertIn("uninstantiated_part", {row["code"] for row in compile_case(graph)["issues"]}) def test_dimension_errors_cycles_and_code_execution_are_rejected(self): graph = frame(800, 400, 1100, 18) for expression in ("w + 2", "w*w", "inner + t", "__import__('os').system('true')"): candidate = copy.deepcopy(graph) candidate["parameters"]["inner"]["expression"] = expression self.assertFalse(compile_case(candidate)["ok"], expression) def test_model_cannot_invent_original_image_or_user_confirmation(self): graph = frame(800, 400, 1100, 18) graph["observations"][0]["image_id"] = "invented" self.assertIn("evidence_image", {row["code"] for row in compile_case(graph)["issues"]}) graph["observations"][0]["source_type"] = "user" self.assertIn("evidence_user", {row["code"] for row in compile_case(graph)["issues"]}) def test_deleting_connections_cannot_hide_a_floating_part(self): graph = frame(800, 400, 1100, 18) graph["connections"] = [] result = compile_case(graph) self.assertFalse(result["ok"]) self.assertIn("disconnected_instance", {row["code"] for row in result["issues"]}) def test_geometry_context_accepts_mm_literals_but_derived_parameters_remain_dimension_safe(self): resolver = ParameterResolver({"w": param(800), "t": param(18)}, {"view": {"source_type": "image", "confidence": 0.95}}) self.assertEqual(5, resolver.length("5")) self.assertEqual(343, resolver.length("(w - 3*t) / 2 - 30")) with self.assertRaises(GraphError): resolver.expression("w - 30") def test_contact_solver_recomputes_normal_positions_and_keeps_audit(self): graph = frame(800, 400, 1100, 18) graph["constraint_solver"] = "orthogonal_contacts_v1" graph["instances"][1]["position_mm"] = [760, 0, 0] graph["instances"][2]["position_mm"] = [30, 0, "h-t"] result = compile_case(graph) self.assertTrue(result["ok"], result["issues"]) positions = {row["id"]: row["position_mm"] for row in result["instances"]} self.assertEqual(782, positions["i2"][0]) self.assertEqual(18, positions["i3"][0]) self.assertGreaterEqual(len(result["solver_adjustments"]), 2) def test_contact_solver_blocks_contradictory_cycle(self): graph = frame(800, 400, 1100, 18) graph["constraint_solver"] = "orthogonal_contacts_v1" graph["connections"].append({"id": "bad", "kind": "fixed", "a": "i1", "b": "i2", "face_a": "x-", "face_b": "x+"}) result = compile_case(graph) self.assertFalse(result["ok"]) self.assertIn("constraint_cycle_conflict", {row["code"] for row in result["issues"]}) def test_local_graph_patch_is_audited_and_cannot_escape_schema(self): graph = frame(800, 400, 1100, 18) updated, audit = apply_graph_patch(graph, [ {"op": "replace", "path": "/parameters/w/value", "value": 920}, {"op": "add", "path": "/uncertainties/-", "value": "隐藏连接件待制造模板复核"}, ]) self.assertEqual(920, updated["parameters"]["w"]["value"]) self.assertEqual(800, graph["parameters"]["w"]["value"]) self.assertEqual(2, len(audit)) with self.assertRaises(GraphError): apply_graph_patch(graph, [{"op": "replace", "path": "/schema_version", "value": "evil"}]) with self.assertRaises(GraphError): apply_graph_patch(graph, [{"op": "remove", "path": "/parameters/missing"}]) def test_local_graph_patch_resolves_stable_ids_in_arrays(self): graph = anchored_frame_with_drawer_assembly() updated, audit = apply_graph_patch(graph, [ {"op": "replace", "path": "/assemblies/drawer-1/bounds_mm/z", "value": [510, 610]}, {"op": "add", "path": "/instances/drawer-solid/placement/z/offset_mm", "value": 5}, ]) self.assertEqual([510, 610], updated["assemblies"][1]["bounds_mm"]["z"]) self.assertEqual(5, updated["instances"][-1]["placement"]["z"]["offset_mm"]) self.assertEqual(2, len(audit)) with self.assertRaises(GraphError): apply_graph_patch(graph, [ {"op": "replace", "path": "/assemblies/missing/bounds_mm/z", "value": [0, 1]}, ]) def test_local_graph_patch_rejects_whole_placement_overwrite(self): graph = anchored_frame() with self.assertRaisesRegex(GraphError, "禁止整段覆盖实例placement"): apply_graph_patch(graph, [{ "op": "replace", "path": "/instances/i1/placement", "value": copy.deepcopy(graph["instances"][1]["placement"]), }]) def test_patch_payload_expands_whole_placement_to_changed_axes(self): graph = anchored_frame() replacement = copy.deepcopy(graph["instances"][0]["placement"]) replacement["x"] = {"mode": "absolute", "value_mm": 12} patches, audit = normalize_graph_patch_payload(graph, {"patches": [{ "op": "replace", "path": "/instances/i1/placement", "value": replacement, }]}) self.assertEqual([{ "op": "replace", "path": "/instances/i1/placement/x", "value": {"mode": "absolute", "value_mm": 12}, }], patches) self.assertEqual(["/instances/i1/placement/x"], audit[1]["expanded_paths"]) updated, patch_audit = apply_graph_patch(graph, patches) self.assertEqual(12, updated["instances"][0]["placement"]["x"]["value_mm"]) self.assertEqual("/instances/i1/placement/x", patch_audit[0]["path"]) def test_patch_payload_accepts_direct_list_and_numeric_instance(self): graph = anchored_frame() replacement = copy.deepcopy(graph["instances"][1]["placement"]) replacement["z"] = {"mode": "absolute", "value_mm": 19} patches, audit = normalize_graph_patch_payload(graph, [{ "op": "replace", "path": "/instances/1/placement", "value": replacement, }]) self.assertEqual("patch_list", audit[0]["source"]) self.assertEqual("/instances/1/placement/z", patches[0]["path"]) def test_patch_payload_rejects_malformed_or_noop_whole_placement(self): graph = anchored_frame() with self.assertRaisesRegex(GraphError, "恰好包含x、y、z"): normalize_graph_patch_payload(graph, {"patches": [{ "op": "replace", "path": "/instances/i1/placement", "value": {"x": {}}, }]}) with self.assertRaisesRegex(GraphError, "没有产生任何有效变化"): normalize_graph_patch_payload(graph, {"patches": [{ "op": "replace", "path": "/instances/i1/placement", "value": copy.deepcopy(graph["instances"][0]["placement"]), }]}) def test_invalid_assembly_role_feedback_contains_legal_enum_and_stable_path(self): graph = anchored_frame_with_drawer_assembly() graph["assemblies"][1]["role"] = "drawer_assembly" result = compile_case(graph) issue = next(row for row in result["issues"] if row["code"] == "assembly_role") self.assertEqual("/assemblies/drawer-1/role", issue["legal_repair"]["path"]) self.assertIn("drawer", issue["allowed"]) self.assertNotIn("drawer_assembly", issue["allowed"]) def test_direct_source_dimension_ledger_blocks_axis_swap(self): graph = frame(800, 400, 1100, 18) valid = compile_graph( graph, source_image_ids={"original-front"}, dimension_ledger={ "x": {"value_mm": 800, "source_image_ids": ["original-front"]}, "y": {"value_mm": 400, "source_image_ids": ["original-front"]}, }, ) self.assertTrue(valid["ok"], valid["issues"]) graph["envelope_mm"] = ["d", "w", "h"] swapped = compile_graph( graph, source_image_ids={"original-front"}, dimension_ledger={ "x": {"value_mm": 800, "source_image_ids": ["original-front"]}, "y": {"value_mm": 400, "source_image_ids": ["original-front"]}, }, ) conflicts = [row for row in swapped["issues"] if row["code"] == "overall_dimension_conflict"] self.assertEqual({"x", "y"}, {row["axis"] for row in conflicts}) def test_dimension_ledger_rejects_local_low_confidence_and_conflicting_arrows(self): ledger = build_dimension_ledger([ {"axis": "x", "value": 80, "unit": "cm", "scope": "overall", "confidence": 0.95, "text": "80", "location": "外轮廓左右"}, {"axis": "y", "value": 40, "unit": "cm", "scope": "part", "confidence": 0.99}, {"axis": "z", "value": 1100, "unit": "mm", "scope": "overall", "confidence": 0.72}, ], source_image_id="dimension") self.assertEqual(800, ledger["x"]["value_mm"]) self.assertNotIn("y", ledger) self.assertNotIn("z", ledger) conflicted = build_dimension_ledger([ {"axis": "x", "value": 800, "unit": "mm", "scope": "overall", "confidence": 0.95}, {"axis": "x", "value": 900, "unit": "mm", "scope": "overall", "confidence": 0.95}, ], source_image_id="dimension") self.assertNotIn("x", conflicted) self.assertEqual("x", conflicted["conflicts"][0]["axis"]) def test_contacts_are_derived_from_geometry_within_declared_assembly(self): graph = frame(800, 400, 1100, 18) graph["connections"] = [] graph["contact_derivation"] = "aabb_within_assembly_v1" result = compile_case(graph) self.assertTrue(result["ok"], result["issues"]) self.assertEqual(2, len(result["derived_contacts"])) self.assertEqual({"edge_to_face"}, {row["interface_type"] for row in result["derived_contacts"]}) self.assertEqual({"i3"}, {row["edge_instance_id"] for row in result["derived_contacts"]}) graph["instances"][2]["position_mm"][0] = "t+5" broken = compile_case(graph) self.assertFalse(broken["ok"]) self.assertIn("disconnected_instance", {row["code"] for row in broken["issues"]}) def test_contacts_do_not_fabricate_cross_assembly_drawer_joint(self): graph = frame(800, 400, 1100, 18) graph["connections"] = [] graph["contact_derivation"] = "aabb_within_assembly_v1" graph["instances"][2]["assembly_id"] = "drawer-1" result = compile_case(graph) self.assertFalse(result["ok"]) self.assertFalse(result["derived_contacts"]) graph["connections"] = [ {"id": "slide-left", "kind": "sliding", "a": "i1", "b": "i3"}, {"id": "slide-right", "kind": "sliding", "a": "i2", "b": "i3"}, ] self.assertTrue(compile_case(graph)["ok"]) def test_anchor_constraints_compute_coordinates_instead_of_trusting_ai_xyz(self): graph = anchored_frame() result = compile_case(graph) self.assertTrue(result["ok"], result["issues"]) positions = {row["id"]: row["position_mm"] for row in result["instances"]} self.assertEqual([0, 0, 0], positions["i1"]) self.assertEqual([782, 0, 0], positions["i2"]) self.assertEqual([18, 0, 1082], positions["i3"]) self.assertEqual(9, len(result["placement_audit"])) self.assertEqual(2, len(result["derived_contacts"])) def test_own_assembly_stable_id_is_a_safe_anchor_alias(self): graph = anchored_frame() for instance in graph["instances"]: for axis in "xyz": rule = instance["placement"][axis] if rule.get("target") == "assembly": rule["target"] = instance["assembly_id"] aliased = compile_case(graph) canonical = compile_case(anchored_frame()) self.assertEqual( [row["position_mm"] for row in canonical["instances"]], [row["position_mm"] for row in aliased["instances"]], ) self.assertTrue(all( row["target_kind"] == "assembly" for row in aliased["placement_audit"] if row["target"] == "root" )) invalid = anchored_frame() invalid["instances"][0]["placement"]["x"]["target"] = "other-assembly" self.assertIn("placement_constraint", {row["code"] for row in compile_case(invalid)["issues"]}) def test_anchor_constraints_follow_parameter_changes(self): graph = anchored_frame() first = compile_case(graph) graph["parameters"]["w"]["value"] = 920 graph["parameters"]["h"]["value"] = 1250 second = compile_case(graph) self.assertTrue(second["ok"], second["issues"]) positions = {row["id"]: row["position_mm"] for row in second["instances"]} self.assertEqual([902, 0, 0], positions["i2"]) self.assertEqual([18, 0, 1232], positions["i3"]) self.assertNotEqual(first["graph_sha256"], second["graph_sha256"]) def test_anchor_constraints_reject_missing_axis_and_dependency_cycle(self): missing = anchored_frame() del missing["instances"][0]["placement"]["z"] self.assertIn("placement_constraint", {row["code"] for row in compile_case(missing)["issues"]}) cyclic = anchored_frame() cyclic["instances"][0]["placement"]["x"] = { "mode": "align", "self_anchor": "min", "target": "i2", "target_anchor": "min" } cyclic["instances"][1]["placement"]["x"] = { "mode": "align", "self_anchor": "min", "target": "i1", "target_anchor": "min" } self.assertIn("placement_constraint", {row["code"] for row in compile_case(cyclic)["issues"]}) def test_assembly_motion_connects_whole_subassembly_not_arbitrary_board(self): graph = anchored_frame_with_drawer_assembly() result = compile_case(graph) self.assertTrue(result["ok"], result["issues"]) self.assertEqual("sliding", result["assembly_connection_audit"][0]["kind"]) self.assertEqual("y", result["assembly_connection_audit"][0]["axis"]) graph["assembly_connections"] = [] broken = compile_case(graph) self.assertIn("disconnected_assembly", {row["code"] for row in broken["issues"]}) def test_assembly_motion_rejects_instance_endpoints_with_actionable_mapping(self): graph = anchored_frame_with_drawer_assembly() graph["assembly_connections"][0]["a"] = "drawer-solid" graph["assembly_connections"][0]["b"] = "i1" result = compile_case(graph) problem = next( row for row in result["issues"] if row["code"] == "assembly_connection_uses_instance" ) self.assertEqual( {"drawer-solid": "drawer-1", "i1": "root"}, problem["instance_endpoint_to_current_assembly"], ) self.assertEqual( "drawer-solid", problem["moving_endpoint_repair"]["instance_id"] ) self.assertIn("root", problem["stationary_endpoint_repair"]) def test_fixed_assembly_relation_requires_real_geometric_contact(self): graph = anchored_frame_with_drawer_assembly() graph["assembly_connections"][0] = { "id": "not-really-fixed", "kind": "fixed", "a": "drawer-1", "b": "root", "evidence_ids": ["view"], } result = compile_case(graph) self.assertIn("fixed_assemblies_not_contacting", {row["code"] for row in result["issues"]}) def test_assembly_joint_cannot_hide_loose_member(self): graph = anchored_frame_with_drawer_assembly() graph["parts"][-1]["observed_quantity"] = 2 loose = copy.deepcopy(graph["instances"][-1]) loose["id"] = "drawer-loose" loose["placement"]["z"] = {"mode": "absolute", "value_mm": 800} graph["instances"].append(loose) result = compile_case(graph) self.assertIn("assembly_internal_disconnected", {row["code"] for row in result["issues"]}) def test_module_local_bounds_prevent_upper_module_from_falling_to_product_floor(self): graph = anchored_frame() graph["parameters"]["lower_h"] = param(420) graph["parts"][0]["size_mm"][2] = "h-lower_h" graph["assemblies"] = [{ "id": "root", "role": "carcass", "evidence_ids": ["view"], "bounds_mm": {"x": [0, "w"], "y": [0, "d"], "z": ["lower_h", "h"]}, }] for instance in graph["instances"][:2]: instance["placement"]["x"]["target"] = "assembly" instance["placement"]["y"]["target"] = "assembly" instance["placement"]["z"]["target"] = "assembly" graph["instances"][2]["placement"]["y"]["target"] = "assembly" graph["instances"][2]["placement"]["z"]["target"] = "assembly" result = compile_case(graph) self.assertTrue(result["ok"], result["issues"]) positions = {row["id"]: row["position_mm"] for row in result["instances"]} self.assertEqual(420, positions["i1"][2]) self.assertEqual(420, positions["i2"][2]) graph["parameters"]["lower_h"]["value"] = 510 moved = compile_case(graph) self.assertTrue(moved["ok"], moved["issues"]) self.assertEqual(510, {row["id"]: row["position_mm"] for row in moved["instances"]}["i1"][2]) def test_part_outside_assembly_and_solid_interference_are_blocking(self): outside = anchored_frame_with_drawer_assembly() outside["instances"][-1]["placement"]["z"] = {"mode": "absolute", "value_mm": 650} result = compile_case(outside) self.assertIn("outside_assembly_bounds", {row["code"] for row in result["issues"]}) issue = next(row for row in result["issues"] if row["code"] == "outside_assembly_bounds") self.assertEqual("drawer-solid", issue["instance_id"]) self.assertEqual(2, len(issue["legal_alignment_replacements"])) self.assertEqual(0, issue["legal_alignment_replacements"][0]["value"]["offset_mm"]) collision = anchored_frame() collision["instances"][2]["placement"]["x"] = {"mode": "absolute", "value_mm": 10} result = compile_case(collision) self.assertIn("solid_interference", {row["code"] for row in result["issues"]}) conflict = next(row for row in result["issues"] if row["code"] == "solid_interference") self.assertTrue(conflict["compatible_separation_candidates"]) self.assertTrue(all(row["patches"] for row in conflict["compatible_separation_candidates"])) def test_collision_candidate_moves_single_member_assembly_and_its_tight_bounds_together(self): graph = anchored_door_with_hardware() graph["assemblies"].append({ "id": "stationary", "role": "carcass", "evidence_ids": ["view"], "bounds_mm": {"x": [0, 18], "y": [20, 100], "z": [0, 700]}, }) graph["parts"].append({ "id": "stationary-panel", "name": "静止结构件", "shape": "box", "functional_role": "side", "thickness_axis": "x", "size_mm": [18, 80, 700], "observed_quantity": 1, }) graph["instances"].append({ "id": "stationary-instance", "part_id": "stationary-panel", "assembly_id": "stationary", "placement": { "x": {"mode": "absolute", "value_mm": 0}, "y": {"mode": "absolute", "value_mm": 20}, "z": {"mode": "absolute", "value_mm": 0}, }, }) result = compile_case(graph) conflict = next( row for row in result["issues"] if row["code"] == "solid_interference" and {row["a"], row["b"]} == {"panel-instance", "stationary-instance"} ) candidate = next( row for row in conflict["compatible_separation_candidates"] if row["moving_instance_id"] == "panel-instance" and row["axis"] == "x" and row["translation_mm"] == 18 ) self.assertEqual(0, candidate["remaining_collision_count"]) self.assertEqual( [ "/instances/panel-instance/placement/x", "/assemblies/moving-panel/bounds_mm/x", ], [row["path"] for row in candidate["patches"]], ) def test_axis_packing_proves_when_front_member_shelf_and_back_cannot_fit(self): shelf = { "id": "moving", "position_mm": [0, 20, 40], "size_mm": [100, 380, 20], } rows = [ shelf, {"id": "front-left", "position_mm": [0, 0, 0], "size_mm": [50, 20, 100]}, {"id": "front-right", "position_mm": [50, 0, 0], "size_mm": [50, 20, 100]}, {"id": "back", "position_mm": [0, 382, 0], "size_mm": [100, 9, 100]}, ] result = _axis_packing_feasibility( shelf, rows, [[0, 100], [0, 400], [0, 100]], axes=[1] )[0] self.assertFalse(result["feasible_without_dimension_or_bounds_change"]) self.assertEqual([], result["feasible_lower_position_intervals_mm"]) self.assertEqual( {"front-left", "front-right", "back"}, {row["instance_id"] for row in result["blocking_instances"]}, ) shorter = copy.deepcopy(shelf) shorter["size_mm"][1] = 360 rows[0] = shorter feasible = _axis_packing_feasibility( shorter, rows, [[0, 100], [0, 400], [0, 100]], axes=[1] )[0] self.assertTrue(feasible["feasible_without_dimension_or_bounds_change"]) self.assertTrue(feasible["feasible_lower_position_intervals_mm"]) def test_reference_envelope_scope_requires_verified_user_or_independent_adjudication(self): graph = anchored_frame() graph["parameters"]["occupied_depth"] = param(420) graph["envelope_mm"][1] = "occupied_depth" graph["reference_bounds_mm"] = { "x": [0, "w"], "y": [20, "occupied_depth"], "z": [0, "h"], } graph["dimension_scope_overrides"] = { "y": {"ledger_scope": "reference", "adjudication_id": "depth-scope"}, } ledger = {"y": {"value_mm": 400, "tolerance_mm": 1, "source_image_ids": ["original-front"]}} unverified = compile_graph( graph, source_image_ids={"original-front"}, dimension_ledger=ledger ) self.assertIn( "dimension_scope_override_unverified", {row["code"] for row in unverified["issues"]}, ) verified = compile_graph( graph, source_image_ids={"original-front"}, dimension_ledger=ledger, dimension_scope_adjudications={"depth-scope": { "axis": "y", "ledger_scope": "reference", "confidence": 0.92, "source_image_ids": ["original-front"], }}, ) codes = {row["code"] for row in verified["issues"]} self.assertNotIn("dimension_scope_override_unverified", codes) self.assertNotIn("overall_dimension_conflict", codes) self.assertEqual("independent_source_adjudication", verified["dimension_scope_audit"][0]["verification_source"]) self.assertEqual(400, verified["dimension_scope_audit"][0]["comparison_mm"]) def test_reference_envelope_scope_accepts_exact_conversation_fact(self): graph = anchored_frame() graph["parameters"]["occupied_depth"] = param(420) graph["envelope_mm"][1] = "occupied_depth" graph["reference_bounds_mm"] = { "x": [0, "w"], "y": [20, "occupied_depth"], "z": [0, "h"], } fact = { "id": "confirmed-depth-scope", "source_type": "user", "text": "400mm是柜体深度,不包含覆盖门", "confidence": 1.0, } graph["observations"].append(fact) graph["dimension_scope_overrides"] = { "y": {"ledger_scope": "reference", "evidence_id": fact["id"]}, } ledger = {"y": {"value_mm": 400, "tolerance_mm": 1, "source_image_ids": ["original-front"]}} result = compile_graph( graph, source_image_ids={"original-front"}, user_evidence={fact["id"]: fact}, dimension_ledger=ledger, ) codes = {row["code"] for row in result["issues"]} self.assertNotIn("dimension_scope_override_unverified", codes) self.assertNotIn("overall_dimension_conflict", codes) self.assertEqual("user", result["dimension_scope_audit"][0]["verification_source"]) outside = copy.deepcopy(graph) outside["reference_bounds_mm"]["y"] = [-20, 400] rejected = compile_graph( outside, source_image_ids={"original-front"}, user_evidence={fact["id"]: fact}, dimension_ledger=ledger, ) self.assertIn("reference_bounds", {row["code"] for row in rejected["issues"]}) def test_anchored_duplicate_instance_id_and_loose_assembly_bounds_are_blocking(self): duplicate = anchored_frame() duplicate["instances"].append(copy.deepcopy(duplicate["instances"][0])) result = compile_case(duplicate) self.assertIn("instance_geometry", {row["code"] for row in result["issues"]}) self.assertEqual(3, len(result["instances"])) loose_bounds = anchored_frame_with_drawer_assembly() loose_bounds["assemblies"][1]["bounds_mm"]["z"][1] = 650 result = compile_case(loose_bounds) self.assertIn("assembly_bounds_not_tight", {row["code"] for row in result["issues"]}) def test_panel_role_requires_a_valid_local_thickness_axis(self): missing = frame(800, 400, 1100, 18) missing["parts"][0].pop("thickness_axis") self.assertIn("part_geometry", {row["code"] for row in compile_case(missing)["issues"]}) wrong = frame(800, 400, 1100, 18) wrong["parts"][0]["thickness_axis"] = "z" self.assertIn("part_geometry", {row["code"] for row in compile_case(wrong)["issues"]}) if __name__ == "__main__": unittest.main()