588 lines
30 KiB
Python
588 lines
30 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,
|
|
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
|
|
|
|
|
|
class StructureGraphTest(unittest.TestCase):
|
|
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"]})
|
|
|
|
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()
|