482 lines
24 KiB
Python

"""Metamorphic assembly tests. No SKU or company-BOM answer constants."""
import copy
import random
import unittest
from structure_graph import (
GraphError, ParameterResolver, SCHEMA_VERSION, apply_graph_patch,
build_dimension_ledger, build_source_topology_contract, compile_graph,
)
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
class StructureGraphTest(unittest.TestCase):
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_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}
self.assertIn("outside_assembly_bounds", {row["code"] for row in compile_case(outside)["issues"]})
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"]})
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()