2026-08-06 18:15:14 +08:00
from __future__ import annotations
2026-08-11 18:28:16 +08:00
import json
2026-08-07 18:08:32 +08:00
import math
2026-08-06 18:15:14 +08:00
import os
from pathlib import Path
2026-08-19 10:28:09 +08:00
import re
2026-08-06 18:15:14 +08:00
import sys
2026-08-11 18:28:16 +08:00
import tempfile
2026-08-19 10:28:09 +08:00
from types import SimpleNamespace
2026-08-06 18:15:14 +08:00
os . environ . setdefault ( "QT_QPA_PLATFORM" , "offscreen" )
2026-08-17 18:53:03 +08:00
from PySide6.QtCore import QEvent , QObject , Qt , QStringListModel
from PySide6.QtGui import QKeyEvent
from PySide6.QtTest import QTest
2026-08-06 18:15:14 +08:00
from PySide6.QtWidgets import (
QApplication ,
2026-08-11 18:28:16 +08:00
QCheckBox ,
2026-08-14 18:42:39 +08:00
QCompleter ,
2026-08-06 18:15:14 +08:00
QFrame ,
QHBoxLayout ,
QLabel ,
QLineEdit ,
2026-08-14 18:42:39 +08:00
QListWidget ,
2026-08-06 18:15:14 +08:00
QPushButton ,
QScrollArea ,
QTableWidget ,
2026-08-17 18:53:03 +08:00
QTableWidgetItem ,
2026-08-06 18:15:14 +08:00
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
2026-08-17 18:53:03 +08:00
from step_editor.relation_formulas import ObjectParameterRef , parse_relation_formula
2026-08-20 17:12:01 +08:00
from step_editor.scdm_feature_mapper import SCDM_FEATURE_CACHE_REVISION
from step_editor.scdm_schema import file_fingerprint
2026-08-07 18:08:32 +08:00
from step_editor.window_actions import WindowActionMixin
from step_editor.window_core import WindowCoreMixin
from step_editor.window_state import (
PROPERTY_CURRENT_COLUMN ,
2026-08-11 18:28:16 +08:00
PROPERTY_INPUT_COLUMN ,
2026-08-07 18:08:32 +08:00
PROPERTY_LABEL_COLUMN ,
PROPERTY_SCOPE_COLUMN ,
2026-08-11 18:28:16 +08:00
PROPERTY_TABLE_HEADERS ,
2026-08-07 18:08:32 +08:00
PROPERTY_TARGET_COLUMN ,
2026-08-17 18:53:03 +08:00
RELATION_FORMULA_OBJECT_COMPLETION_LIMIT ,
2026-08-07 18:08:32 +08:00
WindowStateMixin ,
)
2026-08-19 18:02:47 +08:00
from step_editor.ui_helpers import _selection_mode_label , _selection_mode_value
2026-08-06 18:15:14 +08:00
class _StatusBar :
def showMessage ( self , _text : str ) -> None :
pass
2026-08-07 18:08:32 +08:00
class _PropertyTableProbe ( QWidget , WindowStateMixin ):
2026-08-06 18:15:14 +08:00
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
2026-08-07 18:08:32 +08:00
self . property_table_collapsed_rows = 5
self . property_table_min_visible_rows = 5
2026-08-06 18:15:14 +08:00
self . property_editor_selected_row = None
self . property_command_active_key = ""
self . property_command_buttons = {}
self . property_editor_specs = []
2026-08-14 18:42:39 +08:00
self . relation_formula_items = []
self . relation_formula_next_id = 1
2026-08-07 18:08:32 +08:00
self . selected_kind = "feature"
2026-08-06 18:15:14 +08:00
self . selected_part_id = None
self . selected_solid_id = None
self . selected_face_id = 0
self . selected_edge_id = None
2026-08-17 18:53:03 +08:00
self . selected_pick_position = None
2026-08-13 17:50:20 +08:00
self . step_path = PROJECT_ROOT / "assets" / "models" / "probe.step"
2026-08-07 18:08:32 +08:00
self . current_info_values = self . _plane_info ()
2026-08-13 17:50:20 +08:00
self . executed_property_actions : list [ tuple [ str , str ]] = []
2026-08-19 10:28:09 +08:00
self . scdm_backend_status = None
self . scdm_feature_cache = None
self . scdm_feature_cache_state = "empty"
self . scdm_feature_cache_message = ""
2026-08-19 18:02:47 +08:00
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" ,
2026-08-20 17:12:01 +08:00
"pattern.instance_position" ,
"shell.thickness" ,
2026-08-19 18:02:47 +08:00
}
2026-08-07 18:08:32 +08:00
layout = QVBoxLayout ( self )
self . object_edit_box = self
2026-08-11 18:28:16 +08:00
self . property_table = QTableWidget ( 0 , len ( PROPERTY_TABLE_HEADERS ))
self . property_table . setHorizontalHeaderLabels ( list ( PROPERTY_TABLE_HEADERS ))
2026-08-19 10:28:09 +08:00
self . property_table . itemSelectionChanged . connect ( lambda : self . _update_property_apply_state ())
2026-08-07 18:08:32 +08:00
layout . addWidget ( self . property_table )
2026-08-06 18:15:14 +08:00
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 )
2026-08-07 18:08:32 +08:00
layout . addWidget ( self . property_card_scroll )
self . property_expand_button = QPushButton ()
layout . addWidget ( self . property_expand_button )
2026-08-06 18:15:14 +08:00
self . property_command_summary_label = QLabel ()
self . property_command_bar = QFrame ()
self . property_command_layout = QHBoxLayout ( self . property_command_bar )
2026-08-07 18:08:32 +08:00
self . property_command_help_label = QLabel ()
2026-08-19 10:28:09 +08:00
self . current_capability_button = QPushButton ()
self . scdm_backend_status_label = QLabel ()
self . scdm_backend_detail_label = QLabel ()
2026-08-06 18:15:14 +08:00
self . apply_property_button = QPushButton ()
2026-08-11 18:28:16 +08:00
self . export_parameters_button = QPushButton ()
2026-08-14 18:42:39 +08:00
self . relation_formula_input = QLineEdit ()
self . relation_formula_completer_model = QStringListModel ( self )
self . relation_formula_completer = QCompleter ( self . relation_formula_completer_model , self )
2026-08-17 18:53:03 +08:00
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 )
2026-08-14 18:42:39 +08:00
self . add_relation_formula_button = QPushButton ()
2026-08-20 17:12:01 +08:00
self . import_relation_formula_button = QPushButton ()
self . export_relation_formula_button = QPushButton ()
self . toggle_relation_formula_button = QPushButton ()
2026-08-14 18:42:39 +08:00
self . remove_relation_formula_button = QPushButton ()
self . relation_formula_list = QListWidget ()
2026-08-13 17:50:20 +08:00
self . face_width_input = QLineEdit ()
self . face_height_input = QLineEdit ()
2026-08-06 18:15:14 +08:00
2026-08-07 18:08:32 +08:00
@staticmethod
def _plane_info () -> dict [ str , object ]:
2026-08-06 18:15:14 +08:00
return {
"area" : 100.0 ,
2026-08-07 18:08:32 +08:00
"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 ,
2026-08-13 17:50:20 +08:00
"local_face_size_edit_ready" : True ,
"local_face_size_edit_blocker" : "" ,
2026-08-07 18:08:32 +08:00
"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 ,
2026-08-06 18:15:14 +08:00
"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 )
2026-08-17 18:53:03 +08:00
def setTitle ( self , title : str ) -> None :
self . object_edit_title = title
2026-08-13 17:50:20 +08:00
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 )
2026-08-19 10:28:09 +08:00
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 )
2026-08-06 18:15:14 +08:00
def statusBar ( self ) -> _StatusBar :
return _StatusBar ()
2026-08-17 18:53:03 +08:00
class _RelationFormulaEventProbe ( WindowCoreMixin , _PropertyTableProbe ):
pass
2026-08-19 10:28:09 +08:00
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
2026-08-17 18:53:03 +08:00
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" )
2026-08-19 10:28:09 +08:00
class _LargeDisplayModel :
def __init__ ( self ) -> None :
self . faces = [ object () for _index in range ( 1800 )]
self . edges = [ object () for _index in range ( 5000 )]
2026-08-17 18:53:03 +08:00
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
2026-08-07 18:08:32 +08:00
class _ActionMessageProbe ( WindowActionMixin ):
pass
2026-08-11 18:28:16 +08:00
class _ParameterExportActionProbe ( WindowActionMixin ):
def __init__ ( self , output_path : Path ) -> None :
self . output_path = output_path
2026-08-13 17:50:20 +08:00
self . component_path = output_path . parent / "nodes" / "000_test" / "main.py"
2026-08-11 18:28:16 +08:00
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" ,
},
]
2026-08-13 17:50:20 +08:00
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
+ " \n OUTPUT_PARAMETERS = [{'name': 'output_step', 'displayName': '输出STEP', 'type': 'file', 'ioRole': 'output', 'default': ''}]"
+ " \n PARAMETERS = INPUT_PARAMETERS + OUTPUT_PARAMETERS"
+ " \n NODE_INFO = {'parameters': PARAMETERS}"
+ " \n def execute(inputs, params, context): \n return {'output_step': 'modified.step'} \n " ,
encoding = "utf-8" ,
)
return self . component_path
2026-08-11 18:28:16 +08:00
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
2026-08-07 18:08:32 +08:00
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
2026-08-06 18:15:14 +08:00
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 )
2026-08-07 18:08:32 +08:00
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 ())
]
2026-08-11 18:28:16 +08:00
_assert ( headers == list ( PROPERTY_TABLE_HEADERS ), f "unexpected table headers: { headers } " )
2026-08-07 18:08:32 +08:00
_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 ()]
2026-08-11 18:28:16 +08:00
for label in ( "面内长度" , "面内宽度" , "偏移" ):
2026-08-07 18:08:32 +08:00
_assert ( label in actionable_labels , f "feature parameter table did not expose { label } " )
2026-08-11 18:28:16 +08:00
_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" )
2026-08-07 18:08:32 +08:00
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 )
2026-08-11 18:28:16 +08:00
input_checkbox = probe . _property_input_checkbox ( target_row )
2026-08-07 18:08:32 +08:00
_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" )
2026-08-11 18:28:16 +08:00
_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" )
2026-08-07 18:08:32 +08:00
_assert (
not probe . property_table . findChildren ( QPushButton ),
"feature parameter table should not contain per-row apply buttons" ,
)
2026-08-11 18:28:16 +08:00
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 } " ,
)
2026-08-13 17:50:20 +08:00
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 ] } " ,
)
2026-08-11 18:28:16 +08:00
2026-08-07 18:08:32 +08:00
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" )
2026-08-13 17:50:20 +08:00
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" )
2026-08-07 18:08:32 +08:00
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" )
2026-08-19 10:28:09 +08:00
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 } " ,
)
2026-08-07 18:08:32 +08:00
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 ,
}
2026-08-06 18:15:14 +08:00
probe . _refresh_property_editor ()
QApplication . processEvents ()
2026-08-07 18:08:32 +08:00
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" )
2026-08-14 18:42:39 +08:00
def _assert_relation_formula_editor () -> None :
probe = _PropertyTableProbe ()
probe . _refresh_property_editor ()
2026-08-19 10:28:09 +08:00
_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 ()
2026-08-14 18:42:39 +08:00
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 } " )
2026-08-17 18:53:03 +08:00
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" )
2026-08-14 18:42:39 +08:00
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 ()
2026-08-17 18:53:03 +08:00
if not getattr ( probe , "relation_formula_replay_active" , False ):
2026-08-14 18:42:39 +08:00
break
_assert (
probe . executed_property_actions == [( "resize_face_height_local" , "12" )],
2026-08-17 18:53:03 +08:00
f "formula should immediately fill target value and execute the existing row action: { probe . executed_property_actions } " ,
2026-08-14 18:42:39 +08:00
)
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" )
2026-08-17 18:53:03 +08:00
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" )
2026-08-20 17:12:01 +08:00
def _assert_relation_formula_can_toggle_without_deleting () -> None :
probe = _PropertyTableProbe ()
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 toggling: { probe . relation_formula_items } " )
_assert ( probe . relation_formula_list . count () == 1 , "formula list should show the formula before toggling" )
probe . relation_formula_list . setCurrentRow ( 0 )
probe . _update_relation_formula_buttons ()
_assert ( probe . toggle_relation_formula_button . isEnabled (), "selected formula should enable the toggle button" )
_assert ( probe . toggle_relation_formula_button . text () == "停用公式" , f "enabled formula should offer disable action: { probe . toggle_relation_formula_button . text () } " )
restore_calls = { "count" : 0 }
def _restore_base () -> bool :
restore_calls [ "count" ] += 1
return True
probe . relation_formula_base_snapshot = { "baseline" : True }
probe . _restore_relation_formula_base_snapshot = _restore_base
probe . toggle_selected_relation_formula ()
_assert ( restore_calls [ "count" ] == 1 , "disabling the last active formula should restore the formula base snapshot" )
_assert ( probe . relation_formula_items [ 0 ] . get ( "enabled" ) is False , f "formula should be disabled, not deleted: { probe . relation_formula_items } " )
_assert ( "停用" in probe . relation_formula_list . item ( 0 ) . text (), f "disabled formula should be visibly marked: { probe . relation_formula_list . item ( 0 ) . text () } " )
_assert ( not getattr ( probe , "relation_formula_replay_active" , False ), "disabling all formulas should not leave replay active" )
probe . relation_formula_list . setCurrentRow ( 0 )
probe . _update_relation_formula_buttons ()
_assert ( probe . toggle_relation_formula_button . text () == "启用公式" , f "disabled formula should offer enable action: { probe . toggle_relation_formula_button . text () } " )
probe . toggle_selected_relation_formula ()
for _index in range ( 12 ):
QApplication . processEvents ()
if not getattr ( probe , "relation_formula_replay_active" , False ):
break
_assert ( probe . relation_formula_items [ 0 ] . get ( "enabled" ) is True , f "formula should be re-enabled: { probe . relation_formula_items } " )
_assert ( str ( probe . relation_formula_items [ 0 ] . get ( "status" )) in { "applied" , "ready" }, f "enabled formula should return to a computable state: { probe . relation_formula_items } " )
probe . relation_formula_list . setCurrentRow ( 0 )
probe . _update_relation_formula_buttons ()
_assert ( probe . toggle_relation_formula_button . text () == "停用公式" , "re-enabled formula should offer disable action again" )
def _assert_relation_formula_import_export () -> None :
with tempfile . TemporaryDirectory () as tmp_dir :
root = Path ( tmp_dir )
probe = _PropertyTableProbe ()
probe . relation_formula_items = [
{
"id" : 7 ,
"text" : "Face87.直径 = Face85.半径" ,
"enabled" : True ,
"status" : "applied" ,
"message" : "已修改模型。" ,
}
]
export_path = root / "relation_formulas.json"
_assert ( probe . export_relation_formulas ( export_path ), "relation formula export should succeed" )
payload = json . loads ( export_path . read_text ( encoding = "utf-8" ))
_assert ( payload . get ( "schema" ) == "python-occt.relation-formulas.v1" , f "export schema missing: { payload } " )
formulas = payload . get ( "formulas" )
_assert ( isinstance ( formulas , list ) and len ( formulas ) == 1 , f "export should contain one formula: { payload } " )
_assert (
formulas [ 0 ] . get ( "text" ) == "Face87.直径 = Face85.半径" and formulas [ 0 ] . get ( "enabled" ) is True ,
f "exported formula row is wrong: { formulas } " ,
)
import_path = root / "import_formulas.json"
import_path . write_text (
json . dumps (
{
"formulas" : [
{ "text" : "Face87.直径 = 10mm" , "enabled" : True },
{ "text" : "Face85.直径 = Face87.半径" , "enabled" : False },
]
},
ensure_ascii = False ,
),
encoding = "utf-8" ,
)
reapply_calls : list [ dict [ str , object ]] = []
probe . _start_relation_formula_reapply = lambda ** kwargs : reapply_calls . append ( dict ( kwargs ))
_assert ( probe . import_relation_formulas ( import_path ), "relation formula import should succeed" )
_assert ( len ( probe . relation_formula_items ) == 2 , f "import should replace the formula set: { probe . relation_formula_items } " )
_assert ( probe . relation_formula_items [ 0 ] . get ( "text" ) == "Face87.直径 = 10mm" , f "import should normalize first formula: { probe . relation_formula_items } " )
_assert ( probe . relation_formula_items [ 1 ] . get ( "enabled" ) is False , f "import should preserve disabled state: { probe . relation_formula_items } " )
_assert ( reapply_calls and reapply_calls [ - 1 ] . get ( "reason" ) == "import" , f "enabled imported formulas should trigger reapply: { reapply_calls } " )
previous_items = [ dict ( item ) for item in probe . relation_formula_items ]
invalid_path = root / "invalid_formulas.json"
invalid_path . write_text (
json . dumps ({ "formulas" : [ "Face1.直径 = 1" , "Face1.直径 = 2" ]}, ensure_ascii = False ),
encoding = "utf-8" ,
)
_assert ( not probe . import_relation_formulas ( invalid_path ), "invalid formula groups should be rejected" )
_assert ( probe . relation_formula_items == previous_items , "failed import should not alter the current formula set" )
def _assert_scdm_first_holds_ambiguous_large_cylinders () -> None :
probe = _PropertyTableProbe ()
probe . selected_kind = "feature"
probe . selected_face_id = 1722
probe . scdm_feature_cache = None
probe . scdm_feature_cache_state = "deferred"
probe . _large_model_interaction_mode = lambda : True
def _cylinder_action_info () -> dict [ str , object ]:
return {
"surface" : "cylinder" ,
"feature_guess" : "hole/groove candidate" ,
"feature_type" : "圆柱孔候选" ,
"diameter" : 2.6 ,
"radius" : 1.3 ,
"axis" : ( 0.0 , 1.0 , 0.0 ),
"axis_point" : ( - 83.0 , - 31.0 , - 116.35 ),
"part_id" : 1 ,
"solid_id" : 0 ,
}
probe . current_info_values = _cylinder_action_info ()
probe . _selected_action_info = _cylinder_action_info
probe . _refresh_property_editor ()
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
]
_assert ( not labels , f "SCDM-first large-model cylinder should not expose local hole parameters while cache is deferred: { labels } " )
_assert (
"不开放本地兜底孔/槽参数" in str ( getattr ( probe , "scdm_selection_status_message" , "" )),
f "SCDM-first hold should explain why local cylinder specs are hidden: { getattr ( probe , 'scdm_selection_status_message' , '' ) } " ,
)
2026-08-17 18:53:03 +08:00
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 = []
2026-08-14 18:42:39 +08:00
2026-08-07 18:08:32 +08:00
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 )
2026-08-06 18:15:14 +08:00
_assert (
2026-08-07 18:08:32 +08:00
[ target . get ( "target_id" ) for target in mouse_probe . selected_targets ] == [ 1 ],
"plain left click should still select" ,
2026-08-06 18:15:14 +08:00
)
2026-08-07 18:08:32 +08:00
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" )
2026-08-06 18:15:14 +08:00
2026-08-07 18:08:32 +08:00
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 )
2026-08-06 18:15:14 +08:00
_assert (
2026-08-07 18:08:32 +08:00
not mouse_probe . selected_targets ,
"left-button camera rotation should not select a face even when the cursor lands on the model" ,
2026-08-06 18:15:14 +08:00
)
2026-08-07 18:08:32 +08:00
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 )
2026-08-06 18:15:14 +08:00
_assert (
2026-08-07 18:08:32 +08:00
not mouse_probe . selected_targets ,
"left-button press on the background should not select a face on release" ,
2026-08-06 18:15:14 +08:00
)
2026-08-07 18:08:32 +08:00
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 )
2026-08-06 18:15:14 +08:00
_assert (
2026-08-07 18:08:32 +08:00
not mouse_probe . selected_targets ,
"left-button press/release on different faces should not change selection" ,
2026-08-06 18:15:14 +08:00
)
2026-08-19 10:28:09 +08:00
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" ,
)
2026-08-06 18:15:14 +08:00
2026-08-07 18:08:32 +08:00
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 ),
2026-08-14 18:42:39 +08:00
"axis_center" : ( 0.0 , 0.0 , 3.0 ),
2026-08-07 18:08:32 +08:00
"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 )
2026-08-14 18:42:39 +08:00
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 } " ,
)
2026-08-07 18:08:32 +08:00
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" ,
)
2026-08-06 18:15:14 +08:00
2026-08-07 18:08:32 +08:00
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 )
2026-08-06 18:15:14 +08:00
_assert (
2026-08-07 18:08:32 +08:00
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" : "当前平面没有识别到相对壳体平面。" }
2026-08-06 18:15:14 +08:00
)
_assert (
2026-08-07 18:08:32 +08:00
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" : "当前版本只对简单圆锥解析重建开放这类修改。" }
2026-08-06 18:15:14 +08:00
)
_assert (
2026-08-07 18:08:32 +08:00
unsupported_blocker is not None and unsupported_blocker [ 0 ] == "暂未实现" ,
"blocked unsupported capability plans should be classified" ,
2026-08-06 18:15:14 +08:00
)
2026-08-07 18:08:32 +08:00
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" )
2026-08-06 18:15:14 +08:00
2026-08-07 18:08:32 +08:00
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" )
2026-08-06 18:15:14 +08:00
2026-08-07 18:08:32 +08:00
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" )
2026-08-06 18:15:14 +08:00
2026-08-11 18:28:16 +08:00
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" ))
2026-08-13 17:50:20 +08:00
component_text = probe . component_path . read_text ( encoding = "utf-8" )
2026-08-11 18:28:16 +08:00
_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 } " ,
)
2026-08-13 17:50:20 +08:00
_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" ,
)
2026-08-11 18:28:16 +08:00
2026-08-19 10:28:09 +08:00
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 } " )
2026-08-20 17:12:01 +08:00
def _assert_scdm_parameter_table_shows_only_enabled_specs () -> None :
probe = _PropertyTableProbe ()
probe . selected_kind = "face"
probe . selected_face_id = 31
probe . scdm_feature_cache_state = "ready"
probe . scdm_feature_cache = {
"objects" : [
{
"objectId" : "slot:31" ,
"objectType" : "slot" ,
"geometrySignature" : { "faceIds" : [ 31 ], "objectType" : "slot" },
"capabilities" : [
{
"key" : "slot.width" ,
"displayName" : "槽宽" ,
"currentValue" : 2.0 ,
"valueKind" : "positive" ,
"defaultIntent" : "改槽宽" ,
"backendOperation" : "change_slot_width" ,
"postCheck" : "target_slot_width" ,
},
{
"key" : "slot.depth" ,
"displayName" : "槽深" ,
"currentValue" : 1.5 ,
"valueKind" : "positive" ,
"defaultIntent" : "改槽深" ,
"backendOperation" : "change_slot_depth" ,
"postCheck" : "target_slot_depth" ,
},
],
}
]
}
def labels () -> list [ str ]:
return [
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
]
probe . scdm_edit_runner_ready = set ()
probe . _refresh_property_editor ()
disabled_labels = labels ()
_assert ( "槽宽" not in disabled_labels and "槽深" not in disabled_labels , f "disabled SCDM specs should stay out of the parameter table: { disabled_labels } " )
_assert (
"当前能力未开放" in str ( probe . current_info_values . get ( "scdm_selection_status" ) or "" ),
f "disabled SCDM specs should remain visible as a diagnostic reason: { probe . current_info_values } " ,
)
probe . scdm_edit_runner_ready = { "slot.width" , "slot.depth" }
probe . _refresh_property_editor ()
enabled_labels = labels ()
_assert ( enabled_labels == [ "槽宽" , "槽深" ], f "enabled SCDM specs should replace local fallback rows: { enabled_labels } " )
_assert (
all ( bool ( spec . get ( "enabled" )) for spec in probe . property_editor_specs ),
f "property table should only hold executable SCDM rows: { probe . property_editor_specs } " ,
)
def _assert_ambiguous_slot_empty_state_explains_missing_params () -> None :
probe = _PropertyTableProbe ()
probe . selected_kind = "feature"
probe . selected_face_id = 1362
probe . scdm_feature_cache_state = "ready"
probe . scdm_feature_cache = { "objects" : []}
slot_candidate_info = {
"surface" : "cylinder" ,
"feature_guess" : "hole/groove candidate" ,
"feature_type" : "圆柱孔/槽候选" ,
"recognition_confidence" : "medium" ,
"recognition_score" : 62 ,
"diameter" : 2.6 ,
"radius" : 1.3 ,
"angular_span" : 3.141592653589793 ,
"part_id" : 1 ,
"solid_id" : 0 ,
}
probe . current_info_values = dict ( slot_candidate_info )
probe . _selected_action_info = lambda : dict ( slot_candidate_info )
probe . _refresh_property_editor ()
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
]
_assert ( "槽宽" not in labels and "槽深" not in labels , f "ambiguous slot candidates should not expose fake slot params: { labels } " )
_assert ( "可修改参数" in labels , f "ambiguous slot should keep an empty-state row: { labels } " )
status = str ( probe . current_info_values . get ( "scdm_selection_status" ) or "" )
_assert (
"圆柱孔/槽候选" in status and "证据完整" in status ,
f "ambiguous slot empty state should explain the missing editable evidence: { probe . current_info_values } " ,
)
def _assert_scdm_round_delete_conflict_filtered_for_blind_pocket () -> None :
def round_delete_cache ( face_id : int ) -> dict [ str , object ]:
return {
"objects" : [
{
"objectId" : f "round: { face_id } " ,
"objectType" : "round" ,
"geometrySignature" : { "faceIds" : [ face_id ], "surfaceType" : "cylinder" , "radius" : 1.3 },
"capabilities" : [
{
"key" : "feature.delete_round_or_chamfer" ,
"displayName" : "删除圆角/倒角" ,
"currentValue" : 1 ,
"valueKind" : "command" ,
"editable" : True ,
"defaultIntent" : "删除圆角/倒角并补面" ,
"backendOperation" : "delete_round_or_chamfer" ,
"postCheck" : "target_feature_removed" ,
}
],
}
]
}
probe = _PropertyTableProbe ()
probe . selected_kind = "feature"
probe . selected_face_id = 1360
probe . scdm_feature_cache_state = "ready"
probe . scdm_edit_runner_ready = { "feature.delete_round_or_chamfer" }
probe . scdm_feature_cache = round_delete_cache ( 1360 )
pocket_info = {
"surface" : "cylinder" ,
"feature_guess" : "hole/groove candidate" ,
"feature_type" : "圆柱孔/槽候选" ,
"blind_split_cylindrical_pocket" : True ,
"diameter" : 2.6 ,
"radius" : 1.3 ,
"part_id" : 1 ,
"solid_id" : 0 ,
}
probe . current_info_values = dict ( pocket_info )
probe . _selected_action_info = lambda : dict ( pocket_info )
probe . _refresh_property_editor ()
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
]
_assert ( "删除圆角/倒角" not in labels , f "blind pocket should not expose round/chamfer delete: { labels } " )
_assert ( not labels , f "blind pocket SCDM conflict should not fall back to misleading local rows: { labels } " )
_assert (
probe . current_info_values . get ( "scdm_selection_capability_count" ) == 0 ,
f "filtered SCDM conflict should not remain in diagnostic capability count: { probe . current_info_values } " ,
)
_assert (
probe . current_info_values . get ( "scdm_selection_blocked_count" ) == 0 ,
f "filtered SCDM conflict should not remain in blocked diagnostics: { probe . current_info_values } " ,
)
_assert (
"已隐藏该冲突操作" in str ( probe . current_info_values . get ( "scdm_selection_status" ) or "" ),
f "filtered SCDM conflict should be explained in diagnostics: { probe . current_info_values } " ,
)
round_probe = _PropertyTableProbe ()
round_probe . selected_kind = "feature"
round_probe . selected_face_id = 1722
round_probe . scdm_feature_cache_state = "ready"
round_probe . scdm_edit_runner_ready = { "feature.delete_round_or_chamfer" }
round_probe . scdm_feature_cache = round_delete_cache ( 1722 )
round_info = {
"surface" : "cylinder" ,
"feature_guess" : "round/fillet candidate" ,
"feature_type" : "圆角/倒圆候选" ,
"existing_fillet_status" : "candidate" ,
"radius" : 1.3 ,
"part_id" : 1 ,
"solid_id" : 0 ,
}
round_probe . current_info_values = dict ( round_info )
round_probe . _selected_action_info = lambda : dict ( round_info )
round_probe . _refresh_property_editor ()
round_labels = [
round_probe . property_table . item ( row , PROPERTY_LABEL_COLUMN ) . text ()
for row in range ( round_probe . property_table . rowCount ())
if round_probe . property_table . item ( row , PROPERTY_LABEL_COLUMN ) is not None
]
_assert ( "删除圆角/倒角" in round_labels , f "real round candidates should keep SCDM delete capability: { round_labels } " )
2026-08-19 18:02:47 +08:00
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 } " ,
)
2026-08-19 10:28:09 +08:00
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" )
2026-08-19 18:02:47 +08:00
_assert (
"_restore_scdm_feature_cache_from_disk()" in loaded_body ,
"STEP load should restore a matching SCDM disk cache before launching a new probe" ,
)
2026-08-19 10:28:09 +08:00
_assert (
2026-08-20 17:12:01 +08:00
"_defer_post_import_recognition_preloads" in loaded_body
and "_start_asitus_hole_recognition_preload" not in loaded_body ,
"ordinary STEP import should display the model first and defer external recognition until selection" ,
2026-08-19 10:28:09 +08:00
)
_assert (
2026-08-20 17:12:01 +08:00
"_start_scdm_probe_preload(force=True)" in loaded_body ,
2026-08-19 10:28:09 +08:00
"SCDM edit-result reloads should still be able to force cache refresh for validation" ,
)
2026-08-20 17:12:01 +08:00
restore_cache_body = _function_text ( PROJECT_ROOT / "step_editor/window_core.py" , "_restore_scdm_feature_cache_from_disk" )
_assert (
"scdm_raw_features.json" not in restore_cache_body
and "attach_local_face_ids_to_scdm_cache" not in restore_cache_body
and "_scdm_local_face_signatures()" not in restore_cache_body ,
"STEP import should not rebuild SCDM Face mappings from raw cache during the first display path" ,
)
2026-08-19 10:28:09 +08:00
preload_body = _function_text ( PROJECT_ROOT / "step_editor/window_core.py" , "_start_scdm_probe_preload" )
2026-08-19 18:02:47 +08:00
_assert (
"_current_scdm_feature_cache_matches_loaded_step()" in preload_body ,
"SCDM preload should skip relaunching SpaceClaim when a current cache is already installed" ,
)
2026-08-19 10:28:09 +08:00
_assert (
2026-08-20 17:12:01 +08:00
"model=model" not in preload_body
and 'getattr(model, "scdm_local_face_signatures"' not in preload_body
and "getattr(model, 'scdm_local_face_signatures'" not in preload_body ,
"SCDM preload worker must not keep or inspect the current UI StepModel across threads" ,
)
_assert (
"face_signatures=tuple(local_face_signatures)" in preload_body ,
"SCDM preload should pass a copied local Face signature snapshot into the worker" ,
2026-08-19 10:28:09 +08:00
)
_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 (
2026-08-20 17:12:01 +08:00
'builder = getattr(model, "scdm_local_face_signatures", None)' not in preload_body ,
"SCDM preload should not build local Face signatures from the current UI model inside the background worker" ,
2026-08-19 10:28:09 +08:00
)
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" ,
)
2026-08-20 17:12:01 +08:00
select_feature_body = _function_text ( PROJECT_ROOT / "step_editor/window_core.py" , "select_feature" )
select_face_body = _function_text ( PROJECT_ROOT / "step_editor/window_core.py" , "select_face" )
scdm_on_demand_body = _function_text ( PROJECT_ROOT / "step_editor/window_core.py" , "_maybe_start_scdm_probe_for_selection" )
_assert (
"_maybe_start_scdm_probe_for_selection" in select_feature_body
and "_maybe_start_scdm_probe_for_selection" in select_face_body
and "_start_scdm_probe_preload(force=True)" in scdm_on_demand_body ,
"large-model object selection should trigger on-demand SCDM probing instead of relying on ambiguous local cylinder fallback" ,
)
2026-08-19 10:28:09 +08:00
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" ,
)
2026-08-20 17:12:01 +08:00
def _assert_vtk_interaction_stability_guards () -> None :
copy_body = _function_text ( PROJECT_ROOT / "step_editor/window_core.py" , "_copy_polydata_for_ui_thread" )
_assert (
"DeepCopy" in copy_body ,
"worker-built VTK polydata should be deep-copied before the UI renderer owns it" ,
)
render_body = _function_text ( PROJECT_ROOT / "step_editor/window_core.py" , "_render_window_safely" )
_assert (
"_is_ui_thread" in render_body and "scene_rebuild_in_progress" in render_body ,
"render requests should stay on the UI thread and skip non-forced renders during scene rebuilds" ,
)
window_core_text = ( PROJECT_ROOT / "step_editor/window_core.py" ) . read_text ( encoding = "utf-8" )
window_state_text = ( PROJECT_ROOT / "step_editor/window_state.py" ) . read_text ( encoding = "utf-8" )
direct_core_renders = window_core_text . count ( "render_window.Render()" ) - render_body . count ( "render_window.Render()" )
_assert (
direct_core_renders == 0 and "self.render_window.Render()" not in window_state_text ,
"window code should route all VTK render requests through _render_window_safely()" ,
)
pick_body = _function_text ( PROJECT_ROOT / "step_editor/window_core.py" , "_pick_actor_cell" )
_assert (
"scene_rebuild_in_progress" in pick_body and "PickFromListOff" in pick_body ,
"VTK picking should be disabled while actors/polydata are being replaced" ,
)
hover_body = _function_text ( PROJECT_ROOT / "step_editor/window_core.py" , "_update_hover_target" )
_assert (
"scene_rebuild_in_progress" in hover_body ,
"hover picking should be suppressed during scene rebuilds" ,
)
finish_scdm_body = _function_text ( PROJECT_ROOT / "step_editor/window_core.py" , "_finish_scdm_probe_preload" )
_assert (
"installed = self._install_scdm_feature_cache" in finish_scdm_body
and "cache_ready=False" in finish_scdm_body ,
"SCDM probe finish should not report success when cache installation is rejected" ,
)
deferred_edge_body = _function_text ( PROJECT_ROOT / "step_editor/window_core.py" , "_finish_deferred_edge_display" )
_assert (
"_defer_scene_actor_update_if_interacting" in deferred_edge_body
and "_apply_deferred_edge_display_result" in deferred_edge_body ,
"deferred edge actor installation should wait until camera interaction is idle" ,
)
scene_delay_body = _function_text ( PROJECT_ROOT / "step_editor/window_core.py" , "_scene_actor_update_should_wait_for_camera" )
_assert (
"camera_interaction_active" in scene_delay_body
and "pointer_button_down" in scene_delay_body
and "last_camera_interaction_ended_at" in scene_delay_body ,
"scene actor updates should be delayed during and immediately after camera interaction" ,
)
finish_edit_body = _function_text ( PROJECT_ROOT / "step_editor/window_state.py" , "_finish_scdm_edit_action" )
_assert (
"_load_step_sync" in finish_edit_body
and "background=True" not in finish_edit_body ,
"SCDM edit-result reload should avoid the background LoadWorker path that can hand worker-built VTK objects to the renderer" ,
)
load_sync_body = _function_text ( PROJECT_ROOT / "step_editor/window_core.py" , "_load_step_sync" )
_assert (
"_reject_scdm_result_after_reload" in load_sync_body
and "result-load-failed" in load_sync_body ,
"SCDM edit-result reload should catch scene-apply failures and roll back instead of leaking exceptions through Qt" ,
)
load_apply_body = _function_text ( PROJECT_ROOT / "step_editor/window_core.py" , "_apply_loaded_model_result" )
_assert (
"pending_scdm_reload" in load_apply_body
and "if not pending_scdm_reload: \n self._clear_history()" in load_apply_body ,
"SCDM edit-result reload should not run the ordinary import path that clears edit history while validation is pending" ,
)
def _assert_scdm_cache_revision_guard () -> None :
with tempfile . TemporaryDirectory ( prefix = "step_editor_cache_guard_" ) as temp :
step_path = Path ( temp ) / "guard.step"
step_path . write_text ( "ISO-10303-21; \n END-ISO-10303-21; \n " , encoding = "utf-8" )
fingerprint = file_fingerprint ( step_path )
probe = _MouseSelectionProbe ()
probe . step_path = step_path
probe . model = SimpleNamespace ()
stale = {
"modelFingerprint" : fingerprint ,
"mapperRevision" : max ( 0 , SCDM_FEATURE_CACHE_REVISION - 1 ),
"objects" : [{ "objectId" : "cylindrical_face_group:stale" , "capabilities" : [{ "key" : "hole.diameter" }]}],
}
installed = probe . _install_scdm_feature_cache ( stale )
_assert ( installed is False , "stale SCDM mapper cache must not be installed" )
_assert ( probe . scdm_feature_cache is None and probe . scdm_feature_cache_state == "stale" , "stale SCDM cache should clear UI cache state" )
current = {
"modelFingerprint" : fingerprint ,
"mapperRevision" : SCDM_FEATURE_CACHE_REVISION ,
"objects" : [{ "objectId" : "face:0" , "capabilities" : [{ "key" : "face.offset" }]}],
}
installed = probe . _install_scdm_feature_cache ( current )
_assert ( installed is True , "current SCDM mapper cache should install" )
_assert ( probe . _current_scdm_feature_cache_matches_loaded_step () is True , "current cache should match the loaded STEP" )
2026-08-19 10:28:09 +08:00
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" )
2026-08-19 18:02:47 +08:00
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 } " )
2026-08-19 10:28:09 +08:00
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" ,
)
2026-08-07 18:08:32 +08:00
def main () -> int :
app = QApplication . instance () or QApplication ([])
top_level_label_probe = _TopLevelPropertyLabelProbe ()
app . installEventFilter ( top_level_label_probe )
probe = _PropertyTableProbe ()
probe . show ()
2026-08-06 18:15:14 +08:00
QApplication . processEvents ()
2026-08-07 18:08:32 +08:00
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 } " ,
2026-08-06 18:15:14 +08:00
)
2026-08-19 18:02:47 +08:00
_assert_scdm_target_values_use_backend_units ()
_assert_selection_mode_labels_are_english ()
2026-08-07 18:08:32 +08:00
_assert_property_table_editor ( probe )
2026-08-19 10:28:09 +08:00
_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" )
2026-08-07 18:08:32 +08:00
_assert (
2026-08-19 10:28:09 +08:00
"能改:" 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" )
2026-08-20 17:12:01 +08:00
_assert ( "证据完整的简单槽/长圆槽" in progress_detail , f "software progress should not overpromise ambiguous slot edits: { progress_detail } " )
_assert ( "不等于已经可改" in progress_detail , f "software progress should distinguish recognition from editability: { progress_detail } " )
2026-08-19 10:28:09 +08:00
_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 } " )
2026-08-07 18:08:32 +08:00
_assert_diagnostics_stay_out_of_parameter_table ( probe )
2026-08-14 18:42:39 +08:00
_assert_relation_formula_editor ()
2026-08-17 18:53:03 +08:00
_assert_relation_radius_formula_proxy ()
_assert_relation_formula_input_remains_editable_while_replaying ()
_assert_relation_formula_input_clickable_after_existing_formula ()
2026-08-20 17:12:01 +08:00
_assert_relation_formula_can_toggle_without_deleting ()
_assert_relation_formula_import_export ()
2026-08-17 18:53:03 +08:00
_assert_relation_formula_input_is_selection_independent ()
_assert_relation_formula_input_recovers_after_loading ()
_assert_relation_formula_ids_follow_model_remap ()
2026-08-07 18:08:32 +08:00
_assert_mouse_selection_guards ()
2026-08-19 10:28:09 +08:00
_assert_scdm_selection_diagnostics ()
2026-08-20 17:12:01 +08:00
_assert_scdm_parameter_table_shows_only_enabled_specs ()
_assert_ambiguous_slot_empty_state_explains_missing_params ()
_assert_scdm_round_delete_conflict_filtered_for_blind_pocket ()
_assert_scdm_first_holds_ambiguous_large_cylinders ()
2026-08-19 18:02:47 +08:00
_assert_solid_selection_does_not_expand_face_scdm_specs ()
2026-08-19 10:28:09 +08:00
_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 ()
2026-08-20 17:12:01 +08:00
_assert_vtk_interaction_stability_guards ()
_assert_scdm_cache_revision_guard ()
2026-08-19 10:28:09 +08:00
_assert_large_planar_offset_prefers_local_backend ()
_assert_background_load_uses_worker ()
2026-08-07 18:08:32 +08:00
_assert_quick_blind_depth_spec ()
_assert_user_facing_failure_messages ()
2026-08-11 18:28:16 +08:00
_assert_parameter_export_action ()
2026-08-19 10:28:09 +08:00
_assert_scdm_command_row_uses_unified_apply ()
2026-08-07 18:08:32 +08:00
print ( "property table editor UI ok" )
2026-08-06 18:15:14 +08:00
if QApplication . instance () is app :
app . quit ()
return 0
if __name__ == "__main__" :
raise SystemExit ( main ())