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 from PySide6.QtWidgets import ( QApplication, QCheckBox, QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QTableWidget, 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.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, 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.selected_kind = "feature" self.selected_part_id = None self.selected_solid_id = None self.selected_face_id = 0 self.selected_edge_id = None self.current_info_values = self._plane_info() 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() @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, "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 statusBar(self) -> _StatusBar: return _StatusBar() class _ActionMessageProbe(WindowActionMixin): pass class _ParameterExportActionProbe(WindowActionMixin): def __init__(self, output_path: Path) -> None: self.output_path = output_path 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 _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}", ) 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") 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_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), "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_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")) _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(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, "parameter export info panel summary should be useful") 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_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())