from __future__ import annotations import json import math import os from pathlib import Path import sys import tempfile 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, ) 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]] = [] 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)) 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_headline = 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 statusBar(self) -> _StatusBar: return _StatusBar() class _RelationFormulaEventProbe(WindowCoreMixin, _PropertyTableProbe): pass 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 _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_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() 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", ) 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 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_property_table_editor(probe) _assert("当前支持" in probe.current_capability_headline.text(), "software progress panel did not show supported areas") _assert("优先:" not in probe.current_capability_headline.text(), "software progress panel should not show priority copy") _assert( "矩形槽口袋" in probe.current_capability_headline.toolTip() and "多台阶矩形凸台顶层" in probe.current_capability_headline.toolTip(), "software progress tooltip should name newly supported prismatic feature edits", ) _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_quick_blind_depth_spec() _assert_user_facing_failure_messages() _assert_parameter_export_action() print("property table editor UI ok") if QApplication.instance() is app: app.quit() return 0 if __name__ == "__main__": raise SystemExit(main())