971 lines
48 KiB
Python
Raw 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.

"""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=300):
"""A product-independent moving panel with one template-backed knob."""
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", "material_family": "engineered_panel",
"size_mm": [600, 18, 700], "observed_quantity": 1},
{"id": "fitting", "name": "单孔旋钮", "shape": "box", "functional_role": "hardware",
"thickness_axis": None, "hardware_family": "knob",
"hardware_template_id": "knob_m4_surface_v1",
"size_mm": [1, 1, 1], "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": 0},
"y": {"mode": "absolute", "value_mm": 0},
"z": {"mode": "absolute", "value_mm": 0},
},
"mountings": [{
"slot": "body",
"host_instance_id": "panel-instance", "kind": kind, "face": "y-",
"embed_depth_mm": embed_depth_mm,
"mounting_center_mm": [hardware_x, 20, 350], "evidence_ids": ["view"],
}]},
],
"connections": [],
"assembly_connections": [],
"uncertainties": [],
}
def anchored_hinged_door_with_template(*, cup_edge_mm=5, plate_system_line_mm=37):
"""One generic inset door and side using a selected 35 mm hinge template."""
cup_x = 18 + 17.5 + cup_edge_mm
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": [618, 400, 700],
"assemblies": [
{"id": "door-group", "role": "door", "evidence_ids": ["view"],
"bounds_mm": {"x": [18, 618], "y": [0, 18], "z": [0, 700]}},
{"id": "carcass", "role": "carcass", "evidence_ids": ["view"],
"bounds_mm": {"x": [0, 18], "y": [18, 400], "z": [0, 700]}},
],
"parts": [
{"id": "door", "name": "门板", "shape": "box", "functional_role": "door",
"thickness_axis": "y", "material_family": "engineered_panel",
"size_mm": [600, 18, 700], "observed_quantity": 1},
{"id": "side", "name": "侧板", "shape": "box", "functional_role": "side",
"thickness_axis": "x", "material_family": "engineered_panel",
"size_mm": [18, 382, 700], "observed_quantity": 1},
{"id": "hinge", "name": "35mm暗铰链", "shape": "box", "functional_role": "hardware",
"thickness_axis": None, "hardware_family": "concealed_cup_hinge",
"hardware_template_id": "euro_hinge_35_th52_system32_v1",
"size_mm": [1, 1, 1], "observed_quantity": 1},
],
"instances": [
{"id": "door-instance", "part_id": "door", "assembly_id": "door-group",
"placement": {
"x": {"mode": "absolute", "value_mm": 18},
"y": {"mode": "absolute", "value_mm": 0},
"z": {"mode": "absolute", "value_mm": 0},
}},
{"id": "side-instance", "part_id": "side", "assembly_id": "carcass",
"placement": {
"x": {"mode": "absolute", "value_mm": 0},
"y": {"mode": "absolute", "value_mm": 18},
"z": {"mode": "absolute", "value_mm": 0},
}},
{"id": "hinge-instance", "part_id": "hinge", "assembly_id": "door-group",
"hardware_axis": "z",
"placement": {
"x": {"mode": "absolute", "value_mm": 0},
"y": {"mode": "absolute", "value_mm": 0},
"z": {"mode": "absolute", "value_mm": 0},
},
"mountings": [
{"slot": "cup", "host_instance_id": "door-instance", "kind": "mortised",
"face": "y+", "embed_depth_mm": 12.8,
"mounting_center_mm": [cup_x, 18, 100], "evidence_ids": ["view"]},
{"slot": "plate", "host_instance_id": "side-instance", "kind": "surface",
"face": "x+", "embed_depth_mm": 0,
"mounting_center_mm": [18, 18 + plate_system_line_mm, 100], "evidence_ids": ["view"]},
]},
],
"connections": [],
"assembly_connections": [{
"id": "door-hinge", "kind": "hinged", "a": "door-group", "b": "carcass",
"axis": "z", "angle_limits_deg": [0, 110], "evidence_ids": ["view"],
}],
"uncertainties": [],
}
class StructureGraphTest(unittest.TestCase):
def test_template_knob_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"])
geometry = result["hardware_geometry_by_instance"]["fitting-instance"]
self.assertEqual("knob", geometry["hardware_family"])
self.assertEqual(["knob_body"], [row["role"] for row in geometry["primitives"]])
fitting = next(row for row in result["instances"] if row["id"] == "fitting-instance")
self.assertEqual([1.0, 1.0, 1.0], fitting["declared_placeholder_size_mm"])
self.assertEqual([20.0, 24.0, 20.0], fitting["size_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_surface_knob_cannot_fake_whole_body_through_mounting(self):
graph = anchored_door_with_hardware(kind="through", embed_depth_mm=18)
result = compile_case(graph)
problem = next(row for row in result["issues"] if row["code"] == "hardware_mounting_kind")
self.assertEqual("surface", problem["required_kind"])
self.assertEqual("through", problem["actual_kind"])
self.assertFalse(result["mounting_audit"])
def test_hardware_family_and_template_are_required_and_must_match(self):
missing = anchored_door_with_hardware()
missing["parts"][1].pop("hardware_template_id")
self.assertIn("part_geometry", {row["code"] for row in compile_case(missing)["issues"]})
mismatch = anchored_door_with_hardware()
mismatch["parts"][1]["hardware_family"] = "concealed_cup_hinge"
self.assertIn("part_geometry", {row["code"] for row in compile_case(mismatch)["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_template_hardware_feedback_requires_real_broad_face_not_box_movement(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.assertNotIn("compatible_mounting_values", problem)
def test_hinge_template_validates_slots_system_lines_and_non_box_geometry(self):
result = compile_case(anchored_hinged_door_with_template())
self.assertTrue(result["ok"], result["issues"])
self.assertEqual({"cup", "plate"}, {row["slot"] for row in result["mounting_audit"]})
geometry = result["hardware_geometry_by_instance"]["hinge-instance"]
self.assertEqual(
{"hinge_cup", "hinge_mounting_plate", "hinge_arm_from_cup", "hinge_arm_to_plate"},
{row["role"] for row in geometry["primitives"]},
)
self.assertIn("cylinder", {row["kind"] for row in geometry["primitives"]})
arms = [row for row in geometry["primitives"] if row["role"].startswith("hinge_arm_")]
# Door interior face is Y=18 and side interior face is X=18. The arm
# may touch those planes, but only the cup cylinder may enter a board.
self.assertTrue(all(
row["center_mm"][1] - row["size_mm"][1] / 2 >= 18 for row in arms
))
self.assertTrue(all(
row["center_mm"][0] - row["size_mm"][0] / 2 >= 18 for row in arms
))
self.assertTrue(all(row.get("accepted_as_mounting") for row in result["collision_audit"]))
self.assertEqual([], result["derived_contacts"])
def test_hinge_template_blocks_wrong_cup_edge_and_plate_system_line(self):
cup = compile_case(anchored_hinged_door_with_template(cup_edge_mm=15))
self.assertIn("hinge_cup_edge_distance", {row["code"] for row in cup["issues"]})
plate = compile_case(anchored_hinged_door_with_template(plate_system_line_mm=55))
self.assertIn("hinge_plate_system_line", {row["code"] for row in plate["issues"]})
def test_hinge_template_requires_every_mounting_slot_and_template_kind(self):
missing = anchored_hinged_door_with_template()
missing["instances"][2]["mountings"].pop()
self.assertIn("hardware_mounting_slots_missing", {row["code"] for row in compile_case(missing)["issues"]})
wrong = anchored_hinged_door_with_template()
wrong["instances"][2]["mountings"][0]["kind"] = "through"
self.assertIn("hardware_mounting_kind", {row["code"] for row in compile_case(wrong)["issues"]})
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.assertEqual("occupied_overall", ledger["x"]["ledger_scope"])
self.assertEqual("qualified_overall_arrow", ledger["x"]["scope_source"])
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_invalid_anchor_is_localized_and_preserves_unrelated_geometry(self):
graph = anchored_frame()
graph["instances"][0]["placement"]["x"]["target"] = "other-assembly"
result = compile_case(graph)
self.assertFalse(result["ok"])
self.assertEqual(["i2"], [row["id"] for row in result["instances"]])
self.assertEqual(3, len(result["placement_audit"]))
placement_issues = [
row for row in result["issues"] if row["code"] == "placement_constraint"
]
self.assertEqual(
{"/instances/i1/placement", "/instances/i3/placement"},
{row["path"] for row in placement_issues},
)
self.assertNotIn("/instances", {row["path"] for row in placement_issues})
cascade_codes = {"empty_assembly", "uninstantiated_part", "quantity_conflict"}
self.assertFalse(cascade_codes & {row["code"] for row in result["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()