949 lines
47 KiB
Python
949 lines
47 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, _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.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()
|