Files
pythonocc-step-editor/scripts/verify_property_card_editor_ui.py
T
nikelaluo 4e7877e05c feat: 接入 SCDM 优先编辑闭环并支持阵列局部间距
接入 SCDM probe/edit/cache/校验链路,增强孔组、阵列、关系式和参数表交互。

支持阵列相邻段间距、移动意图切换、结果回滚校验,并补充对应回归脚本。
2026-08-19 18:02:47 +08:00

2110 lines
100 KiB
Python

from __future__ import annotations
import json
import math
import os
from pathlib import Path
import re
import sys
import tempfile
from types import SimpleNamespace
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QEvent, QObject, Qt, QStringListModel
from PySide6.QtGui import QKeyEvent
from PySide6.QtTest import QTest
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QCompleter,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QPushButton,
QScrollArea,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.widgets import NoWheelComboBox
from step_editor.relation_formulas import ObjectParameterRef, parse_relation_formula
from step_editor.window_actions import WindowActionMixin
from step_editor.window_core import WindowCoreMixin
from step_editor.window_state import (
PROPERTY_CURRENT_COLUMN,
PROPERTY_INPUT_COLUMN,
PROPERTY_LABEL_COLUMN,
PROPERTY_SCOPE_COLUMN,
PROPERTY_TABLE_HEADERS,
PROPERTY_TARGET_COLUMN,
RELATION_FORMULA_OBJECT_COMPLETION_LIMIT,
WindowStateMixin,
)
from step_editor.ui_helpers import _selection_mode_label, _selection_mode_value
class _StatusBar:
def showMessage(self, _text: str) -> None:
pass
class _PropertyTableProbe(QWidget, WindowStateMixin):
def __init__(self) -> None:
super().__init__()
self.model = object()
self.operation_in_progress = False
self.scan_in_progress = False
self.load_in_progress = False
self.property_editor_updating = False
self.property_table_expanded = False
self.property_table_collapsed_rows = 5
self.property_table_min_visible_rows = 5
self.property_editor_selected_row = None
self.property_command_active_key = ""
self.property_command_buttons = {}
self.property_editor_specs = []
self.relation_formula_items = []
self.relation_formula_next_id = 1
self.selected_kind = "feature"
self.selected_part_id = None
self.selected_solid_id = None
self.selected_face_id = 0
self.selected_edge_id = None
self.selected_pick_position = None
self.step_path = PROJECT_ROOT / "assets" / "models" / "probe.step"
self.current_info_values = self._plane_info()
self.executed_property_actions: list[tuple[str, str]] = []
self.scdm_backend_status = None
self.scdm_feature_cache = None
self.scdm_feature_cache_state = "empty"
self.scdm_feature_cache_message = ""
self.scdm_edit_runner_ready = {
"face.offset",
"hole.diameter",
"hole.position",
"feature.fill",
"slot.width",
"slot.depth",
"slot.position",
"boss.diameter",
"boss.height",
"boss.position",
"round.radius",
"chamfer.distance",
"feature.delete_round_or_chamfer",
"pattern.spacing",
"pattern.segment_spacing",
}
layout = QVBoxLayout(self)
self.object_edit_box = self
self.property_table = QTableWidget(0, len(PROPERTY_TABLE_HEADERS))
self.property_table.setHorizontalHeaderLabels(list(PROPERTY_TABLE_HEADERS))
self.property_table.itemSelectionChanged.connect(lambda: self._update_property_apply_state())
layout.addWidget(self.property_table)
self.property_card_scroll = QScrollArea()
self.property_card_container = QWidget()
self.property_card_layout = QVBoxLayout(self.property_card_container)
self.property_card_scroll.setWidget(self.property_card_container)
layout.addWidget(self.property_card_scroll)
self.property_expand_button = QPushButton()
layout.addWidget(self.property_expand_button)
self.property_command_summary_label = QLabel()
self.property_command_bar = QFrame()
self.property_command_layout = QHBoxLayout(self.property_command_bar)
self.property_command_help_label = QLabel()
self.current_capability_button = QPushButton()
self.scdm_backend_status_label = QLabel()
self.scdm_backend_detail_label = QLabel()
self.apply_property_button = QPushButton()
self.export_parameters_button = QPushButton()
self.relation_formula_input = QLineEdit()
self.relation_formula_completer_model = QStringListModel(self)
self.relation_formula_completer = QCompleter(self.relation_formula_completer_model, self)
self.relation_formula_completer.setMaxVisibleItems(12)
self.relation_formula_completer.activated[str].connect(self._on_relation_formula_completion_activated)
relation_popup = self.relation_formula_completer.popup()
if relation_popup is not None:
relation_popup.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.relation_formula_completer.setWidget(self.relation_formula_input)
self.relation_formula_input.textChanged.connect(self._on_relation_formula_input_changed)
layout.addWidget(self.relation_formula_input)
self.add_relation_formula_button = QPushButton()
self.remove_relation_formula_button = QPushButton()
self.relation_formula_list = QListWidget()
self.face_width_input = QLineEdit()
self.face_height_input = QLineEdit()
@staticmethod
def _plane_info() -> dict[str, object]:
return {
"area": 100.0,
"area_center": (5.0, 5.0, 0.0),
"bbox_center": (5.0, 5.0, 0.0),
"bbox_diagonal": 14.1421356237,
"local_face_width": 10.0,
"local_face_height": 10.0,
"local_face_size_edit_ready": True,
"local_face_size_edit_blocker": "",
"plane_origin": (0.0, 0.0, 0.0),
"push_pull_outward_direction": (0.0, 0.0, 1.0),
"normal": (0.0, 0.0, 1.0),
}
def _selected_action_info(self) -> dict[str, object]:
return {
**self.current_info_values,
"surface": "plane",
"push_pull_status": "ready",
"first_level_boundary_edge_count": 4,
"first_level_boundary_vertex_count": 4,
"first_level_adjacent_face_count": 4,
}
def _set_control_state(self, widget, enabled: bool, _enabled_tip: str, _disabled_tip: str) -> None:
widget.setEnabled(enabled)
def setTitle(self, title: str) -> None:
self.object_edit_title = title
def resize_face_width_local(self) -> None:
self.executed_property_actions.append(("resize_face_width_local", self.face_width_input.text()))
self._after_property_edit_finished(success=True)
def resize_face_height_local(self) -> None:
self.executed_property_actions.append(("resize_face_height_local", self.face_height_input.text()))
self._after_property_edit_finished(success=True)
def apply_scdm_property_edit(self, spec: dict[str, object] | None = None, target_text: str | None = None) -> None:
self.executed_property_actions.append(
(
"apply_scdm_property_edit",
f"{(spec or {}).get('scdm_capability_key', '')}:{target_text or ''}",
)
)
self._after_property_edit_finished(success=True)
def statusBar(self) -> _StatusBar:
return _StatusBar()
class _RelationFormulaEventProbe(WindowCoreMixin, _PropertyTableProbe):
pass
class _ScdmAutoPromptProbe(_PropertyTableProbe):
def __init__(self) -> None:
super().__init__()
self.prompt_calls: list[tuple[bool, str, str]] = []
def configure_scdm_backend(self, *, automatic: bool = False, reason: str = "", message: str = "") -> bool:
self.prompt_calls.append((bool(automatic), str(reason), str(message)))
return False
class _PropertyUiRerouteProbe(_PropertyTableProbe):
def __init__(self) -> None:
super().__init__()
self.rerouted_callbacks = 0
def _reroute_to_ui_thread(self, _callback) -> bool:
self.rerouted_callbacks += 1
return True
class _GlobalRelationCompletionModel:
def __init__(self) -> None:
self.faces = [object() for _index in range(100)]
self.edges = [object() for _index in range(12)]
self.face_logical_ids = list(range(len(self.faces)))
self.face_region_call_count = 0
def face_logical_id(self, face_id: int) -> int:
return int(self.face_logical_ids[int(face_id)])
def face_region_logical_id(self, face_id: int) -> int:
self.face_region_call_count += 1
return int(face_id)
def resolve_face_selection_id(self, object_id: int) -> int | None:
if 0 <= int(object_id) < len(self.faces):
return int(object_id)
return None
def quick_face_info(self, face_id: int) -> dict[str, object]:
return {"surface": "cylinder" if int(face_id) in {85, 87} else "plane"}
class _LargeRelationCompletionModel(_GlobalRelationCompletionModel):
def __init__(self) -> None:
self.faces = [object() for _index in range(10000)]
self.edges = [object() for _index in range(1000)]
self.face_logical_ids = list(range(len(self.faces)))
self.face_region_call_count = 0
def face_region_logical_id(self, face_id: int) -> int:
self.face_region_call_count += 1
raise AssertionError("relation formula completion should not call face_region_logical_id")
class _LargeDisplayModel:
def __init__(self) -> None:
self.faces = [object() for _index in range(1800)]
self.edges = [object() for _index in range(5000)]
class _RelationFormulaRemapModel:
def __init__(self, face_infos: dict[int, dict[str, object]], face_count: int = 120) -> None:
self.faces = [object() for _index in range(face_count)]
self.edges = []
self.face_logical_ids = list(range(face_count))
self.face_part_ids = [0 for _index in range(face_count)]
self.face_solid_ids = [0 for _index in range(face_count)]
self.face_infos = dict(face_infos)
def face_logical_id(self, face_id: int) -> int:
return int(self.face_logical_ids[int(face_id)])
def face_region_logical_id(self, face_id: int) -> int:
return self.face_logical_id(int(face_id))
def face_ids_for_logical_id(self, logical_id: int) -> list[int]:
return [face_id for face_id, item in enumerate(self.face_logical_ids) if int(item) == int(logical_id)]
def resolve_face_selection_id(self, object_id: int) -> int | None:
matches = self.face_ids_for_logical_id(int(object_id))
if matches:
return int(matches[0])
if 0 <= int(object_id) < len(self.faces):
return int(object_id)
return None
def quick_face_info(self, face_id: int) -> dict[str, object]:
info = dict(self.face_infos.get(int(face_id), {}))
if not info:
info = {"surface": "plane", "area_center": (1000.0 + int(face_id), 0.0, 0.0), "area": 1.0}
info.setdefault("part_id", 0)
info.setdefault("solid_id", 0)
info.setdefault("axis", (0.0, 0.0, 1.0))
return info
class _ActionMessageProbe(WindowActionMixin):
pass
class _ParameterExportActionProbe(WindowActionMixin):
def __init__(self, output_path: Path) -> None:
self.output_path = output_path
self.component_path = output_path.parent / "nodes" / "000_test" / "main.py"
self.status_bar = _StatusBarProbe()
self.info_text = ""
self.export_state_updates = 0
def _parameter_export_output_path(self) -> Path:
return self.output_path
def _selected_parameter_export_rows(self) -> list[dict[str, str]]:
return [
{
"name": "面内长度",
"displayName": "面内长度",
"type": "number",
"ioRole": "input",
"default": "151",
},
{
"name": "偏移",
"displayName": "偏移",
"type": "number",
"ioRole": "input",
"default": "57.5",
},
]
def _export_parametric_component_main(self, rows: list[dict[str, str]]) -> Path:
assert rows
self.component_path.parent.mkdir(parents=True, exist_ok=True)
input_rows = json.dumps(rows, ensure_ascii=False, indent=4)
self.component_path.write_text(
"INPUT_PARAMETERS = "
+ input_rows
+ "\nOUTPUT_PARAMETERS = [{'name': 'output_step', 'displayName': '输出STEP', 'type': 'file', 'ioRole': 'output', 'default': ''}]"
+ "\nPARAMETERS = INPUT_PARAMETERS + OUTPUT_PARAMETERS"
+ "\nNODE_INFO = {'parameters': PARAMETERS}"
+ "\ndef execute(inputs, params, context):\n return {'output_step': 'modified.step'}\n",
encoding="utf-8",
)
return self.component_path
def _update_parameter_export_state(self) -> None:
self.export_state_updates += 1
def statusBar(self) -> _StatusBarProbe:
return self.status_bar
def set_plain_info(self, text: str) -> None:
self.info_text = text
class _TimerProbe:
def stop(self) -> None:
pass
class _RenderWindowProbe:
def Render(self) -> None:
pass
class _StatusBarProbe:
def __init__(self) -> None:
self.messages: list[str] = []
def showMessage(self, message: str) -> None:
self.messages.append(str(message))
class _MouseSelectionProbe(WindowCoreMixin):
def __init__(self) -> None:
self.pointer_button_down = False
self.left_button_press_position = None
self.left_button_press_camera_state = None
self.left_button_press_target = None
self.left_button_dragged = False
self.left_click_drag_threshold_px = 6
self.left_click_camera_tolerance = 1e-7
self.camera_interaction_active = False
self.pending_hover_position = None
self.last_hover_pick_position = None
self.hover_timer = _TimerProbe()
self.camera_state = (0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 10.0, 30.0)
self.load_in_progress = False
self.operation_in_progress = False
self.scan_in_progress = False
self.model = object()
self.selected_kind = None
self.render_window = _RenderWindowProbe()
self.status_bar = _StatusBarProbe()
self.pick_targets: list[dict[str, object] | None] = [
{"kind": "face", "target_id": 1, "pick_position": (0.0, 0.0, 0.0)},
{"kind": "face", "target_id": 1, "pick_position": (0.0, 0.0, 0.0)},
]
self.selected_targets: list[dict[str, object]] = []
self.hover_clear_count = 0
self.camera_end_count = 0
def _camera_state_signature(self):
return tuple(self.camera_state)
def _current_selection_mode(self) -> str:
return "Face"
def _pick_selection_target(self, _mode: str, _x: int, _y: int) -> dict[str, object] | None:
if self.pick_targets:
return self.pick_targets.pop(0)
return None
def _select_pick_target(self, target: dict[str, object]) -> None:
self.selected_targets.append(dict(target))
def statusBar(self) -> _StatusBarProbe:
return self.status_bar
def _clear_hover(self, render: bool = True) -> None:
self.hover_clear_count += 1
def _end_camera_interaction(self) -> None:
self.camera_interaction_active = False
self.camera_end_count += 1
class _TopLevelPropertyLabelProbe(QObject):
def __init__(self) -> None:
super().__init__()
self.shown_labels: list[str] = []
def eventFilter(self, watched, event):
if (
event.type() == QEvent.Type.Show
and isinstance(watched, QLabel)
and watched.isWindow()
and watched.objectName().startswith("propertyCard")
):
self.shown_labels.append(f"{type(watched).__name__}:{watched.objectName()}:{watched.text()}")
return False
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _row_by_label(probe: _PropertyTableProbe, label: str) -> int:
for row in range(probe.property_table.rowCount()):
item = probe.property_table.item(row, PROPERTY_LABEL_COLUMN)
if item is not None and item.text() == label:
return row
labels = [
probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text()
for row in range(probe.property_table.rowCount())
if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None
]
raise AssertionError(f"{label!r} row was not found; labels={labels}")
def _assert_property_table_editor(probe: _PropertyTableProbe) -> None:
_assert(
not WindowCoreMixin._should_suppress_transient_tooltip(probe, probe.property_table),
"property table tooltip events should not be suppressed",
)
_assert(
not WindowCoreMixin._should_suppress_transient_tooltip(probe, probe.property_table.viewport()),
"property table viewport tooltip events should not be suppressed",
)
headers = [
probe.property_table.horizontalHeaderItem(column).text()
for column in range(probe.property_table.columnCount())
]
_assert(headers == list(PROPERTY_TABLE_HEADERS), f"unexpected table headers: {headers}")
_assert(probe.property_table.rowCount() == len(probe.property_editor_specs), "table row count should match specs")
header_height = int(probe.property_table.horizontalHeader().height())
frame = int(probe.property_table.frameWidth()) * 2
default_row_height = max(int(probe.property_table.verticalHeader().defaultSectionSize()), 22)
expected_five_row_height = header_height + frame + default_row_height * 5 + 8
_assert(
probe.property_table.minimumHeight() >= expected_five_row_height,
"feature parameter table should reserve enough height for five default rows",
)
_assert(not getattr(probe, "property_card_rows", {}), "property card rows should not be built in table mode")
_assert(not probe.property_card_scroll.isVisible(), "property card scroll area should stay hidden")
_assert(probe.property_card_scroll.maximumHeight() == 0, "property card scroll area should not reserve height")
_assert(not probe.property_command_buttons, "legacy command buttons should not be shown in the parameter table")
_assert(not probe.property_command_bar.isVisible(), "legacy command bar should be hidden")
table_labels = [
probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text()
for row in range(probe.property_table.rowCount())
if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None
]
actionable_labels = [str(spec.get("label", "")) for _row, spec in probe._actionable_property_rows()]
for label in ("面内长度", "面内宽度", "偏移"):
_assert(label in actionable_labels, f"feature parameter table did not expose {label}")
_assert("中心" not in actionable_labels, "center should be temporarily hidden from editable parameters")
_assert("中心" not in table_labels, "center should not appear in the feature parameter table")
for legacy_label in ("面积", "U向尺寸", "V向尺寸", "偏移变换"):
_assert(legacy_label not in actionable_labels, f"{legacy_label} should not be exposed as an editable parameter")
_assert(legacy_label not in table_labels, f"{legacy_label} should not appear in the feature parameter table")
for diagnostic_label in ("建模形式", "推荐操作", "一级关系", "关联探测"):
_assert(diagnostic_label not in table_labels, f"{diagnostic_label} should stay out of the feature parameter table")
target_row = _row_by_label(probe, "面内长度")
target_widget = probe.property_table.cellWidget(target_row, PROPERTY_TARGET_COLUMN)
scope_widget = probe.property_table.cellWidget(target_row, PROPERTY_SCOPE_COLUMN)
input_checkbox = probe._property_input_checkbox(target_row)
_assert(isinstance(target_widget, QLineEdit), "editable table row should have a target editor")
_assert(isinstance(scope_widget, NoWheelComboBox), "editable table row should have a modeling-intent combo")
_assert(isinstance(input_checkbox, QCheckBox), "editable table row should have an input-parameter checkbox")
_assert(not input_checkbox.isChecked(), "input-parameter checkbox should be unchecked by default")
_assert(
probe.property_table.cellWidget(target_row, PROPERTY_INPUT_COLUMN) is not None,
"input-parameter checkbox should be hosted in the input column",
)
_assert(not probe.export_parameters_button.isEnabled(), "parameter export button should start disabled")
_assert(
not probe.property_table.findChildren(QPushButton),
"feature parameter table should not contain per-row apply buttons",
)
input_checkbox.setChecked(True)
QApplication.processEvents()
selected_rows = probe._selected_parameter_export_rows()
_assert(probe.export_parameters_button.isEnabled(), "parameter export button should enable after a row is checked")
_assert(
selected_rows == [
{
"name": "面内长度",
"displayName": "面内长度",
"type": "number",
"ioRole": "input",
"default": "10",
}
],
f"unexpected parameter export payload: {selected_rows}",
)
component_edits = probe._selected_parameter_component_edits(selected_rows)
_assert(len(component_edits) == 1, f"selected parameter should map to one component edit: {component_edits}")
_assert(
component_edits[0]["operation"] == "resize_face_size_local",
f"face width export should map to backend resize operation: {component_edits[0]}",
)
_assert(
component_edits[0]["args"] == [0, {"param": "面内长度"}, "width"],
f"face width export should embed parameter placeholder args: {component_edits[0]}",
)
target_widget.setText("12")
probe._update_property_apply_state()
changed = probe._changed_property_rows()
_assert(any(row == target_row for row, _spec, _text in changed), "table target edit was not detected")
_assert(probe.apply_property_button.isEnabled(), "single parametric modeling button should enable for one changed row")
width_row = _row_by_label(probe, "面内宽度")
width_widget = probe.property_table.cellWidget(width_row, PROPERTY_TARGET_COLUMN)
_assert(isinstance(width_widget, QLineEdit), "second editable table row should have a target editor")
width_widget.setText("8")
probe._update_property_apply_state()
changed = probe._changed_property_rows()
_assert(len(changed) >= 2, f"two target edits should be detected: {changed}")
_assert(probe.apply_property_button.isEnabled(), "parametric modeling button should stay enabled for multiple changed rows")
probe.apply_current_property_edit()
for _index in range(6):
QApplication.processEvents()
if not getattr(probe, "property_batch_active", False):
break
_assert(
probe.executed_property_actions == [
("resize_face_width_local", "12"),
("resize_face_height_local", "8"),
],
f"batch parametric modeling should execute changed rows in table order: {probe.executed_property_actions}",
)
_assert(not getattr(probe, "property_batch_active", False), "property batch state should clear after completion")
probe.toggle_property_table_expanded()
_assert(probe.property_table_expanded, "property table expand toggle failed")
_assert("收起" in probe.property_expand_button.text(), "expanded table button should offer to collapse")
preserved_editor = probe.property_table.cellWidget(target_row, PROPERTY_TARGET_COLUMN)
_assert(isinstance(preserved_editor, QLineEdit), "target editor disappeared after table expand")
_assert(preserved_editor.text().strip() == "12", "target value was not preserved after table expand")
def _assert_scdm_command_row_uses_unified_apply() -> None:
probe = _PropertyTableProbe()
probe.scdm_edit_runner_ready = {"feature.fill"}
probe.scdm_feature_cache_state = "ready"
probe.scdm_feature_cache = {
"objects": [
{
"objectId": "hole:0",
"objectType": "hole",
"geometrySignature": {
"faceIds": [0],
"surfaceType": "cylinder",
"radius": 1.0,
"diameter": 2.0,
"center": [0.0, 0.0, 0.0],
},
"capabilities": [
{
"key": "feature.fill",
"displayName": "填孔/删除小特征",
"currentValue": 1,
"valueKind": "command",
"editable": True,
"defaultIntent": "删除并补面",
"backendOperation": "fill_feature",
"postCheck": "target_feature_removed",
}
],
}
]
}
probe._refresh_property_editor()
row = _row_by_label(probe, "填孔/删除小特征")
_assert(probe.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN) is None, "SCDM command row should not have a target editor")
current_item = probe.property_table.item(row, PROPERTY_CURRENT_COLUMN)
target_item = probe.property_table.item(row, PROPERTY_TARGET_COLUMN)
_assert(current_item is not None and current_item.text() == "可执行", f"command current text should be readable: {current_item.text() if current_item else None}")
_assert(target_item is not None and target_item.text() == "执行", f"command target text should be readable: {target_item.text() if target_item else None}")
_assert(not probe.apply_property_button.isEnabled(), "command row should not enable parametric modeling until selected")
probe.property_table.selectRow(row)
QApplication.processEvents()
probe._update_property_apply_state()
pending = probe._pending_property_edit_rows()
_assert(len(pending) == 1 and pending[0][0] == row, f"selected command row should be pending: {pending}")
_assert(probe.apply_property_button.isEnabled(), "selected command row should enable unified parametric modeling button")
probe.apply_current_property_edit()
_assert(
probe.executed_property_actions == [("apply_scdm_property_edit", "feature.fill:执行")],
f"selected command row should execute through SCDM property action: {probe.executed_property_actions}",
)
def _assert_diagnostics_stay_out_of_parameter_table(probe: _PropertyTableProbe) -> None:
long_context = "已按“相邻特征”沿共享边拓扑探测当前特征及 3 个局部关联特征;关联尺寸可在同一参数表中直接修改。"
probe.current_info_values = {
**probe._plane_info(),
"feature_context_note": long_context,
"feature_detection_level": "相邻特征",
"associated_feature_count": 3,
}
probe._refresh_property_editor()
QApplication.processEvents()
table_labels = [
probe.property_table.item(row, PROPERTY_LABEL_COLUMN).text()
for row in range(probe.property_table.rowCount())
if probe.property_table.item(row, PROPERTY_LABEL_COLUMN) is not None
]
for diagnostic_label in ("建模形式", "推荐操作", "一级关系", "关联探测"):
_assert(diagnostic_label not in table_labels, f"{diagnostic_label} should not be shown as a feature parameter")
def _assert_relation_formula_editor() -> None:
probe = _PropertyTableProbe()
probe._refresh_property_editor()
_assert(
not probe.relation_formula_completer_model.stringList(),
"relation formula completions should stay lazy while the formula input is not focused",
)
probe.relation_formula_input.setFocus(Qt.FocusReason.OtherFocusReason)
QApplication.processEvents()
probe._update_relation_formula_completions()
completions = set(probe.relation_formula_completer_model.stringList())
_assert("Face0.面内长度" in completions, f"relation formula completion missing face length: {completions}")
_assert("Face0.面内宽度" in completions, f"relation formula completion missing face width: {completions}")
probe.relation_formula_input.setText("F")
probe.relation_formula_input.setCursorPosition(1)
probe._update_relation_formula_completions()
f_completions = set(probe.relation_formula_completer_model.stringList())
_assert(
"Face" in f_completions or "Face0" in f_completions,
f"relation formula should suggest face object tokens after 'F': {f_completions}",
)
_assert(probe.relation_formula_completer.completionCount() > 0, "relation formula completer should have matches after 'F'")
_assert(probe._accept_relation_formula_completion(), "Tab should accept relation formula completion after 'F'")
_assert(
probe.relation_formula_input.text() in {"Face", "Face0"},
f"Tab should replace 'F' with a face completion: {probe.relation_formula_input.text()}",
)
if probe.relation_formula_input.text() == "Face":
probe._update_relation_formula_completions()
face_object_completions = set(probe.relation_formula_completer_model.stringList())
_assert(
"Face0" in face_object_completions,
f"relation formula should suggest concrete Face IDs after completing Face: {face_object_completions}",
)
_assert(probe._accept_relation_formula_completion(), "second Tab should accept a concrete Face ID completion")
_assert(
probe.relation_formula_input.text() == "Face0",
f"second Tab should complete Face to Face0: {probe.relation_formula_input.text()}",
)
probe._update_relation_formula_completions()
exact_object_completions = set(probe.relation_formula_completer_model.stringList())
_assert(
any(str(item).startswith("Face0.") for item in exact_object_completions),
f"concrete object completion should suggest editable parameters: {exact_object_completions}",
)
event_probe = _RelationFormulaEventProbe()
event_probe.show()
event_probe._refresh_property_editor()
event_probe.relation_formula_input.setFocus(Qt.FocusReason.OtherFocusReason)
QApplication.processEvents()
event_probe.relation_formula_input.setText("F")
event_probe.relation_formula_input.setCursorPosition(1)
tab_event = QKeyEvent(QEvent.Type.ShortcutOverride, Qt.Key.Key_Tab, Qt.KeyboardModifier.NoModifier)
_assert(
event_probe.eventFilter(event_probe.relation_formula_input, tab_event),
"relation formula input should consume Tab before Qt moves focus",
)
_assert(
event_probe.relation_formula_input.text() in {"Face", "Face0"},
f"ShortcutOverride Tab should accept the face completion: {event_probe.relation_formula_input.text()}",
)
QApplication.processEvents()
_assert(event_probe.relation_formula_input.hasFocus(), "relation formula input should keep focus after Tab completion")
popup = event_probe.relation_formula_completer.popup()
if popup is not None:
popup.installEventFilter(event_probe)
before_backspace = event_probe.relation_formula_input.text()
popup_backspace = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Backspace, Qt.KeyboardModifier.NoModifier)
_assert(
event_probe.eventFilter(popup, popup_backspace),
"relation formula completer popup should forward Backspace to the input",
)
_assert(
event_probe.relation_formula_input.text() == before_backspace[:-1],
f"Backspace should edit the input even if popup receives it: {event_probe.relation_formula_input.text()}",
)
_assert(event_probe.relation_formula_input.hasFocus(), "relation formula input should keep focus after popup Backspace")
global_probe = _PropertyTableProbe()
global_probe.model = _GlobalRelationCompletionModel()
global_probe.selected_kind = "face"
global_probe.selected_face_id = 0
global_probe.show()
global_probe._refresh_property_editor()
global_probe.relation_formula_input.setFocus(Qt.FocusReason.OtherFocusReason)
QApplication.processEvents()
global_probe.model.face_region_call_count = 0
global_probe.relation_formula_input.setText("Face")
global_probe.relation_formula_input.setCursorPosition(len("Face"))
global_probe._update_relation_formula_completions()
global_probe._show_relation_formula_completion_popup()
QApplication.processEvents()
global_face_completion_list = list(global_probe.relation_formula_completer_model.stringList())
global_face_completions = set(global_face_completion_list)
_assert("Face85" in global_face_completions, f"global Face completion should include Face85: {global_face_completions}")
_assert("Face85." not in global_face_completions, f"object completion should not show trailing dot: {global_face_completions}")
_assert(
global_face_completion_list.index("Face2") < global_face_completion_list.index("Face10") < global_face_completion_list.index("Face85"),
f"Face completions should be sorted by numeric ID: {global_face_completion_list[:20]}",
)
global_probe.relation_formula_input.setText("Face8")
global_probe.relation_formula_input.setCursorPosition(len("Face8"))
global_probe._update_relation_formula_completions()
face8_completion_list = list(global_probe.relation_formula_completer_model.stringList())
_assert("Face87" in face8_completion_list, f"Face8 should still suggest longer Face IDs: {face8_completion_list[:20]}")
_assert(
face8_completion_list[0] != "Face8",
f"Face8 completion should prefer longer IDs instead of no-op self completion: {face8_completion_list[:20]}",
)
_assert(
face8_completion_list.index("Face80") < face8_completion_list.index("Face87"),
f"Face8 completions should keep numeric order: {face8_completion_list[:20]}",
)
face8_tab_probe = _PropertyTableProbe()
face8_tab_probe.model = _GlobalRelationCompletionModel()
face8_tab_probe.selected_kind = "face"
face8_tab_probe.selected_face_id = 0
face8_tab_probe._refresh_property_editor()
face8_tab_probe.relation_formula_input.setText("Face8")
face8_tab_probe.relation_formula_input.setCursorPosition(len("Face8"))
_assert(face8_tab_probe._accept_relation_formula_completion(), "Tab should accept a longer Face8 completion")
_assert(
face8_tab_probe.relation_formula_input.text().startswith("Face8")
and face8_tab_probe.relation_formula_input.text() != "Face8",
f"Tab after Face8 should complete to a longer ID, not stay unchanged: {face8_tab_probe.relation_formula_input.text()}",
)
face_tab_probe = _RelationFormulaEventProbe()
face_tab_probe.model = _GlobalRelationCompletionModel()
face_tab_probe.selected_kind = "face"
face_tab_probe.selected_face_id = 0
face_tab_probe.show()
face_tab_probe._refresh_property_editor()
face_tab_probe.relation_formula_input.setFocus(Qt.FocusReason.OtherFocusReason)
QApplication.processEvents()
face_tab_probe.relation_formula_input.setText("F")
face_tab_probe.relation_formula_input.setCursorPosition(len("F"))
face_tab_event = QKeyEvent(QEvent.Type.ShortcutOverride, Qt.Key.Key_Tab, Qt.KeyboardModifier.NoModifier)
_assert(face_tab_probe.eventFilter(face_tab_probe.relation_formula_input, face_tab_event), "F + Tab should be consumed")
for _index in range(4):
QApplication.processEvents()
_assert(face_tab_probe.relation_formula_input.text() == "Face", f"F + Tab should complete to Face: {face_tab_probe.relation_formula_input.text()}")
_assert("Face87" in set(face_tab_probe.relation_formula_completer_model.stringList()), "Face layer should suggest Face87 after F + Tab")
_assert(face_tab_probe.relation_formula_completer.popup().isVisible(), "Face layer popup should open after F + Tab")
_assert(
face_tab_probe.relation_formula_completer.popup().minimumWidth()
>= max(face_tab_probe.relation_formula_input.width(), 320),
"Face completion popup should be wide enough to show text",
)
face_manual_probe = _RelationFormulaEventProbe()
face_manual_probe.model = _GlobalRelationCompletionModel()
face_manual_probe.selected_kind = "face"
face_manual_probe.selected_face_id = 0
face_manual_probe.show()
face_manual_probe._refresh_property_editor()
face_manual_probe.relation_formula_input.setFocus(Qt.FocusReason.OtherFocusReason)
QApplication.processEvents()
face_manual_probe.relation_formula_input.setText("Face")
face_manual_probe.relation_formula_input.setCursorPosition(len("Face"))
for _index in range(4):
QApplication.processEvents()
_assert("Face87" in set(face_manual_probe.relation_formula_completer_model.stringList()), "manual Face input should suggest Face87")
_assert(face_manual_probe.relation_formula_completer.popup().isVisible(), "manual Face input should open the next completion layer")
_assert(
face_manual_probe.relation_formula_completer.popup().minimumWidth()
>= max(face_manual_probe.relation_formula_input.width(), 320),
"manual Face popup should not collapse to cursor width",
)
global_probe.relation_formula_input.setText("Face87")
global_probe.relation_formula_input.setCursorPosition(len("Face87"))
global_probe._update_relation_formula_completions()
face87_completion_list = list(global_probe.relation_formula_completer_model.stringList())
face87_ref = next((item for item in face87_completion_list if str(item).startswith("Face87.")), "")
_assert(face87_ref, f"exact Face ID should suggest editable parameters: {face87_completion_list}")
face87_radius_ref = next((item for item in face87_completion_list if str(item).endswith("半径")), "")
_assert(face87_radius_ref, f"exact Face ID should suggest radius: {face87_completion_list}")
radius_probe = _PropertyTableProbe()
radius_probe.model = _GlobalRelationCompletionModel()
radius_probe.selected_kind = "face"
radius_probe.selected_face_id = 0
radius_probe.show()
radius_probe._refresh_property_editor()
radius_probe.relation_formula_input.setFocus(Qt.FocusReason.OtherFocusReason)
QApplication.processEvents()
radius_probe.relation_formula_input.setText("Face87")
radius_probe.relation_formula_input.setCursorPosition(len("Face87"))
radius_probe._update_relation_formula_completions()
radius_probe._show_relation_formula_completion_popup()
QApplication.processEvents()
radius_completion_list = list(radius_probe.relation_formula_completer_model.stringList())
radius_row = radius_completion_list.index(face87_radius_ref)
radius_model = radius_probe.relation_formula_completer.model()
radius_popup = radius_probe.relation_formula_completer.popup()
radius_probe.relation_formula_completer.setCurrentRow(radius_row)
radius_popup.setCurrentIndex(radius_model.index(radius_row, 0))
_assert(radius_probe._accept_relation_formula_completion(), "Tab should accept the popup-selected radius completion")
_assert(
radius_probe.relation_formula_input.text() == face87_radius_ref,
f"Tab should keep the popup-selected completion, not reset to default: {radius_probe.relation_formula_input.text()}",
)
global_probe.relation_formula_input.setText(face87_ref)
global_probe.relation_formula_input.setCursorPosition(len(face87_ref))
global_probe._update_relation_formula_completions()
_assert(" = " in set(global_probe.relation_formula_completer_model.stringList()), "complete Face parameter should suggest '='")
global_probe._on_relation_formula_completion_activated(" = ")
_assert(
global_probe.relation_formula_input.text() == f"{face87_ref} = ",
f"mouse activation should insert '=' without replacing the whole formula: {global_probe.relation_formula_input.text()}",
)
global_probe.relation_formula_input.setText(face87_ref)
global_probe.relation_formula_input.setCursorPosition(len(face87_ref))
global_probe._update_relation_formula_completions()
global_probe.relation_formula_input.setText(f"{face87_ref} = F")
global_probe.relation_formula_input.setCursorPosition(len(f"{face87_ref} = F"))
global_probe._update_relation_formula_completions()
rhs_f_completion_list = list(global_probe.relation_formula_completer_model.stringList())
_assert(
"Face85" in rhs_f_completion_list and "Face87" in rhs_f_completion_list,
f"right-hand expression should suggest global Face IDs after F: {rhs_f_completion_list[:20]}",
)
global_probe.relation_formula_input.setText(f"{face87_ref} = Face8")
global_probe.relation_formula_input.setCursorPosition(len(f"{face87_ref} = Face8"))
global_probe._update_relation_formula_completions()
rhs_face8_completion_list = list(global_probe.relation_formula_completer_model.stringList())
_assert("Face87" in rhs_face8_completion_list, f"right-hand Face8 should suggest Face87: {rhs_face8_completion_list[:20]}")
global_probe.relation_formula_input.setText(f"{face87_ref} = Face87")
global_probe.relation_formula_input.setCursorPosition(len(f"{face87_ref} = Face87"))
global_probe._update_relation_formula_completions()
rhs_face87_completion_list = list(global_probe.relation_formula_completer_model.stringList())
_assert(
any(str(item).startswith("Face87.") for item in rhs_face87_completion_list),
f"right-hand exact Face ID should suggest parameters: {rhs_face87_completion_list}",
)
global_probe.relation_formula_input.setText("Edge")
global_probe.relation_formula_input.setCursorPosition(len("Edge"))
global_probe._update_relation_formula_completions()
global_edge_completion_list = list(global_probe.relation_formula_completer_model.stringList())
global_edge_completions = set(global_edge_completion_list)
_assert("Edge0" in global_edge_completions, f"Edge completion should be available even in Face selection mode: {global_edge_completions}")
_assert(
global_edge_completion_list.index("Edge2") < global_edge_completion_list.index("Edge10"),
f"Edge completions should be sorted by numeric ID: {global_edge_completion_list}",
)
_assert(
global_probe.model.face_region_call_count == 0,
f"relation formula typing should not call heavy face_region_logical_id: {global_probe.model.face_region_call_count}",
)
large_probe = _PropertyTableProbe()
large_probe.model = _LargeRelationCompletionModel()
large_probe.selected_kind = "face"
large_probe.selected_face_id = 0
large_probe._refresh_property_editor()
large_probe.model.face_region_call_count = 0
large_probe.relation_formula_input.setText("F")
large_probe.relation_formula_input.setCursorPosition(len("F"))
large_probe._update_relation_formula_completions()
large_completions = list(large_probe.relation_formula_completer_model.stringList())
_assert("Face87" in large_completions, f"large model F completion should still include common Face IDs: {large_completions[:20]}")
_assert(
len(large_completions) <= RELATION_FORMULA_OBJECT_COMPLETION_LIMIT + 2,
f"large model broad completion should be capped: {len(large_completions)}",
)
_assert(
large_probe.model.face_region_call_count == 0,
f"large model completion should not call heavy face_region_logical_id: {large_probe.model.face_region_call_count}",
)
probe.relation_formula_input.setText("Face0.")
probe.relation_formula_input.setCursorPosition(len("Face0."))
probe._update_relation_formula_completions()
face_completions = set(probe.relation_formula_completer_model.stringList())
_assert("Face0.面内长度" in face_completions, f"relation formula should suggest parameters after Face0.: {face_completions}")
probe.relation_formula_input.setText("Face0.面内宽度")
probe.relation_formula_input.setCursorPosition(len("Face0.面内宽度"))
probe._update_relation_formula_completions()
_assert(" = " in set(probe.relation_formula_completer_model.stringList()), "relation formula should suggest '=' after a target parameter")
probe.relation_formula_input.setText("Face0.面内宽度 = Face0.面内长度 * 1.2")
probe.add_relation_formula()
_assert(len(probe.relation_formula_items) == 1, f"formula was not stored: {probe.relation_formula_items}")
_assert(probe.relation_formula_list.count() == 1, "formula list should display the stored formula")
for _index in range(6):
QApplication.processEvents()
if not getattr(probe, "relation_formula_replay_active", False):
break
_assert(
probe.executed_property_actions == [("resize_face_height_local", "12")],
f"formula should immediately fill target value and execute the existing row action: {probe.executed_property_actions}",
)
width_row = _row_by_label(probe, "面内宽度")
width_widget = probe.property_table.cellWidget(width_row, PROPERTY_TARGET_COLUMN)
_assert(isinstance(width_widget, QLineEdit), "formula target row should still have a target editor")
_assert(width_widget.text().strip() == "12", "formula result should be written back to the target value cell")
_assert(str(probe.relation_formula_items[0].get("status")) == "applied", "formula should be marked as applied")
for _index in range(4):
QApplication.processEvents()
_assert(probe.relation_formula_input.isEnabled(), "relation formula input should stay enabled after one formula is applied")
probe.relation_formula_input.setText("F")
_assert(probe.add_relation_formula_button.isEnabled(), "existing formulas should not disable adding another formula")
def _assert_relation_radius_formula_proxy() -> None:
probe = _PropertyTableProbe()
probe.model = _GlobalRelationCompletionModel()
probe.selected_kind = "face"
probe.selected_face_id = 87
probe.property_table.setRowCount(1)
probe.property_editor_specs = [
{
"key": "diameter",
"label": "直径",
"current_raw": 1.0,
"current_text": "1",
"target_text": "1",
"editable": True,
"enabled": True,
"action": "resize_hole",
"value_type": "number",
"source_face_id": 87,
}
]
probe.property_table.setItem(0, PROPERTY_LABEL_COLUMN, QTableWidgetItem("直径"))
target = QLineEdit("1")
probe.property_table.setCellWidget(0, PROPERTY_TARGET_COLUMN, target)
ref = ObjectParameterRef("Face", 87, "半径")
visible = probe._relation_visible_spec_for_ref(ref)
_assert(visible is not None, "Face radius formula should fall back to an executable diameter row")
_assert(
abs(float(probe._relation_value_for_ref(ref)) - 0.5) <= 1e-9,
f"radius formula should read half of the diameter: {probe._relation_value_for_ref(ref)}",
)
row = probe._set_relation_target_value(ref, 0.6)
_assert(row == 0, f"radius proxy should write to the diameter row: {row}")
_assert(target.text() == "1.2", f"radius proxy should convert target radius to diameter: {target.text()}")
def _assert_relation_formula_input_remains_editable_while_replaying() -> None:
probe = _PropertyTableProbe()
probe.model = _GlobalRelationCompletionModel()
probe.selected_kind = "face"
probe.selected_face_id = 87
probe._refresh_property_editor()
probe.operation_in_progress = True
probe.relation_formula_replay_active = True
probe.relation_formula_replay_queue = []
probe.relation_formula_replay_total = 1
probe.relation_formula_input.setEnabled(False)
probe._update_relation_formula_buttons()
_assert(probe.relation_formula_input.isEnabled(), "relation formula input should stay editable while formulas replay")
probe.relation_formula_input.setText("Face87.半径 = Face85.半径")
probe.add_relation_formula()
_assert(len(probe.relation_formula_items) == 1, f"formula should be stored while replaying: {probe.relation_formula_items}")
_assert(len(probe.relation_formula_replay_queue) == 1, f"formula should be queued while replaying: {probe.relation_formula_replay_queue}")
_assert(probe.relation_formula_input.text() == "", "queued formula should clear the input for the next formula")
_assert(probe.relation_formula_input.isEnabled(), "relation formula input should remain enabled after queuing")
_assert(probe.relation_formula_replay_active, "adding a queued formula should not stop the current replay")
def _assert_relation_formula_input_clickable_after_existing_formula() -> None:
probe = _RelationFormulaEventProbe()
probe.relation_formula_input.installEventFilter(probe)
probe.show()
probe._refresh_property_editor()
probe.relation_formula_input.setText("Face0.面内宽度 = Face0.面内长度 * 1.2")
probe.add_relation_formula()
for _index in range(12):
QApplication.processEvents()
if not getattr(probe, "relation_formula_replay_active", False):
break
_assert(len(probe.relation_formula_items) == 1, f"formula should exist before click test: {probe.relation_formula_items}")
_assert(str(probe.relation_formula_items[0].get("status")) == "applied", "formula should be applied before click test")
probe.activateWindow()
probe.raise_()
QApplication.processEvents()
QTest.mouseClick(probe.relation_formula_input, Qt.MouseButton.LeftButton)
QApplication.processEvents()
_assert(probe.relation_formula_input.isEnabled(), "relation formula input should stay enabled with an existing formula")
_assert(probe.relation_formula_input.hasFocus(), "clicking the relation formula input should focus it with an existing formula")
probe.relation_formula_input.setText("F")
_assert(probe.add_relation_formula_button.isEnabled(), "clickable input should allow adding another formula")
def _assert_relation_formula_input_is_selection_independent() -> None:
probe = _RelationFormulaEventProbe()
probe.relation_formula_input.installEventFilter(probe)
probe.model = _GlobalRelationCompletionModel()
probe.selected_kind = None
probe.selected_face_id = None
probe.selected_edge_id = None
probe.relation_formula_items = []
probe.relation_formula_input.setEnabled(False)
probe._refresh_relation_formula_list()
_assert(probe.relation_formula_input.isEnabled(), "relation formula input should be editable without a selected face")
_assert(not probe.relation_formula_input.isReadOnly(), "relation formula input should not become read-only without a selected face")
_assert(not probe.add_relation_formula_button.isEnabled(), "empty relation formula should not enable the add button")
probe.show()
probe.activateWindow()
probe.raise_()
QApplication.processEvents()
QTest.mouseClick(probe.relation_formula_input, Qt.MouseButton.LeftButton)
QApplication.processEvents()
probe.relation_formula_input.setText("Face8")
probe.relation_formula_input.setCursorPosition(len("Face8"))
probe._update_relation_formula_completions()
object_completions = list(probe.relation_formula_completer_model.stringList())
_assert("Face85" in object_completions and "Face87" in object_completions, f"global face completions should not require selection: {object_completions[:20]}")
probe.relation_formula_input.setText("Face85")
probe.relation_formula_input.setCursorPosition(len("Face85"))
probe._update_relation_formula_completions()
parameter_completions = list(probe.relation_formula_completer_model.stringList())
_assert(any(str(item).startswith("Face85.") for item in parameter_completions), f"Face parameter completions should not require selection: {parameter_completions}")
def _assert_relation_formula_input_recovers_after_loading() -> None:
probe = _RelationFormulaEventProbe()
probe.model = _GlobalRelationCompletionModel()
probe.mode_combo = NoWheelComboBox()
probe.mode_combo.addItem("Face", "Face")
probe.load_thread = None
probe.load_refine_thread = None
probe.pending_load_path = None
probe.load_in_progress = True
probe._update_relation_formula_buttons()
_assert(not probe.relation_formula_input.isEnabled(), "relation formula input should be disabled while loading")
probe._end_load_task()
_assert(probe.relation_formula_input.isEnabled(), "relation formula input should recover after loading without selecting a face")
def _assert_relation_formula_ids_follow_model_remap() -> None:
old_model = _RelationFormulaRemapModel(
{
85: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.25, "area": 8.0},
87: {"surface": "cylinder", "area_center": (2.0, 1.0, 9.5), "bbox_center": (2.0, 1.0, 9.5), "radius": 0.25, "area": 8.0},
}
)
new_model = _RelationFormulaRemapModel(
{
90: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.25, "area": 8.0},
92: {"surface": "cylinder", "area_center": (2.0, 1.0, 9.5), "bbox_center": (2.0, 1.0, 9.5), "radius": 0.15, "area": 5.0},
}
)
probe = _PropertyTableProbe()
formula_text = "Face87.直径 = Face85.半径"
probe.model = old_model
formula = parse_relation_formula(formula_text)
probe.relation_formula_items = [
{
"id": 1,
"text": formula_text,
"enabled": True,
"status": "applied",
"message": "已修改模型。",
"signatures": probe._relation_signatures_for_formula(formula),
}
]
probe.model = new_model
note = probe._refresh_relation_formulas_after_model_edit()
_assert(
probe.relation_formula_items[0]["text"] == "Face92.直径 = Face90.半径",
f"relation formula should follow remapped visible Face IDs: {probe.relation_formula_items}",
)
_assert("Face ID" in note, f"relation remap note should mention updated Face IDs: {note}")
old_model = _RelationFormulaRemapModel(
{
85: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.25, "area": 8.0},
87: {"surface": "cylinder", "area_center": (0.5, 1.0, 0.5), "bbox_center": (0.5, 1.0, 0.5), "radius": 0.25, "area": 8.0},
}
)
new_model = _RelationFormulaRemapModel(
{
85: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.125, "area": 4.0},
11: {"surface": "cylinder", "area_center": (0.5, 1.0, 0.5), "bbox_center": (0.5, 1.0, 0.5), "radius": 0.25, "area": 8.0},
}
)
probe = _PropertyTableProbe()
formula_text = "Face85.直径 = Face87.半径"
probe.model = old_model
formula = parse_relation_formula(formula_text)
probe.relation_formula_items = [
{
"id": 1,
"text": formula_text,
"enabled": True,
"status": "applied",
"message": "已修改模型。",
"signatures": probe._relation_signatures_for_formula(formula),
}
]
probe.model = new_model
probe._refresh_relation_formulas_after_model_edit()
_assert(
probe.relation_formula_items[0]["text"] == "Face85.直径 = Face11.半径",
f"relation formula should remap a changed reference ID even when the target ID is preserved: {probe.relation_formula_items}",
)
old_model = _RelationFormulaRemapModel(
{
85: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.25, "area": 8.0},
87: {"surface": "cylinder", "area_center": (0.5, 1.0, 0.5), "bbox_center": (0.5, 1.0, 0.5), "radius": 0.25, "area": 8.0},
}
)
new_model = _RelationFormulaRemapModel(
{
85: {"surface": "cylinder", "area_center": (0.5, 1.0, 0.5), "bbox_center": (0.5, 1.0, 0.5), "radius": 0.5, "area": 16.0},
89: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.125, "area": 4.0},
91: {"surface": "cylinder", "area_center": (0.5, 1.0, 0.5), "bbox_center": (0.5, 1.0, 0.5), "radius": 0.25, "area": 8.0},
}
)
new_model.face_logical_ids[89] = 85
probe = _PropertyTableProbe()
formula_text = "Face85.直径 = Face87.半径"
probe.model = old_model
formula = parse_relation_formula(formula_text)
probe.relation_formula_items = [
{
"id": 1,
"text": formula_text,
"enabled": True,
"status": "applied",
"message": "已修改模型。",
"signatures": probe._relation_signatures_for_formula(formula),
}
]
probe.model = new_model
probe._refresh_relation_formulas_after_model_edit()
_assert(
probe.relation_formula_items[0]["text"] == "Face85.直径 = Face91.半径",
f"relation remap should prefer the old reference radius over a same-center changed target: {probe.relation_formula_items}",
)
old_model = _RelationFormulaRemapModel(
{
9: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.25, "area": 8.0},
87: {"surface": "cylinder", "area_center": (0.5, 1.0, 0.5), "bbox_center": (0.5, 1.0, 0.5), "radius": 0.15, "area": 4.8},
}
)
new_model = _RelationFormulaRemapModel(
{
14: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.4, "area": 12.8},
87: {"surface": "cylinder", "area_center": (0.5, 1.0, 0.5), "bbox_center": (0.5, 1.0, 0.5), "radius": 0.15, "area": 4.8},
}
)
new_model.face_logical_ids[14] = 9
probe = _PropertyTableProbe()
formula_text = "Face87.直径 = Face9.半径"
probe.model = old_model
formula = parse_relation_formula(formula_text)
probe.relation_formula_items = [
{
"id": 1,
"text": formula_text,
"enabled": True,
"status": "applied",
"message": "已修改模型。",
"signatures": probe._relation_signatures_for_formula(formula),
}
]
probe.model = new_model
probe._refresh_relation_formulas_after_model_edit(
context={"target_kind": "face", "target_id": 9, "target_logical_id": 9}
)
_assert(
probe.relation_formula_items[0]["text"] == "Face87.直径 = Face9.半径",
f"edited right-hand dependency should keep its logical Face ID instead of becoming invalid: {probe.relation_formula_items}",
)
_assert(
str(probe.relation_formula_items[0].get("status")) != "invalid",
f"edited right-hand dependency should remain valid: {probe.relation_formula_items}",
)
_assert(
getattr(probe, "_last_relation_formula_refresh_affected_ids", []) == [1],
f"edited right-hand dependency should mark the formula for reapply: {probe.relation_formula_items}",
)
old_model = _RelationFormulaRemapModel(
{
9: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.25, "area": 8.0},
87: {"surface": "cylinder", "area_center": (2.0, 1.0, 9.5), "bbox_center": (2.0, 1.0, 9.5), "radius": 0.125, "area": 4.0},
}
)
new_model = _RelationFormulaRemapModel(
{
1: {"surface": "cylinder", "area_center": (2.0, 1.0, 9.5), "bbox_center": (2.0, 1.0, 9.5), "radius": 0.5, "area": 16.0},
9: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.4, "area": 12.8},
11: {"surface": "cylinder", "area_center": (2.0, 1.0, 9.5), "bbox_center": (2.0, 1.0, 9.5), "radius": 0.125, "area": 4.0},
}
)
new_model.face_logical_ids[9] = 9
probe = _PropertyTableProbe()
formula_text = "Face87.\u76f4\u5f84 = Face9.\u534a\u5f84"
probe.model = old_model
formula = parse_relation_formula(formula_text)
probe.relation_formula_items = [
{
"id": 1,
"text": formula_text,
"enabled": True,
"status": "applied",
"message": "applied",
"signatures": probe._relation_signatures_for_formula(formula),
}
]
probe.model = new_model
probe._refresh_relation_formulas_after_model_edit(
context={"target_kind": "face", "target_id": 9, "target_logical_id": 9}
)
_assert(
probe.relation_formula_items[0]["text"] == "Face11.\u76f4\u5f84 = Face9.\u534a\u5f84",
f"unchanged formula target should stay strict while a dependency changes: {probe.relation_formula_items}",
)
_assert(
str(probe.relation_formula_items[0].get("status")) != "invalid",
f"strict target remap should keep the dependency formula valid: {probe.relation_formula_items}",
)
_assert(
getattr(probe, "_last_relation_formula_refresh_affected_ids", []) == [1],
f"edited dependency should still queue the formula for reapply after target remap: {probe.relation_formula_items}",
)
def _unexpected_restore() -> bool:
raise AssertionError("dependency relation replay must not restore the formula base snapshot")
probe.relation_formula_base_snapshot = {"sentinel": object()}
probe._restore_relation_formula_base_snapshot = _unexpected_restore
probe._start_relation_formula_reapply(reason="dependency", restore_base=False, formula_ids=[1])
_assert(
[int(item.get("id", -1)) for item in probe.relation_formula_replay_queue] == [1],
f"dependency replay should queue only the affected formula: {probe.relation_formula_replay_queue}",
)
probe.relation_formula_replay_active = False
probe.relation_formula_replay_queue = []
def _assert_mouse_selection_guards() -> None:
mouse_probe = _MouseSelectionProbe()
mouse_probe._handle_left_button_press(20, 20)
mouse_probe._handle_left_button_release(20, 20)
_assert(
[target.get("target_id") for target in mouse_probe.selected_targets] == [1],
"plain left click should still select",
)
mouse_probe = _MouseSelectionProbe()
mouse_probe._handle_left_button_press(20, 20)
mouse_probe._update_left_button_drag_state(40, 20)
mouse_probe._handle_left_button_release(40, 20)
_assert(not mouse_probe.selected_targets, "left-button drag should not select a face on release")
mouse_probe = _MouseSelectionProbe()
mouse_probe._handle_left_button_press(20, 20)
mouse_probe.camera_state = (0.5, 0.0, 9.8, 0.0, 0.0, 0.0, 0.02, 1.0, 0.0, 1.0, 9.8, 30.0)
mouse_probe._handle_left_button_release(22, 21)
_assert(
not mouse_probe.selected_targets,
"left-button camera rotation should not select a face even when the cursor lands on the model",
)
mouse_probe = _MouseSelectionProbe()
mouse_probe.pick_targets = [None, {"kind": "face", "target_id": 1, "pick_position": (0.0, 0.0, 0.0)}]
mouse_probe._handle_left_button_press(20, 20)
mouse_probe._handle_left_button_release(20, 20)
_assert(
not mouse_probe.selected_targets,
"left-button press on the background should not select a face on release",
)
mouse_probe = _MouseSelectionProbe()
mouse_probe.pick_targets = [
{"kind": "face", "target_id": 1, "pick_position": (0.0, 0.0, 0.0)},
{"kind": "face", "target_id": 2, "pick_position": (0.0, 0.0, 0.0)},
]
mouse_probe._handle_left_button_press(20, 20)
mouse_probe._handle_left_button_release(20, 20)
_assert(
not mouse_probe.selected_targets,
"left-button press/release on different faces should not change selection",
)
mouse_probe = _MouseSelectionProbe()
mouse_probe._large_model_interaction_mode = lambda stats=None: True
mouse_probe.pick_targets = [{"kind": "face", "target_id": 8, "pick_position": (0.0, 0.0, 0.0)}]
mouse_probe._handle_left_button_press(20, 20)
_assert(
len(mouse_probe.pick_targets) == 0,
"large-model left-button press should record the pressed target so background drags cannot select on release",
)
mouse_probe._handle_left_button_release(20, 20)
_assert(
[target.get("target_id") for target in mouse_probe.selected_targets] == [8],
"large-model plain click should select the target recorded on press",
)
mouse_probe = _MouseSelectionProbe()
mouse_probe._large_model_interaction_mode = lambda stats=None: True
mouse_probe.pick_targets = [{"kind": "face", "target_id": 8, "pick_position": (0.0, 0.0, 0.0)}]
mouse_probe._handle_left_button_press(20, 20)
mouse_probe._update_left_button_drag_state(40, 20)
mouse_probe._handle_left_button_release(40, 20)
_assert(
len(mouse_probe.pick_targets) == 0 and not mouse_probe.selected_targets,
"large-model drag/rotation may record the press target but must not select on release",
)
def _assert_quick_blind_depth_spec() -> None:
blind_probe = _PropertyTableProbe()
blind_probe.selected_kind = "feature"
blind_probe.selected_face_id = 6
quick_blind_info = {
"surface": "cylinder",
"diameter": 4.0,
"radius": 2.0,
"axis_point": (0.0, 0.0, 0.0),
"axis": (0.0, 0.0, 1.0),
"axis_center": (0.0, 0.0, 3.0),
"angular_span": math.tau,
"feature_guess": "hole/groove candidate",
"feature_type": "圆柱孔候选",
"confidence": "medium",
"cylinder_end_type": "blind",
"hole_depth_estimate": 6.0,
"depth_status": "ready",
"recognition_ready_actions": "孔/槽/圆柱直径;盲孔/盲槽深度;封堵孔/槽",
}
blind_specs, _blind_used = blind_probe._editable_property_specs(quick_blind_info)
blind_feature_specs = blind_probe._feature_property_specs(blind_specs, quick_blind_info)
blind_axis_specs = [
spec for spec in blind_feature_specs if str(spec.get("key", "")) == "hole_axis_center"
]
_assert(blind_axis_specs, "quick blind hole axis center should be visible in feature parameters")
blind_axis_spec = blind_axis_specs[0]
_assert(str(blind_axis_spec.get("value_type", "")) == "vector3", "hole axis center should accept X/Y/Z")
blind_axis_effective = blind_probe._effective_property_spec(blind_axis_spec)
_assert(bool(blind_axis_effective.get("enabled")), "quick blind hole axis center should be editable")
_assert(
str(blind_axis_effective.get("action", "")) == "move_cylindrical_hole_axis",
f"quick blind hole axis center should move the hole itself: {blind_axis_effective}",
)
blind_depth_specs = [
spec for spec in blind_feature_specs if str(spec.get("key", "")) == "hole_depth_estimate"
]
_assert(blind_depth_specs, "quick blind hole depth estimate should be visible in feature parameters")
blind_depth_spec = blind_depth_specs[0]
blind_effective_depth = blind_probe._effective_property_spec(blind_depth_spec)
_assert(bool(blind_effective_depth.get("enabled")), "quick blind hole depth should be editable")
_assert(
str(blind_effective_depth.get("action", "")) == "resize_hole_depth",
f"quick blind hole depth should use local depth edit first: {blind_effective_depth}",
)
_assert(
"重新确认底面" in str(blind_effective_depth.get("enabled_tip", "")),
"quick blind hole depth should explain execution-time bottom-face confirmation",
)
owning_mode = dict(blind_depth_spec.get("scope_modes", {})).get("owning", {})
_assert(
not bool(owning_mode.get("enabled")),
"quick blind hole without explicit bottom faces should not expose owning-scale depth as editable",
)
def _assert_user_facing_failure_messages() -> None:
action_probe = _ActionMessageProbe()
illegal_context = {
"operation_name": "测试修改",
"target": "Face 1",
"parameters": {"resize_status": "blocked", "resize_blockers": "目标值必须大于 0。"},
}
illegal_blocker = action_probe._edit_preflight_blocker(illegal_context)
_assert(illegal_blocker is not None, "blocked edit plan should be stopped before worker startup")
_assert(illegal_blocker[0] == "当前操作不合法", "illegal blocked edit should be classified clearly")
english_illegal_context = {
"operation_name": "调整槽宽",
"target": "Face 3",
"parameters": {"resize_status": "blocked", "resize_blockers": "Target slot value must be greater than 0."},
}
english_illegal_blocker = action_probe._edit_preflight_blocker(english_illegal_context)
_assert(
english_illegal_blocker is not None and english_illegal_blocker[0] == "当前操作不合法",
"english geometry blockers should also be classified as illegal operations",
)
risk_blocker = action_probe._plan_preflight_blocker(
{"status": "blocked", "risk": "blocked", "message": "目标壳体厚度会让几何风险过高。"}
)
_assert(risk_blocker is not None and risk_blocker[0] == "风险过高", "blocked high-risk plans should be classified")
recognition_blocker = action_probe._plan_preflight_blocker(
{"status": "blocked", "message": "当前平面没有识别到相对壳体平面。"}
)
_assert(
recognition_blocker is not None and recognition_blocker[0] == "识别不足",
"blocked recognition failures should be classified",
)
unsupported_blocker = action_probe._plan_preflight_blocker(
{"status": "blocked", "message": "当前版本只对简单圆锥解析重建开放这类修改。"}
)
_assert(
unsupported_blocker is not None and unsupported_blocker[0] == "暂未实现",
"blocked unsupported capability plans should be classified",
)
auxiliary_context = {
"operation_name": "测试修改",
"target": "Face 4",
"parameters": {"quick_plan_status": "blocked", "resize_status": "ready", "resize_blockers": ""},
}
_assert(action_probe._edit_preflight_blocker(auxiliary_context) is None, "auxiliary statuses should not block ready edits")
deferred_context = {
"operation_name": "复杂 Face 修改",
"target": "Face 2",
"parameters": {"ui_deferred_model_plan": True, "message": "当前对象需要完整一级关系计划。"},
}
deferred_blocker = action_probe._edit_preflight_blocker(deferred_context)
_assert(deferred_blocker is not None, "deferred model plan should be stopped before slow worker startup")
_assert(deferred_blocker[0] == "暂未实现", "deferred model plan should be classified as unsupported")
title, user_message = action_probe._user_facing_edit_failure_message(
"隔离子进程执行失败;主程序没有崩溃,原模型保持不变。 当前版本暂不支持复杂链式圆角。",
deferred_context,
)
_assert(title == "不能修改", "edit failure dialog title should be user-facing")
_assert("隔离子进程" not in user_message and "子进程" not in user_message, "failure message should not expose isolation details")
_assert("暂未实现" in user_message, "unsupported failure should state that the operation is not implemented yet")
_empty_title, empty_message = action_probe._user_facing_edit_failure_message(
"隔离子进程执行失败;主程序没有崩溃,原模型保持不变。",
deferred_context,
)
_assert("隔离子进程" not in empty_message and "子进程" not in empty_message, "empty internal failure should stay user-facing")
def _assert_parameter_export_action() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
output_path = Path(temp_dir) / "data.json"
probe = _ParameterExportActionProbe(output_path)
probe.export_selected_parameters()
payload = json.loads(output_path.read_text(encoding="utf-8"))
component_text = probe.component_path.read_text(encoding="utf-8")
_assert(
payload == [
{
"name": "面内长度",
"displayName": "面内长度",
"type": "number",
"ioRole": "input",
"default": "151",
},
{
"name": "偏移",
"displayName": "偏移",
"type": "number",
"ioRole": "input",
"default": "57.5",
},
],
f"parameter export action wrote unexpected JSON: {payload}",
)
_assert(
"INPUT_PARAMETERS" in component_text and "def execute(inputs, params, context):" in component_text and "面内长度" in component_text,
"parameter export should generate FlowEditor-style component main.py with embedded input parameters",
)
_assert(
not (probe.component_path.parent / "data.json").exists(),
"component export should embed parameters in main.py instead of writing component data.json",
)
_assert(
probe.status_bar.messages and "已导出 2 个输入参数" in probe.status_bar.messages[-1],
"parameter export status should mention exported count",
)
_assert(
"data.json" in probe.info_text and "参数数量:2" in probe.info_text and "组件入口:main.py" in probe.info_text,
"parameter export info panel summary should mention generated component",
)
def _assert_scdm_selection_diagnostics() -> None:
probe = _PropertyTableProbe()
probe.scdm_backend_status = {
"path": "D:/softwaresInstallDir/ANSYS Inc/v222/SCDM/SpaceClaim.exe",
"source": "common:D:/softwaresInstallDir/ANSYS Inc",
"version": "v222",
"runScriptOk": True,
"licenseOk": True,
}
probe.scdm_feature_cache_state = "ready"
probe.scdm_edit_runner_ready = {"face.offset"}
probe.scdm_feature_cache = {
"objects": [
{
"objectId": "face:0",
"objectType": "face",
"geometrySignature": {"faceIds": [0], "surfaceType": "plane", "planeOffset": 0.0},
"capabilities": [
{
"key": "face.offset",
"displayName": "偏移",
"currentValue": 0.0,
"valueKind": "number",
"defaultIntent": "推拉平面",
"backendOperation": "pull_face_offset",
"postCheck": "target_face_offset",
}
],
}
]
}
probe._refresh_property_editor()
info = dict(probe.current_info_values)
_assert("SCDM:已配置 v222" in str(info.get("scdm_backend_status")), f"SCDM backend diagnostic missing: {info}")
_assert("识别缓存已就绪" in str(info.get("scdm_runtime_status")), f"SCDM runtime diagnostic missing: {info}")
_assert("SCDM 已识别当前对象" in str(info.get("scdm_selection_status")), f"SCDM selection diagnostic missing: {info}")
_assert("偏移" in str(info.get("scdm_selection_enabled_capabilities")), f"SCDM enabled capability diagnostic missing: {info}")
def _assert_solid_selection_does_not_expand_face_scdm_specs() -> None:
probe = _PropertyTableProbe()
probe.scdm_feature_cache_state = "ready"
probe.scdm_edit_runner_ready = {"face.offset"}
probe.scdm_feature_cache = {
"objects": [
{
"objectId": "face:0",
"objectType": "face",
"geometrySignature": {"faceIds": [0], "surfaceType": "plane", "planeOffset": 0.0},
"capabilities": [
{
"key": "face.offset",
"displayName": "偏移",
"currentValue": 0.0,
"valueKind": "number",
"defaultIntent": "推拉平面",
"backendOperation": "pull_face_offset",
"postCheck": "target_face_offset",
}
],
},
{
"objectId": "face:1",
"objectType": "face",
"geometrySignature": {"faceIds": [1], "surfaceType": "plane", "planeOffset": -5.0},
"capabilities": [
{
"key": "face.offset",
"displayName": "偏移",
"currentValue": -5.0,
"valueKind": "number",
"defaultIntent": "推拉平面",
"backendOperation": "pull_face_offset",
"postCheck": "target_face_offset",
}
],
},
]
}
probe.selected_kind = "face"
probe.selected_face_id = 0
face_specs = probe._scdm_property_specs_for_selection()
_assert(
len([item for item in face_specs if item.get("scdm_capability_key") == "face.offset"]) == 1,
f"Face selection should show the selected Face SCDM offset only: {face_specs}",
)
probe.selected_kind = "solid"
probe.selected_face_id = None
probe.selected_solid_id = 0
probe.model = SimpleNamespace(face_solid_ids=[0, 0])
solid_specs = probe._scdm_property_specs_for_selection()
_assert(
solid_specs == [],
f"Solid selection should not expand every child Face SCDM offset into the parameter table: {solid_specs}",
)
def _assert_operation_record_backend_sources() -> None:
class _OperationRecordProbe(WindowActionMixin):
pass
stats = SimpleNamespace(solids=1, faces=6, edges=12)
probe = _OperationRecordProbe()
probe.current_info_values = {}
internal_record = probe._make_operation_record(
operation_name="拉伸/切除平面",
target="Face 1",
parameters={"surface": "plane"},
result_message="Planar face push/pull completed: nearest_face=2, actual=10, target=10.",
before_stats=stats,
after_stats=stats,
before_geometry={},
after_geometry={},
target_kind="face",
target_id=1,
target_logical_id=1,
)
_assert("backend: OCCT" in internal_record.detail, f"OCCT backend source missing: {internal_record.detail}")
_assert("execution: Qt background worker" in internal_record.detail, f"OCCT execution source missing: {internal_record.detail}")
_assert(
"recognition: internal StepModel" in internal_record.detail,
f"internal recognition source missing: {internal_record.detail}",
)
asitus_record = probe._make_operation_record(
operation_name="调整孔径",
target="Face 87",
parameters={
"surface": "cylinder",
"asitus_relation_status": "ready",
"analysis_situs_feature_hint_summary": "Analysis Situs hint=hole",
},
result_message="Cylinder resize completed: verified_face=87.",
before_stats=stats,
after_stats=stats,
before_geometry={},
after_geometry={},
target_kind="feature",
target_id=87,
target_logical_id=87,
isolation={"operation": "resize_cylinder"},
)
_assert("backend: OCCT" in asitus_record.detail, f"OCCT backend source missing for Analysis Situs record: {asitus_record.detail}")
_assert(
"execution: isolated OCCT subprocess" in asitus_record.detail,
f"isolated execution source missing: {asitus_record.detail}",
)
_assert(
"recognition: Analysis Situs + internal StepModel" in asitus_record.detail,
f"Analysis Situs recognition source missing: {asitus_record.detail}",
)
_assert(
asitus_record.parameters and asitus_record.parameters.get("recognition_source") == "Analysis Situs + internal StepModel",
f"recognition_source should be stored in record parameters: {asitus_record.parameters}",
)
def _assert_scdm_auto_prompt() -> None:
probe = _ScdmAutoPromptProbe()
probe.maybe_prompt_missing_scdm_backend(reason="probe-failed", message="script failed")
QApplication.processEvents()
_assert(not probe.prompt_calls, f"SCDM prompt should only open for missing backend: {probe.prompt_calls}")
probe.maybe_prompt_missing_scdm_backend(reason="missing-spaceclaim", message="not found")
QApplication.processEvents()
_assert(probe.prompt_calls == [(True, "missing-spaceclaim", "not found")], f"SCDM prompt should open once for missing backend: {probe.prompt_calls}")
probe.maybe_prompt_missing_scdm_backend(reason="missing-spaceclaim", message="still missing")
QApplication.processEvents()
_assert(len(probe.prompt_calls) == 1, f"SCDM prompt should be guarded against repeated popups: {probe.prompt_calls}")
def _assert_property_ui_reroute_guards() -> None:
probe = _PropertyUiRerouteProbe()
probe._refresh_property_editor()
probe._clear_property_editor()
probe._update_current_capability_panel()
probe._sync_scdm_selection_diagnostics()
probe._update_relation_formula_completions()
probe._update_property_apply_state()
_assert(
probe.rerouted_callbacks == 6,
f"property UI entry points should reroute before touching widgets: {probe.rerouted_callbacks}",
)
def _function_text(path: Path, name: str) -> str:
text = path.read_text(encoding="utf-8")
marker = f"def {name}"
start = text.find(marker)
_assert(start >= 0, f"missing function {name} in {path}")
tail = text[start + len(marker) :]
match = re.search(r"\n (?:@Slot\([^\n]*\)\n )?def ", tail)
end = start + len(marker) + match.start() if match else len(text)
return text[start:end]
def _assert_worker_ui_callbacks_guarded() -> None:
targets = {
"step_editor/window_core.py": (
"_finish_scdm_probe_preload",
"_fail_scdm_probe_preload",
"_finish_asitus_hole_recognition",
"_fail_asitus_hole_recognition",
"_finish_initial_load",
"_fail_initial_load",
"_finish_deferred_edge_display",
"_fail_deferred_edge_display",
"_finish_load_refine",
"_fail_load_refine",
),
"step_editor/window_actions.py": (
"_finish_scan_task_result",
"_fail_scan_task_result",
"_finish_edit_action",
"_fail_edit_action",
),
"step_editor/window_state.py": (
"_finish_scdm_edit_action",
"_fail_scdm_edit_action",
"_finish_pending_scdm_edit_reload",
"_fail_pending_scdm_edit_reload",
),
}
for relative_path, names in targets.items():
path = PROJECT_ROOT / relative_path
for name in names:
body = _function_text(path, name)
header = body[:420]
_assert(
"_reroute_to_ui_thread" in header or "_is_ui_thread" in header,
f"{relative_path}:{name} should reroute to the UI thread before touching widgets",
)
def _assert_large_model_preload_stays_lightweight() -> None:
model_body = _function_text(PROJECT_ROOT / "step_editor/model.py", "scdm_local_face_signatures")
_assert("quick_face_info" not in model_body, "SCDM local Face signatures must not run full Face recognition")
_assert("SurfaceProperties" not in model_body, "SCDM local Face signatures should avoid full area/center integration")
quick_cylinder_body = _function_text(PROJECT_ROOT / "step_editor/model.py", "_quick_cylindrical_feature_hint")
internal_graph_body = _function_text(PROJECT_ROOT / "step_editor/model.py", "_can_use_internal_recognition_graph")
_assert(
"len(self.faces) <= 1000" in internal_graph_body,
"large models should not synchronously build the internal full recognition graph",
)
_assert(
"connected_same_domain_face_ids" not in quick_cylinder_body,
"quick cylinder selection must not trigger full same-domain/recognition graph expansion",
)
_assert(
"_connected_cocylindrical_face_ids" in quick_cylinder_body,
"quick cylinder selection should only merge directly connected co-cylindrical fragments",
)
window_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_scdm_local_face_signatures")
_assert("scdm_local_face_signatures" in window_body, "window SCDM preload should use the lightweight model signature API")
_assert("quick_face_info" not in window_body, "window SCDM preload must not call quick_face_info for every Face")
loaded_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_apply_loaded_model_result")
_assert(
"_restore_scdm_feature_cache_from_disk()" in loaded_body,
"STEP load should restore a matching SCDM disk cache before launching a new probe",
)
_assert(
"_defer_large_model_recognition_preloads" in loaded_body,
"large model loads should defer full external recognition preloads by default",
)
_assert(
"_start_scdm_probe_preload(force=pending_scdm_reload)" in loaded_body,
"SCDM edit-result reloads should still be able to force cache refresh for validation",
)
preload_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_start_scdm_probe_preload")
_assert(
"_current_scdm_feature_cache_matches_loaded_step()" in preload_body,
"SCDM preload should skip relaunching SpaceClaim when a current cache is already installed",
)
_assert(
"local_face_signatures = self._scdm_local_face_signatures()" not in preload_body,
"SCDM preload must not build all local Face signatures on the UI thread",
)
_assert(
"force: bool = False" in preload_body
and "_large_model_interaction_mode()" in preload_body
and "_defer_large_model_recognition_preloads()" in preload_body,
"large-model SCDM preload should be deferred unless a validation path forces it",
)
_assert(
'builder = getattr(model, "scdm_local_face_signatures", None)' in preload_body,
"SCDM preload should build local Face signatures inside the background worker",
)
status_body = _function_text(PROJECT_ROOT / "step_editor/scdm_status.py", "summarize_scdm_runtime")
_assert('state == "deferred"' in status_body, "SCDM status should explain deferred large-model recognition")
def _assert_large_model_selection_stays_lightweight() -> None:
selection_body = _function_text(PROJECT_ROOT / "step_editor/window_state.py", "_feature_info_for_selected_face")
_assert(
"_should_defer_selection_first_level_topology" in selection_body,
"large-model selection should defer full first-level topology expansion",
)
level_body = _function_text(PROJECT_ROOT / "step_editor/window_state.py", "_selection_feature_detection_level")
_assert(
"_large_model_interaction_mode" in level_body and '"current-only"' in level_body,
"large-model feature clicks should force the quick current-feature detection level",
)
defer_body = _function_text(PROJECT_ROOT / "step_editor/window_state.py", "_should_defer_selection_first_level_topology")
_assert(
"return True" in defer_body,
"large-model selection should defer first-level topology even when the combo requests deeper detection",
)
_assert(
"_quick_face_first_level_selection_fields" in selection_body,
"large-model selection should use a quick first-level summary",
)
_assert(
"connected_same_domain_face_ids" not in selection_body
and "_connected_cocylindrical_face_ids" in selection_body,
"large-model feature selection should not trigger full same-domain expansion for cylinders",
)
quick_body = _function_text(PROJECT_ROOT / "step_editor/window_state.py", "_quick_face_first_level_selection_fields")
_assert(
"face_first_level_topology" not in quick_body and "face_first_level_facts" not in quick_body,
"quick large-model selection summary must not run full topology/fact graph builders",
)
loaded_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_apply_loaded_model_result")
_assert("large_interaction_model" in loaded_body, "large models should be detected after load")
_assert(
"hide_edges_during_camera_interaction" in loaded_body,
"large models should hide edge overlay during camera interaction",
)
_assert(
"large_model_edge_overlay_skipped = True" in loaded_body,
"large models should skip deferred full-edge overlay during default viewing",
)
_assert(
"large_model_hover_disabled = large_interaction_model" in loaded_body,
"large models should disable hover picking/highlighting to avoid pointer stalls",
)
rebuild_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_rebuild_scene")
_assert(
"_empty_edge_polydata()" in rebuild_body and "large_model_edge_overlay_skipped" in rebuild_body,
"large-model scene rebuilds should keep edge overlay lazy by default",
)
mode_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_on_mode_changed")
_assert(
"_rebuild_deferred_edge_display" in mode_body and '"Edge"' in mode_body,
"Edge selection mode should restore deferred edge display on demand",
)
hover_body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_queue_hover_position")
_assert(
"large_model_hover_disabled" in hover_body,
"large-model hover picking should be suppressed before the VTK picker runs",
)
action_body = _function_text(PROJECT_ROOT / "step_editor/window_actions.py", "_isolation_for_plan")
_assert(
"_prefer_isolated_process_for_large_interactive_edit" in action_body
and "large-model-smooth-ui-isolated-occ-edit" in action_body,
"large complex push/pull rebuilds should use an independent background process for smoother interaction",
)
job_body = _function_text(PROJECT_ROOT / "step_editor/window_actions.py", "_make_edit_job")
_assert(
"skip_before_quality_check" in job_body,
"large-model edit jobs should be able to skip the pre-edit full B-Rep check while preserving post-edit validation",
)
finish_body = _function_text(PROJECT_ROOT / "step_editor/window_actions.py", "_finish_edit_action")
_assert(
"large_model_edge_overlay_skipped" in finish_body
and 'not bool(getattr(self, "large_model_edge_overlay_skipped", False))' in finish_body,
"large-model edit finish should not automatically rebuild full edge overlay",
)
def _assert_large_planar_offset_prefers_local_backend() -> None:
probe = _PropertyTableProbe()
probe.model = _LargeDisplayModel()
probe.selected_kind = "feature"
probe.selected_face_id = 0
probe.scdm_feature_cache_state = "ready"
probe.scdm_feature_cache = {
"objects": [
{
"objectId": "face:0",
"objectType": "face",
"geometrySignature": {"objectType": "face", "faceIds": [0], "surfaceType": "plane"},
"capabilities": [
{
"key": "face.offset",
"displayName": "偏移",
"currentValue": 57.5,
"editable": True,
"defaultIntent": "推拉平面",
"backendOperation": "pull_face_offset",
"postCheck": "target_face_offset",
}
],
}
]
}
action_info = probe._selected_action_info()
action_info.update({"inner_boundary_wires": 5, "boundary_edges": 61})
specs = probe._property_editor_specs(action_info, action_info)
_assert(specs, "large planar Face should still expose local editable specs")
_assert(
all(str(spec.get("action") or "") != "apply_scdm_property_edit" for spec in specs),
f"large multi-boundary planar offset should not route to SCDM: {specs}",
)
offset = next((spec for spec in specs if str(spec.get("key") or "") == "face_target_normal_position"), None)
_assert(isinstance(offset, dict), f"local Face offset spec should be present: {specs}")
_assert(
str(offset.get("action") or "") in {"push_pull_face", "push_pull_face_keep_relations"},
f"local Face offset should use optimized OCCT path: {offset}",
)
_assert("SCDM" in str(probe.scdm_selection_status_message), "backend preference should explain that SCDM was bypassed")
def _assert_scdm_target_values_use_backend_units() -> None:
probe = _PropertyTableProbe()
offset = probe._scdm_property_target_value(
{"scdm_capability_key": "face.offset", "value_type": "number", "scdm_unit_scale": 0.001},
"2.5",
)
_assert(abs(float(offset) - 0.0025) <= 1.0e-12, f"SCDM numeric targets should be converted back to backend units: {offset}")
position = probe._scdm_property_target_value(
{"scdm_capability_key": "hole.position", "value_type": "vector3", "scdm_unit_scale": 0.001},
"(1, 2, 3)",
)
_assert(position == [0.001, 0.002, 0.003], f"SCDM vector targets should be converted back to backend units: {position}")
spacing_spec = {"label": "阵列间距", "value_type": "positive", "max_value": 1.3571428571428572}
_assert(
not probe._property_target_validation_error(spacing_spec, "1.1"),
"pattern spacing targets within the support face range should be accepted",
)
spacing_error = probe._property_target_validation_error(spacing_spec, "2")
_assert(
"阵列间距" in spacing_error and "1.35714" in spacing_error,
f"pattern spacing beyond the support face range should be blocked in the UI: {spacing_error}",
)
def _assert_selection_mode_labels_are_english() -> None:
expected = {
"Feature": "Feature",
"Face": "Face",
"Edge": "Edge",
"Solid": "Solid",
"Part": "Part",
}
for value, label in expected.items():
_assert(_selection_mode_label(value) == label, f"selection mode {value} should display as English: {_selection_mode_label(value)}")
_assert(_selection_mode_value(label) == value, f"selection mode label {label} should resolve to {value}")
for legacy, value in {
"智能特征": "Feature",
"特征": "Feature",
"面": "Face",
"边": "Edge",
"实体": "Solid",
"零件": "Part",
"装配零件": "Part",
}.items():
_assert(_selection_mode_value(legacy) == value, f"legacy selection label {legacy} should resolve to {value}")
def _assert_background_load_uses_worker() -> None:
body = _function_text(PROJECT_ROOT / "step_editor/window_core.py", "_load_step_background_or_sync")
_assert("LoadWorker(action)" in body, "background STEP load should run through LoadWorker")
_assert("worker.moveToThread(thread)" in body, "background STEP load worker should move to QThread")
_assert("_finish_initial_load" in body and "_fail_initial_load" in body, "background STEP load should finish through queued callbacks")
_assert(
"_run_deferred_initial_load(path)" not in body,
"background STEP load must not fall back to main-thread deferred loading",
)
def main() -> int:
app = QApplication.instance() or QApplication([])
top_level_label_probe = _TopLevelPropertyLabelProbe()
app.installEventFilter(top_level_label_probe)
probe = _PropertyTableProbe()
probe.show()
QApplication.processEvents()
probe._refresh_property_editor()
QApplication.processEvents()
_assert(
not top_level_label_probe.shown_labels,
f"property labels were shown as transient top-level windows: {top_level_label_probe.shown_labels}",
)
_assert_scdm_target_values_use_backend_units()
_assert_selection_mode_labels_are_english()
_assert_property_table_editor(probe)
_assert(probe.current_capability_button.text() == "软件进度", "software progress should be a compact button")
_assert("当前支持" in probe.current_capability_button.toolTip(), "software progress button tooltip did not show supported areas")
_assert("优先:" not in probe.current_capability_button.toolTip(), "software progress button tooltip should not show priority copy")
_assert(
"能改:" in probe.current_capability_button.toolTip()
and "能识别:" in probe.current_capability_button.toolTip()
and "暂不能:" in probe.current_capability_button.toolTip(),
f"software progress tooltip should be customer-facing capability copy: {probe.current_capability_button.toolTip()}",
)
_assert("当前 cache" not in probe.current_capability_button.toolTip(), "software progress tooltip should hide cache internals")
_assert("SCDM 能力:" not in probe.current_capability_button.toolTip(), "software progress tooltip should hide SCDM internals")
_assert(not hasattr(probe, "configure_scdm_button"), "software progress should not expose a persistent SCDM configure button")
progress_detail = probe._software_progress_detail_text()
_assert("# 软件进度" in progress_detail, "software progress dialog text should be Markdown-like")
_assert("## 已能修改" in progress_detail, "software progress dialog text should list editable capabilities first")
_assert("## 已能识别" in progress_detail, "software progress dialog text should list recognized capabilities")
_assert("## 暂不能修改" in progress_detail, "software progress dialog text should list unsupported edits")
_assert("## 暂不能稳定识别" in progress_detail, "software progress dialog text should list unsupported recognition")
_assert("孔组" in progress_detail and "一级关系" in progress_detail, "software progress dialog text should include recognition scope")
_assert("B-Rep 校验" in progress_detail and "原 CAD 历史树" in progress_detail, "software progress dialog text should explain limits")
_assert("SCDM-first 路线状态" not in progress_detail, "software progress dialog text should hide roadmap internals")
_assert("SCDM 能力报告" not in progress_detail, "software progress dialog text should hide dynamic SCDM internals")
probe.scdm_backend_status = {
"path": "D:/softwaresInstallDir/ANSYS Inc/v222/SCDM/SpaceClaim.exe",
"source": "common:D:/softwaresInstallDir/ANSYS Inc",
"version": "v222",
"runScriptOk": True,
"licenseOk": True,
}
probe.scdm_feature_cache_state = "ready"
probe.scdm_feature_cache = {
"objects": [
{"objectId": "face:1", "capabilities": [{"key": "face.offset"}]},
{"objectId": "hole:1", "capabilities": [{"key": "hole.diameter"}, {"key": "hole.position"}]},
],
"diagnostics": {
"geometry_candidate_hints": [
{"capabilityKey": "boss.height", "displayName": "凸台高度", "evidenceCount": 2, "confidence": "low"}
]
},
}
probe._update_current_capability_panel()
configured_detail = probe._software_progress_detail_text()
_assert("已配置 v222" not in configured_detail, f"SCDM progress detail should hide backend version: {configured_detail}")
_assert("当前 cache 可执行" not in configured_detail, f"SCDM progress detail should hide cache count: {configured_detail}")
_assert("2 个对象" not in probe.current_capability_button.toolTip(), f"SCDM cache status should stay out of tooltip: {probe.current_capability_button.toolTip()}")
_assert("SCDM 能力:" not in probe.current_capability_button.toolTip(), f"SCDM progress tooltip should hide capability counters: {probe.current_capability_button.toolTip()}")
_assert("几何证据" not in probe.current_capability_button.toolTip(), f"SCDM progress tooltip should hide geometry hint counts: {probe.current_capability_button.toolTip()}")
_assert("凸台高度:几何证据待分类" not in configured_detail, f"SCDM detail should hide geometry-only hints: {configured_detail}")
_assert_diagnostics_stay_out_of_parameter_table(probe)
_assert_relation_formula_editor()
_assert_relation_radius_formula_proxy()
_assert_relation_formula_input_remains_editable_while_replaying()
_assert_relation_formula_input_clickable_after_existing_formula()
_assert_relation_formula_input_is_selection_independent()
_assert_relation_formula_input_recovers_after_loading()
_assert_relation_formula_ids_follow_model_remap()
_assert_mouse_selection_guards()
_assert_scdm_selection_diagnostics()
_assert_solid_selection_does_not_expand_face_scdm_specs()
_assert_operation_record_backend_sources()
_assert_scdm_auto_prompt()
_assert_property_ui_reroute_guards()
_assert_worker_ui_callbacks_guarded()
_assert_large_model_preload_stays_lightweight()
_assert_large_model_selection_stays_lightweight()
_assert_large_planar_offset_prefers_local_backend()
_assert_background_load_uses_worker()
_assert_quick_blind_depth_spec()
_assert_user_facing_failure_messages()
_assert_parameter_export_action()
_assert_scdm_command_row_uses_unified_apply()
print("property table editor UI ok")
if QApplication.instance() is app:
app.quit()
return 0
if __name__ == "__main__":
raise SystemExit(main())