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())