diff --git a/apps/studio/config/compound_eye.png b/apps/studio/config/compound_eye.png index 3769363..b017f12 100644 Binary files a/apps/studio/config/compound_eye.png and b/apps/studio/config/compound_eye.png differ diff --git a/apps/studio/docs/ui-elements.json b/apps/studio/docs/ui-elements.json new file mode 100644 index 0000000..d12a1cb --- /dev/null +++ b/apps/studio/docs/ui-elements.json @@ -0,0 +1,518 @@ +{ + "version": 1, + "scope": "apps/studio", + "note": "Authored, not generated: docs/ui-elements.json is the machine-readable half of the layout contract in docs/ui-layout.md, and apps/studio/tests/test_ui_elements.py fails when the window and this file disagree. tools/ui_elements_dump.py drafts it from a live window; its output must be curated, never copied. An element is found by 'path' (dot path from the window, with '()' for a Qt getter, '[i]' for an index and '.' for the window itself) or by a unique 'objectName'. 'objectName' is recorded only when the name is a stable identity; 'style' records the stylesheet token a widget carries instead (the rail's transport is 'primaryButton' while idle and 'ghostButton' while acquiring, so it has no name of its own). 'condition' names a predicate from ui/elements.py CONDITIONS: the element is expected only when it holds, and the suite has a case per condition so no element is permanently skipped. 'attrs' records a structural property of the widget itself (a table's column count, a splitter's child count). The drift report compares the visible widgets' objectNames against the element identities plus the style_tokens below (Qt's own 'qt_' names excluded), and views..unlisted_named_widgets records what is deliberately left over; it is empty because every name on screen is accounted for. 'rows' expands a per-channel template into one element per force axis.", + "window": { + "id": "main_window", + "path": ".", + "objectName": null, + "class": "StudioMainWindow", + "purpose": "the shell: brand bar, rail, splitter, menus and status bar on one window", + "views": "all" + }, + "views": { + "live": { + "purpose": "live measurement: the measurement panes lead, the charts and the rail's settings accompany them", + "primary": ["measurement_2d", "measurement_3d"], + "companions": ["charts"], + "unlisted_named_widgets": [] + }, + "replay": { + "purpose": "history browsing: the charts lead, the measurement panes stay beside them, and the rail shows a recorded video instead of a camera", + "primary": ["charts"], + "companions": ["measurement_2d", "measurement_3d"], + "unlisted_named_widgets": [] + }, + "calibration": { + "purpose": "force calibration: cal_panel's six-column table and action row lead, and the charts remain on screen beside it", + "primary": ["cal_panel"], + "companions": ["charts"], + "unlisted_named_widgets": [] + } + }, + "style_tokens": [ + { "name": "navButton", "purpose": "brand-bar view tab" }, + { "name": "sectionSeparator", "purpose": "1 px rule between groups" }, + { "name": "panelTitle", "purpose": "panel and section heading" }, + { "name": "panelMeta", "purpose": "secondary readout beside a heading" }, + { "name": "hintText", "purpose": "explanation or state line under a control" }, + { "name": "fieldLabel", "purpose": "label of a form field" }, + { "name": "unitBadge", "purpose": "small filled badge: unit, channel or product" }, + { "name": "valueCard", "purpose": "large monospace numeric reading" }, + { "name": "valueGroup", "purpose": "container of one reading's card" }, + { "name": "cardPanel", "purpose": "raised container for a picture or a plot" }, + { "name": "contentPanel", "purpose": "the panel frame every splitter slot uses" }, + { "name": "primaryButton", "purpose": "the filled action of the moment" }, + { "name": "ghostButton", "purpose": "outline action" }, + { "name": "dangerOutline", "purpose": "outline stop/cancel action" }, + { "name": "dangerButton", "purpose": "filled stop/record-stop action" } + ], + "regions": [ + { + "id": "brand_bar", + "path": "brand_bar", + "objectName": "brandBar", + "class": "BrandBar", + "purpose": "the three top-level view tabs, the lockup and the product name", + "elements": [ + { "id": "brand_logo", "path": "brand_bar.logo", "objectName": "brandLogo", "class": "QLabel", + "purpose": "corporate mark, re-tinted from the theme; the text lockup stays if the asset is missing" }, + { "id": "brand_wordmark", "path": "brand_bar.name", "objectName": "brandName", "class": "QLabel", + "purpose": "product wordmark beside the mark" }, + { "id": "brand_tagline", "path": "brand_bar.tagline", "objectName": "brandTagline", "class": "QLabel", + "purpose": "tagline: kept in the layout but hidden in the one-line rail, where it is the wordmark's tooltip" }, + { "id": "brand_product", "path": "brand_bar.product", "objectName": "brandProduct", "class": "QLabel", + "purpose": "product title, right-aligned at the end of the bar" }, + { "id": "brand_divider", "path": "brand_bar.divider", "objectName": null, "style": "sectionSeparator", "class": "QFrame", + "purpose": "1 px rule between the tabs and the product title" }, + { "id": "tab_live", "path": "brand_bar.btn_live", "objectName": null, "style": "navButton", "class": "QPushButton", + "purpose": "the live (measurement) view tab; its checked state marks the active view" }, + { "id": "tab_replay", "path": "brand_bar.btn_playback", "objectName": null, "style": "navButton", "class": "QPushButton", + "purpose": "the replay (history) view tab" }, + { "id": "tab_calibration", "path": "brand_bar.btn_calibration", "objectName": null, "style": "navButton", "class": "QPushButton", + "purpose": "the calibration view tab" } + ] + }, + { + "id": "rail", + "path": "toolbar", + "objectName": null, + "class": "ToolbarWidget", + "purpose": "the status rail: sensor summary, session phase, capture source, record state, transport and the settings toggle", + "elements": [ + { "id": "sensor_button", "path": "toolbar.btn_sensor", "objectName": null, "style": "ghostButton", "class": "QPushButton", + "purpose": "sensor and config summary ('BOX . box/sensor.json'); opens the sensor dialog" }, + { "id": "phase_chip", "path": "toolbar.status_chip", "objectName": "statusChip", "class": "QLabel", + "purpose": "session phase (idle / connecting / running / error)" }, + { "id": "source_summary", "path": "toolbar.source_summary", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel", + "purpose": "capture-source detail (rate, video or recording name); hidden while empty, with the whole text in its tooltip" }, + { "id": "calibration_summary", "path": "toolbar.calibration_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel", + "purpose": "which calibration the device confirmed; hidden while empty" }, + { "id": "data_state", "path": "toolbar.data_state", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel", + "purpose": "data-validity readout; hidden until a sample has been judged" }, + { "id": "record_chip", "path": "toolbar.record_chip", "objectName": "recordChip", "class": "RecordStatusChip", + "purpose": "record state, elapsed time and what the writer dropped" }, + { "id": "source_slot", "path": "toolbar.source_slot", "objectName": null, "class": "QWidget", + "purpose": "the one capture-source slot; the view decides which of the three groups inside it is shown" }, + { "id": "camera_group", "path": "toolbar.camera_group", "objectName": null, "class": "QWidget", "views": ["live"], + "purpose": "live camera picker group (camera index and search)" }, + { "id": "camera_id_label", "path": "toolbar.cam_id_label", "objectName": null, "style": "fieldLabel", "class": "QLabel", "views": ["live"], + "purpose": "label of the camera index field" }, + { "id": "camera_id_spin", "path": "toolbar.cam_id_spin", "objectName": null, "class": "QSpinBox", "views": ["live"], + "purpose": "camera index the next run opens" }, + { "id": "camera_search", "path": "toolbar.btn_search", "objectName": null, "class": "QPushButton", "views": ["live"], + "purpose": "probe the cameras and offer the ones found" }, + { "id": "video_group", "path": "toolbar.video_group", "objectName": null, "class": "QWidget", "views": ["live"], "condition": "video_source", + "purpose": "shown instead of the camera picker while the run source is a file" }, + { "id": "use_camera", "path": "toolbar.btn_use_camera", "objectName": null, "class": "QPushButton", "views": ["live"], "condition": "video_source", + "purpose": "one-click way back to the camera source" }, + { "id": "replay_group", "path": "toolbar.replay_group", "objectName": null, "class": "QWidget", "views": ["replay"], + "purpose": "the replay view's source group" }, + { "id": "replay_video", "path": "toolbar.btn_replay_video", "objectName": null, "class": "QPushButton", "views": ["replay"], + "purpose": "choose the recorded video to play" }, + { "id": "recompute", "path": "toolbar.btn_recompute", "objectName": null, "class": "QPushButton", "views": ["replay"], + "purpose": "re-run the vision pipeline over that video: the only processed picture a recording can produce" }, + { "id": "record", "path": "toolbar.btn_record", "objectName": null, "style": "primaryButton / dangerButton", "class": "QPushButton", + "purpose": "start or stop the recording; enabled with a live session and always enabled while recording" }, + { "id": "transport", "path": "toolbar.btn_run", "objectName": null, "style": "primaryButton / ghostButton", "class": "QPushButton", + "purpose": "the transport of both views: play / pause / resume; unchecked means paused while a session exists and idle when none does" }, + { "id": "stop", "path": "toolbar.btn_stop", "objectName": null, "style": "dangerOutline / dangerButton", "class": "QPushButton", + "purpose": "end the live session, leave the replay, or cancel a connect that has not returned" }, + { "id": "reset", "path": "toolbar.btn_reset", "objectName": null, "class": "QPushButton", + "purpose": "zero the device tracker; enabled by the session state, never by which panel is on screen" }, + { "id": "settings_toggle", "path": "toolbar.btn_settings", "objectName": null, "style": "ghostButton", "class": "QPushButton", + "purpose": "expand or collapse the settings area" } + ] + }, + { + "id": "settings_area", + "path": "toolbar.settings_page", + "objectName": null, + "class": "QWidget", + "purpose": "the collapsible settings area: what is configuration rather than state", + "elements": [ + { "id": "settings_scroll", "path": "toolbar._settings_scroll", "objectName": null, "class": "QScrollArea", + "purpose": "the settings area scrolls sideways so a narrow window squeezes nothing into an unreadable row" }, + { "id": "preview", "path": "toolbar.btn_preview", "objectName": null, "class": "QPushButton", + "purpose": "live camera preview on/off; offered only for a camera source in the live view" }, + { "id": "units_label", "path": "toolbar.units_label", "objectName": null, "style": "fieldLabel", "class": "QLabel", + "purpose": "label of the display-unit field" }, + { "id": "units_combo", "path": "toolbar.units_combo", "objectName": null, "class": "QComboBox", "attrs": { "count": 2 }, + "purpose": "display unit for the charts and the readings: newtons or raw counts" }, + { "id": "record_mode_label", "path": "toolbar.record_label", "objectName": null, "style": "fieldLabel", "class": "QLabel", + "purpose": "label of the record-mode field" }, + { "id": "record_mode_combo", "path": "toolbar.record_mode_combo", "objectName": null, "class": "QComboBox", "attrs": { "count": 2 }, + "purpose": "what a recording writes: video + data, or data only" } + ] + }, + { + "id": "menus", + "path": "menuBar()", + "objectName": null, + "class": "QMenuBar", + "purpose": "the menu bar, its menus, and the actions they hold (plus the Space shortcut, which has no menu entry)", + "elements": [ + { "id": "file_menu", "path": "file_menu", "objectName": null, "class": "QMenu", + "purpose": "open a video, reveal the sensor config, open a recording for playback" }, + { "id": "view_menu", "path": "view_menu", "objectName": null, "class": "QMenu", + "purpose": "what is on screen: panels, 3D, display aids, transport shortcuts, theme and language" }, + { "id": "theme_menu", "path": "theme_menu", "objectName": null, "class": "QMenu", + "purpose": "the dark / light submenu of the view menu" }, + { "id": "lang_menu", "path": "lang_menu", "objectName": null, "class": "QMenu", + "purpose": "the Chinese / English submenu of the view menu" }, + { "id": "help_menu", "path": "help_menu", "objectName": null, "class": "QMenu", + "purpose": "the about entry" }, + { "id": "act_open_video", "path": "act_open_video", "objectName": null, "class": "QAction", + "purpose": "file menu: open a recorded video in the replay view (enabled in the replay view only)" }, + { "id": "act_reveal_config", "path": "act_reveal_config", "objectName": null, "class": "QAction", + "purpose": "file menu: show the active sensor config in the file manager" }, + { "id": "act_playback", "path": "act_playback", "objectName": null, "class": "QAction", + "purpose": "file menu: open a recorded session for playback" }, + { "id": "act_playback_latest", "path": "act_playback_latest", "objectName": null, "class": "QAction", + "purpose": "file menu: open the most recent recording" }, + { "id": "act_play", "path": "act_play", "objectName": null, "class": "QAction", + "purpose": "Space: the transport shortcut, a window action with no menu entry" }, + { "id": "act_cal_panel", "path": "act_cal_panel", "objectName": null, "class": "QAction", + "purpose": "view menu: show the calibration panel (checking it enters the calibration view, unchecking returns to live)" }, + { "id": "act_finger_3d", "path": "act_finger_3d", "objectName": null, "class": "QAction", + "purpose": "view menu: the 3D pane on/off, the same state as the pane's own switch; enabled for a finger sensor" }, + { "id": "act_scene3d_settings", "path": "act_scene3d_settings", "objectName": null, "class": "QAction", + "purpose": "view menu: open the 3D display settings dialog (the pane's gear is the same call)" }, + { "id": "act_show_fps", "path": "act_show_fps", "objectName": null, "class": "QAction", + "purpose": "view menu: show the frame-rate readout" }, + { "id": "act_show_diagnostics", "path": "act_show_diagnostics", "objectName": null, "class": "QAction", + "purpose": "view menu: how much the panels explain themselves around their pictures" }, + { "id": "act_raw_auto_zero", "path": "act_raw_auto_zero", "objectName": null, "class": "QAction", + "purpose": "view menu: the display baseline (auto-zero) master switch" }, + { "id": "act_raw_reestimate", "path": "act_raw_reestimate", "objectName": null, "class": "QAction", + "purpose": "view menu: restart the idle baseline window" }, + { "id": "act_settings", "path": "act_settings", "objectName": null, "class": "QAction", + "purpose": "view menu: the rail's settings area, the same state as its toggle button" }, + { "id": "act_plot_settings", "path": "act_plot_settings", "objectName": null, "class": "QAction", + "purpose": "view menu: chart window, ranges and acquisition filters (the chart gear is the same call)" }, + { "id": "act_rotate_arrows", "path": "act_rotate_arrows", "objectName": null, "class": "QAction", + "purpose": "view menu: rotate the 2D arrow field by 90 degrees for the mounted sensors" }, + { "id": "act_recompute", "path": "act_recompute", "objectName": null, "class": "QAction", + "purpose": "view menu: recompute the vision pipeline over the recorded video, the same state as the rail's button" }, + { "id": "act_run", "path": "act_run", "objectName": null, "class": "QAction", + "purpose": "F5: start a run (the rail's transport is the primary control)" }, + { "id": "act_stop", "path": "act_stop", "objectName": null, "class": "QAction", + "purpose": "Esc: stop the session or the replay" }, + { "id": "act_preview", "path": "act_preview", "objectName": null, "class": "QAction", + "purpose": "F6: camera preview on/off" }, + { "id": "act_reset", "path": "act_reset", "objectName": null, "class": "QAction", + "purpose": "Ctrl+R: zero the device tracker, the same action as the rail's reset" }, + { "id": "act_reset_view", "path": "act_reset_view", "objectName": null, "class": "QAction", + "purpose": "F8: reset the views (curves, window, cursor, 3D camera) without touching the device" }, + { "id": "act_pause", "path": "act_pause", "objectName": null, "class": "QAction", + "purpose": "F7: pause/resume the live session, the same state as the transport" }, + { "id": "act_theme_dark", "path": "act_theme_dark", "objectName": null, "class": "QAction", + "purpose": "theme submenu: dark theme" }, + { "id": "act_theme_light", "path": "act_theme_light", "objectName": null, "class": "QAction", + "purpose": "theme submenu: light theme" }, + { "id": "act_lang_zh", "path": "act_lang_zh", "objectName": null, "class": "QAction", + "purpose": "language submenu: Chinese" }, + { "id": "act_lang_en", "path": "act_lang_en", "objectName": null, "class": "QAction", + "purpose": "language submenu: English" }, + { "id": "act_about", "path": "act_about", "objectName": null, "class": "QAction", + "purpose": "help menu: the about dialog" } + ] + }, + { + "id": "status_bar", + "path": "statusBar()", + "objectName": null, + "class": "_NoticeBar", + "purpose": "the one-off notice line: the expiring-message QStatusBar subclass; session states live on the rail, not here", + "elements": [] + }, + { + "id": "content", + "path": "content_splitter", + "objectName": null, + "class": "QSplitter", + "attrs": { "count": 4 }, + "purpose": "the content splitter: the measurement region and its companions, in panel order (3D, 2D, charts, calibration); its children are the panes below", + "elements": [] + }, + { + "id": "sensor_picker", + "path": "sensor_picker", + "objectName": null, + "class": "SensorPickerWidget", + "purpose": "the sensor type and config pickers the rail summarises in one button; the dialog is where they live", + "elements": [ + { "id": "picker_badge", "path": "sensor_picker.thumb_label", "objectName": null, "style": "unitBadge", "class": "QLabel", + "purpose": "the selected product's name as a badge" }, + { "id": "picker_type_label", "path": "sensor_picker.type_label", "objectName": null, "style": "fieldLabel", "class": "QLabel", + "purpose": "label of the sensor-type field" }, + { "id": "picker_type_combo", "path": "sensor_picker.type_combo", "objectName": null, "class": "QComboBox", + "purpose": "the sensor type: which configs exist follows from it" }, + { "id": "picker_config_label", "path": "sensor_picker.config_label", "objectName": null, "style": "fieldLabel", "class": "QLabel", + "purpose": "label of the config-file field" }, + { "id": "picker_config_combo", "path": "sensor_picker.config_combo", "objectName": null, "class": "QComboBox", + "purpose": "the config file that belongs to the selected sensor type" } + ] + }, + { + "id": "sensor_dialog", + "path": "sensor_dialog", + "objectName": "sensorDialog", + "class": "SensorDialog", + "condition": "sensor_dialog_open", + "purpose": "the pickers' dialog plus the calibration facts that follow them; non-modal and built on first use", + "elements": [ + { "id": "dialog_calibration_title", "path": "sensor_dialog.calib_title", "objectName": null, "style": "panelTitle", "class": "QLabel", + "purpose": "heading of the calibration half" }, + { "id": "dialog_version_label", "path": "sensor_dialog.version_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel", + "purpose": "the calibration identity the device confirmed" }, + { "id": "dialog_axes_label", "path": "sensor_dialog.axes_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel", + "purpose": "which axes that calibration covers" }, + { "id": "dialog_config_label", "path": "sensor_dialog.config_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel", + "purpose": "the active sensor config file" }, + { "id": "dialog_reveal", "path": "sensor_dialog.btn_reveal", "objectName": null, "class": "QPushButton", + "purpose": "show that config file in the file manager" }, + { "id": "dialog_export_title", "path": "sensor_dialog.export_title", "objectName": null, "style": "panelTitle", "class": "QLabel", + "purpose": "heading of the exported-coefficients half" }, + { "id": "dialog_export_label", "path": "sensor_dialog.export_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel", + "purpose": "the last coefficients file written by the calibration panel" }, + { "id": "dialog_identity_label", "path": "sensor_dialog.identity_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel", + "purpose": "what that file says it belongs to, so a mismatched apply is visible" }, + { "id": "dialog_export", "path": "sensor_dialog.btn_export", "objectName": null, "class": "QPushButton", + "purpose": "write the calibration panel's coefficients (the panel's own action)" }, + { "id": "dialog_apply", "path": "sensor_dialog.btn_apply", "objectName": null, "class": "QPushButton", + "purpose": "push the last export into the running device" }, + { "id": "dialog_close", "path": "sensor_dialog.btn_close", "objectName": null, "class": "QPushButton", + "purpose": "close the dialog" } + ] + } + ], + "panes": [ + { + "id": "measurement_2d", + "path": "arrow_panel", + "objectName": null, + "style": "contentPanel", + "class": "ArrowPanel", + "views": ["live", "replay"], + "purpose": "the 2D measurement pane: arrows, stitched arrows or depth, drawn from the same frame the 3D pane reads", + "elements": [ + { "id": "arrow_title", "path": "arrow_panel.title", "objectName": null, "style": "panelTitle", "class": "QLabel", + "purpose": "panel heading" }, + { "id": "arrow_view_combo", "path": "arrow_panel.view_combo", "objectName": null, "class": "QComboBox", + "purpose": "the view modes the current frame can actually offer (a recording offers only its raw picture)" }, + { "id": "arrow_canvas", "path": "arrow_panel.image_label", "objectName": null, "class": "AspectImageLabel", + "purpose": "the picture area, which keeps the frame's own aspect ratio" }, + { "id": "arrow_scale_label", "path": "arrow_panel.scale_label", "objectName": null, "style": "hintText", "class": "ShrinkableLabel", + "purpose": "footer: the magnification the arrows were drawn at" }, + { "id": "arrow_pipeline_label", "path": "arrow_panel.pipeline_label", "objectName": null, "style": "hintText", "class": "ShrinkableLabel", + "purpose": "footer: source to output, shown with the diagnostics switch" }, + { "id": "arrow_area_label", "path": "arrow_panel.area_label", "objectName": null, "style": "hintText", "class": "ShrinkableLabel", + "purpose": "footer: the valid sensing area of the frame" }, + { "id": "arrow_note_label", "path": "arrow_panel.note_label", "objectName": null, "style": "hintText", "class": "ShrinkableLabel", + "purpose": "one line explaining an unexpected plan, e.g. that a recording stores only the raw camera image" }, + { "id": "arrow_colorbar", "path": "arrow_panel.colorbar", "objectName": "colorBar", "class": "ColorBarLabel", + "purpose": "depth colour strip, painted from the same lookup table the picture uses; hidden outside the depth view" }, + { "id": "arrow_depth_scale", "path": "arrow_panel.depth_scale_label", "objectName": null, "style": "panelMeta", "class": "QLabel", + "purpose": "the fixed depth range the colour strip is scaled to; hidden outside the depth view" } + ] + }, + { + "id": "measurement_3d", + "path": "mesh3d_panel", + "objectName": null, + "style": "contentPanel", + "class": "Mesh3dPanel", + "views": ["live", "replay"], + "condition": "mesh3d_view", + "purpose": "the 3D pane: a peer of the 2D pane and never a replacement for it; only a finger sensor has a scene", + "elements": [ + { "id": "mesh3d_title", "path": "mesh3d_panel.title", "objectName": null, "style": "panelTitle", "class": "QLabel", + "purpose": "panel heading" }, + { "id": "mesh3d_reset_view", "path": "mesh3d_panel.btn_reset_view", "objectName": null, "class": "QPushButton", + "purpose": "back to the framing the scene asset calls its own" }, + { "id": "mesh3d_settings_button", "path": "mesh3d_panel.btn_settings", "objectName": null, "class": "QPushButton", + "purpose": "open the 3D settings dialog; also what builds the view on first use" }, + { "id": "mesh3d_message", "path": "mesh3d_panel.message", "objectName": null, "style": "hintText", "class": "QLabel", + "purpose": "the one-line reason there is no picture (no scene, no asset, no GL, loading, refused)" }, + { "id": "mesh3d_native_handle", "path": "mesh3d_panel._native_handle", "objectName": "mesh3dNativeHandle", "class": "QWidget", + "purpose": "zero-sized holder that reserves the window's native handle, so adding the first GL widget cannot make Qt re-create (flash) the window" }, + { "id": "mesh3d_controls", "path": "mesh3d_panel.view.panel.controls_widget", "objectName": "scene3dControls", "class": "QWidget", "condition": "mesh3d_panel", + "purpose": "the block of 3D settings: the pane never shows it, the settings dialog hosts it" }, + { "id": "mesh3d_enable", "path": "mesh3d_panel.view.panel.enable_check", "objectName": null, "class": "QCheckBox", "condition": "mesh3d_panel", + "purpose": "the pane's own on/off switch, the same state as the view menu's 3D entry" }, + { "id": "mesh3d_preset_label", "path": "mesh3d_panel.view.panel.preset_label", "objectName": null, "class": "QLabel", "condition": "mesh3d_panel", + "purpose": "label of the camera-preset field" }, + { "id": "mesh3d_preset_combo", "path": "mesh3d_panel.view.panel.preset_combo", "objectName": null, "class": "QComboBox", "condition": "mesh3d_panel", + "purpose": "camera presets: the asset's own default view and the ones it names" }, + { "id": "mesh3d_housing", "path": "mesh3d_panel.view.panel.housing_check", "objectName": null, "class": "QCheckBox", "condition": "mesh3d_panel", + "purpose": "show the housing geometry, a mechanical check rather than the measurement; off by default" }, + { "id": "mesh3d_deform", "path": "mesh3d_panel.view.panel.deform_check", "objectName": null, "class": "QCheckBox", "condition": "mesh3d_panel", + "purpose": "exaggerate the deformation so a small displacement is visible" }, + { "id": "mesh3d_gain", "path": "mesh3d_panel.view.panel.gain_spin", "objectName": null, "class": "QDoubleSpinBox", "condition": "mesh3d_panel", + "purpose": "the deformation exaggeration factor" }, + { "id": "mesh3d_field_label", "path": "mesh3d_panel.view.panel.field_label", "objectName": null, "class": "QLabel", "condition": "mesh3d_panel", + "purpose": "label of the displayed-quantity field" }, + { "id": "mesh3d_field_combo", "path": "mesh3d_panel.view.panel.field_combo", "objectName": null, "class": "QComboBox", "condition": "mesh3d_panel", + "purpose": "which quantity colours the surface: depth response or optical-flow magnitude" }, + { "id": "mesh3d_field_note", "path": "mesh3d_panel.view.panel.field_note", "objectName": null, "style": "hintText", "class": "QLabel", "condition": "mesh3d_panel", + "purpose": "says which quantity is actually on screen" }, + { "id": "mesh3d_flow_arrows", "path": "mesh3d_panel.view.panel.flow_arrows_check", "objectName": null, "class": "QCheckBox", "condition": "mesh3d_panel", + "purpose": "the optical-flow arrow overlay on the surface" }, + { "id": "mesh3d_contact_arrow", "path": "mesh3d_panel.view.panel.contact_arrow_check", "objectName": null, "class": "QCheckBox", "condition": "mesh3d_panel", + "purpose": "the contact arrow overlay: where the surface is being touched" }, + { "id": "mesh3d_scale_label", "path": "mesh3d_panel.view.panel.scale_label", "objectName": null, "class": "QLabel", "condition": "mesh3d_panel", + "purpose": "label of the colour-scale mode field" }, + { "id": "mesh3d_scale_combo", "path": "mesh3d_panel.view.panel.scale_combo", "objectName": null, "class": "QComboBox", "condition": "mesh3d_panel", + "purpose": "fixed bounds or an automatic range for the colour scale" }, + { "id": "mesh3d_range_label", "path": "mesh3d_panel.view.panel.range_label", "objectName": null, "class": "QLabel", "condition": "mesh3d_panel", + "purpose": "label of the fixed colour-range fields" }, + { "id": "mesh3d_range_lower", "path": "mesh3d_panel.view.panel.lower_spin", "objectName": null, "class": "QDoubleSpinBox", "condition": "mesh3d_panel", + "purpose": "lower bound of the fixed colour range" }, + { "id": "mesh3d_range_upper", "path": "mesh3d_panel.view.panel.upper_spin", "objectName": null, "class": "QDoubleSpinBox", "condition": "mesh3d_panel", + "purpose": "upper bound of the fixed colour range" }, + { "id": "mesh3d_fps_label", "path": "mesh3d_panel.view.panel.fps_label", "objectName": null, "class": "QLabel", "condition": "mesh3d_panel", + "purpose": "label of the display-budget field" }, + { "id": "mesh3d_fps_combo", "path": "mesh3d_panel.view.panel.fps_combo", "objectName": null, "class": "QComboBox", "condition": "mesh3d_panel", + "purpose": "the 3D pane's display budget in frames per second; never reaches the camera or the recording" }, + { "id": "mesh3d_reset_button", "path": "mesh3d_panel.view.panel.reset_button", "objectName": null, "class": "QPushButton", "condition": "mesh3d_panel", + "purpose": "reset the 3D view from inside the settings dialog" }, + { "id": "mesh3d_stats", "path": "mesh3d_panel.view.panel.stats_label", "objectName": null, "style": "panelMeta", "class": "QLabel", "condition": "mesh3d_panel", + "purpose": "display-side counters: an engineering readout, not something to read while measuring" }, + { "id": "mesh3d_scale_lower", "path": "mesh3d_panel.view.panel.scale_lower_label", "objectName": null, "style": "panelMeta", "class": "QLabel", "condition": "mesh3d_panel", + "purpose": "the lower bound the legend was drawn with" }, + { "id": "mesh3d_scale_bar", "path": "mesh3d_panel.view.panel.scale_bar", "objectName": "colorScaleBar", "class": "ColorScaleBar", "condition": "mesh3d_panel", + "purpose": "the colour ramp the shader actually used, from the same lookup table" }, + { "id": "mesh3d_scale_upper", "path": "mesh3d_panel.view.panel.scale_upper_label", "objectName": null, "style": "panelMeta", "class": "QLabel", "condition": "mesh3d_panel", + "purpose": "the upper bound the legend was drawn with" }, + { "id": "mesh3d_legend", "path": "mesh3d_panel.view.panel.legend_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel", "condition": "mesh3d_panel", + "purpose": "one legend line: what the colours mean" }, + { "id": "mesh3d_source", "path": "mesh3d_panel.view.panel.source_label", "objectName": null, "style": "panelMeta", "class": "ShrinkableLabel", "condition": "mesh3d_panel", + "purpose": "which source produced the field on screen: live, replay or a recompute pass" }, + { "id": "mesh3d_state", "path": "mesh3d_panel.view.panel.state_label", "objectName": null, "style": "hintText", "class": "QLabel", "condition": "mesh3d_panel", + "purpose": "the scene state line: whether this frame is a measurement or a reference" } + ] + }, + { + "id": "charts", + "path": "force_plots", + "objectName": null, + "style": "contentPanel", + "class": "ForcePlotsPanel", + "views": ["live", "replay", "calibration"], + "purpose": "the force charts: three channels plus a whole-session overview strip, with the per-axis readings above the curves", + "rows": { + "values": [0, 1, 2], + "names": ["fn", "fx", "fy"], + "keys": ["card", "badge", "name", "value", "unit", "detail"], + "template": { + "card": { "id": "chart_{name}_card", "path": "force_plots.value_cards[{i}]", "objectName": null, "style": "valueGroup", "class": "QFrame", + "purpose": "the {name} channel's reading: badge and name over the number, then the selection's mean and sigma" }, + "badge": { "id": "chart_{name}_badge", "path": "force_plots.channel_badges[{i}]", "objectName": null, "style": "unitBadge", "class": "QLabel", + "purpose": "the {name} channel badge, coloured like its curve" }, + "name": { "id": "chart_{name}_name", "path": "force_plots.channel_names[{i}]", "objectName": null, "style": "hintText", "class": "QLabel", + "purpose": "what the {name} channel measures, in the operator's language" }, + "value": { "id": "chart_{name}_value", "path": "force_plots.value_labels[{i}]", "objectName": null, "style": "valueCard", "class": "QLabel", + "purpose": "the {name} reading itself: large monospace digits, so the axes can be compared at a glance" }, + "unit": { "id": "chart_{name}_unit", "path": "force_plots.value_units[{i}]", "objectName": null, "style": "hintText", "class": "QLabel", + "purpose": "the unit the {name} reading is in, which the chip decides for every axis at once" }, + "detail": { "id": "chart_{name}_detail", "path": "force_plots.value_details[{i}]", "objectName": null, "style": "hintText", "class": "QLabel", + "purpose": "the {name} selection's mean and sigma; kept at a fixed height, so selecting a window adds a number instead of moving the charts" } + } + }, + "elements": [ + { "id": "chart_title", "path": "force_plots.title", "objectName": null, "style": "panelTitle", "class": "QLabel", + "purpose": "panel heading" }, + { "id": "chart_mode_label", "path": "force_plots.mode_label", "objectName": null, "style": "hintText", "class": "QLabel", + "purpose": "whether the charts are following live, browsing a selection, or reading a recording" }, + { "id": "chart_latest", "path": "force_plots.btn_latest", "objectName": null, "class": "QPushButton", + "purpose": "leave browsing and follow the newest sample; disabled for a recording" }, + { "id": "chart_overview", "path": "force_plots.btn_overview", "objectName": null, "class": "QPushButton", + "purpose": "fit the whole session in the window" }, + { "id": "chart_box_zoom", "path": "force_plots.btn_box_zoom", "objectName": null, "class": "QPushButton", + "purpose": "box zoom, the one chart action that is a mode and stays checked" }, + { "id": "chart_settings", "path": "force_plots.btn_settings", "objectName": null, "class": "QPushButton", + "purpose": "open the chart settings: window, ranges and acquisition filters" }, + { "id": "chart_unit_badge", "path": "force_plots.unit_badge", "objectName": null, "style": "unitBadge", "class": "QPushButton", + "purpose": "the unit chip: a button, because switching newtons/raw is a chart concern" }, + { "id": "chart_value_group", "path": "force_plots.value_group", "objectName": null, "style": "valueGroup", "class": "QFrame", + "purpose": "the row of per-axis readings, placed above the curves because that is what is read at a glance" }, + { "id": "chart_plot_frame", "path": "force_plots.plot_frame", "objectName": null, "style": "cardPanel", "class": "QFrame", + "purpose": "the raised container of the plot canvas" }, + { "id": "chart_plot_widget", "path": "force_plots.plot_widget", "objectName": null, "class": "GraphicsLayoutWidget", + "purpose": "the pyqtgraph canvas: one subplot per channel and the whole-session overview strip below them" }, + { "id": "chart_window_caption", "path": "force_plots.window_caption", "objectName": null, "style": "panelTitle", "class": "QLabel", + "purpose": "caption of the overview strip" }, + { "id": "chart_window_label", "path": "force_plots.window_label", "objectName": null, "style": "panelMeta", "class": "QLabel", + "purpose": "the time window the strip is showing" }, + { "id": "chart_stats", "path": "force_plots.stats_label", "objectName": null, "style": "panelMeta", "class": "QLabel", + "purpose": "how long the selection is and how many samples it holds; the full per-axis readout is its tooltip" } + ] + }, + { + "id": "cal_panel", + "path": "cal_panel", + "objectName": null, + "style": "contentPanel", + "class": "CalPanel", + "views": ["calibration"], + "purpose": "the calibration panel: sample each axis against a reference load, review the points, export and apply the coefficients", + "elements": [ + { "id": "cal_scroll", "path": "cal_panel.scroll", "objectName": null, "class": "QScrollArea", + "purpose": "everything below the title scrolls, so a short window clips content instead of losing a function" }, + { "id": "cal_title", "path": "cal_panel.title", "objectName": null, "style": "panelTitle", "class": "QLabel", + "purpose": "panel heading" }, + { "id": "cal_channel_badge", "path": "cal_panel.channel_badge", "objectName": null, "style": "unitBadge", "class": "QLabel", + "purpose": "which channel is being calibrated, coloured like that channel's curve" }, + { "id": "cal_radio_fn", "path": "cal_panel.radio_fn", "objectName": null, "class": "QRadioButton", + "purpose": "calibrate the normal force channel" }, + { "id": "cal_radio_fx", "path": "cal_panel.radio_fx", "objectName": null, "class": "QRadioButton", + "purpose": "calibrate the shear channel FX" }, + { "id": "cal_radio_fy", "path": "cal_panel.radio_fy", "objectName": null, "class": "QRadioButton", + "purpose": "calibrate the shear channel FY" }, + { "id": "cal_weight_label", "path": "cal_panel._weight_label", "objectName": null, "style": "fieldLabel", "class": "QLabel", + "purpose": "label of the reference-load field" }, + { "id": "cal_weight_spin", "path": "cal_panel.weight_spin", "objectName": null, "class": "QDoubleSpinBox", + "purpose": "the reference load applied for the point being sampled" }, + { "id": "cal_duration_label", "path": "cal_panel._duration_label", "objectName": null, "style": "fieldLabel", "class": "QLabel", + "purpose": "label of the sampling-duration field" }, + { "id": "cal_duration_spin", "path": "cal_panel.duration_spin", "objectName": null, "class": "QDoubleSpinBox", + "purpose": "how long one calibration point is sampled for" }, + { "id": "cal_record", "path": "cal_panel.btn_record", "objectName": null, "style": "primaryButton / dangerButton", "class": "QPushButton", + "purpose": "sample one point; while sampling it is the cancel affordance and stays enabled" }, + { "id": "cal_progress", "path": "cal_panel.progress", "objectName": null, "class": "QProgressBar", + "purpose": "how far the current point's sampling has got" }, + { "id": "cal_live_label", "path": "cal_panel.live_label", "objectName": null, "class": "QLabel", + "purpose": "the three raw axis readings while a point is sampled" }, + { "id": "cal_empty_hint", "path": "cal_panel.empty_hint", "objectName": null, "style": "hintText", "class": "QLabel", + "purpose": "what to do when there are no points yet" }, + { "id": "cal_table", "path": "cal_panel.table", "objectName": null, "class": "QTableWidget", "attrs": { "columnCount": 6 }, + "purpose": "the diagnostic table: six columns (reference load, FN, FX, FY, stability, state), resized to their contents so a narrow panel scrolls instead of eliding a heading" }, + { "id": "cal_fit_label", "path": "cal_panel.fit_label", "objectName": null, "style": "hintText", "class": "QLabel", + "purpose": "what the current points fit: the readout that says whether more points are needed" }, + { "id": "cal_sn_label", "path": "cal_panel._sn_label", "objectName": null, "style": "fieldLabel", "class": "QLabel", + "purpose": "label of the device serial field" }, + { "id": "cal_sn_edit", "path": "cal_panel.sn_edit", "objectName": null, "class": "QLineEdit", + "purpose": "the device serial written into the exported coefficients and the session metadata" }, + { "id": "cal_toggle_excluded", "path": "cal_panel.btn_toggle_excluded", "objectName": null, "class": "QPushButton", + "purpose": "selection action: exclude or include the selected point in the fit" }, + { "id": "cal_toggle_validation", "path": "cal_panel.btn_toggle_validation", "objectName": null, "class": "QPushButton", + "purpose": "selection action: mark the selected point as a validation point" }, + { "id": "cal_delete", "path": "cal_panel.btn_delete", "objectName": null, "class": "QPushButton", + "purpose": "selection action: delete the selected point" }, + { "id": "cal_clear_channel", "path": "cal_panel.btn_clear_channel", "objectName": null, "class": "QPushButton", + "purpose": "selection action: clear the channel being calibrated" }, + { "id": "cal_clear_all", "path": "cal_panel.btn_clear_all", "objectName": null, "class": "QPushButton", + "purpose": "selection action: clear every channel" }, + { "id": "cal_open_record", "path": "cal_panel.btn_open_record", "objectName": null, "class": "QPushButton", + "purpose": "file row: load a saved calibration record" }, + { "id": "cal_save_record", "path": "cal_panel.btn_save_record", "objectName": null, "class": "QPushButton", + "purpose": "file row: save the samples and their identity as an audit record" }, + { "id": "cal_export", "path": "cal_panel.btn_export", "objectName": null, "class": "QPushButton", + "purpose": "file row: write the fitted coefficients to disk; it does not apply them" }, + { "id": "cal_apply", "path": "cal_panel.btn_apply", "objectName": null, "style": "primaryButton", "class": "QPushButton", + "purpose": "file row: push the last export into the running device" } + ] + } + ] +} diff --git a/apps/studio/docs/ui-layout.md b/apps/studio/docs/ui-layout.md index 63ee908..7bbaf82 100644 --- a/apps/studio/docs/ui-layout.md +++ b/apps/studio/docs/ui-layout.md @@ -126,3 +126,37 @@ not a prerequisite. **Golden images, if adopted:** pin the window size, scale factor, font and theme, or they will flake and be disabled. A flaky render gate is worse than none. + +## 7. The element manifest + +[`ui-elements.json`](ui-elements.json) is the machine-readable half of this contract. The rules +above say who owns the arrangement; the manifest says **which elements are supposed to exist**, per +region, per pane and per view. Each entry carries a stable `id`, the Qt `objectName` (recorded only +when it is a stable identity — the rail's transport carries a stylesheet token instead, so it is +found by path), the class, a one-line purpose, and the views it appears in. Anything that exists +only in some views, or only while a condition holds, declares `views` or a named `condition` +rather than leaving it ambiguous. + +[`ui/elements.py`](../src/orisys_studio/ui/elements.py) loads it and answers the questions a test +needs: `missing()`, `wrong_class()`, `unlisted_named_widgets()` and `validate()`. +[`tests/test_ui_elements.py`](../tests/test_ui_elements.py) runs them against a real +`StudioMainWindow` in each view, and asserts the declared panes are the visible ones. +[`tools/ui_elements_dump.py`](../tools/ui_elements_dump.py) drafts the file from a live window. + +**The manifest is authored, not generated.** A generated file accepts whatever the code happens to +contain, which is the drift it exists to catch; the dump is a drafting aid and its output must be +curated by hand. **An element is listed before a test relies on it** — that is what makes the file a +spec rather than a description of what someone found. The drift report (visible named widgets the +manifest accounts for neither as an element identity nor as a declared stylesheet token) is +compared with the record in each view, so new interface names fail until the manifest says what +they are. + +Whoever changes the set of elements — adding, renaming or removing a widget the manifest covers — +updates the manifest in the same change, before the test is asked to rely on it. + +This is the **element** tier of §6 and nothing more. It does not assert geometry or containment, it +does not run the locale/theme/minimum-size matrix, and it does not compare rendered pixels. Those +extend it rather than replace it: the geometry tier would add the rectangle a listed element is +allowed to occupy, the matrix tier would vary the window states the same list is checked under, and +the rendered tier would pin a reference image for the panes it names. None of that is attempted +here. diff --git a/apps/studio/src/orisys_studio/ui/elements.py b/apps/studio/src/orisys_studio/ui/elements.py new file mode 100644 index 0000000..691aa77 --- /dev/null +++ b/apps/studio/src/orisys_studio/ui/elements.py @@ -0,0 +1,926 @@ +"""The Studio window's element manifest, and the helpers that compare it to reality. + +``docs/ui-layout.md`` is the layout contract in prose: it says who owns the arrangement and +what each view is for, but nothing in it knows *which elements are supposed to exist*. This +module reads the machine-readable half of that contract — ``docs/ui-elements.json`` — and +answers the questions a test needs to ask of a real ``StudioMainWindow``: + +* ``missing(window, view)`` — expected elements this window does not have; +* ``wrong_class(window, view)`` — elements that are not the recorded Qt class; +* ``unlisted_named_widgets(window, view)`` — the **drift report**: visible widgets whose + ``objectName`` the manifest accounts for neither as an element's identity nor as a + declared stylesheet token; +* ``validate(window, view)`` — all of the above in one structured result. + +The drift report is deliberately separate from the hard assertions: the manifest will +legitimately lag the code, and the point is to *report* the difference rather than to pretend +it does not exist. The suite records the drift it expects for each view +(``views..unlisted_named_widgets``) and compares, so a newly named widget fails the +test until the manifest is updated. + +The manifest is **authored and reviewed, never generated**; ``tools/ui_elements_dump.py`` +drafts it from a live window, and that output must be curated before it is adopted. + +An element is located by ``path`` (a dot path from the window, e.g. ``toolbar.btn_run``, with +``()`` for a Qt getter such as ``menuBar()``, ``[i]`` for an index, and ``.`` for the window +itself) or, when it has a genuinely unique Qt ``objectName``, by that name. Both exist because +a Qt ``objectName`` is often a *style* rather than an identifier: the rail's transport carries +``primaryButton`` while idle and ``ghostButton`` while acquiring, so it has no stable name to +find it by. ``objectName`` therefore records the name only when it is stable, ``style`` +records the transient or shared token, and ``path`` is what the loader uses. + +The module needs only the standard library and Qt, and it is imported by tests and tools — the +application itself does not import it, so the manifest does not need to ship in a frozen +build. ``load()`` raises when the file is missing rather than inventing an empty spec: a +manifest that cannot be read is not a window with no elements. +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Iterator +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any + +#: The authored manifest. Derived from this file's location, not from the working directory, +#: so a test can run from anywhere (``apps/studio`` per the suite's convention). +MANIFEST_PATH = Path(__file__).resolve().parents[3] / "docs" / "ui-elements.json" + +#: Named state predicates an element may declare in ``condition``. An element whose condition +#: is false for the window under test is *skipped*, not failed: it is a fact about that window +#: (a box sensor has no 3D scene), not drift. Keeping the vocabulary closed means a typo in +#: the manifest is a manifest problem rather than a silently-skipped element. +CONDITIONS: dict[str, str] = { + "mesh3d_view": "the 3D view exists (finger sensor with the pane on)", + "mesh3d_panel": "the 3D pane's own controls exist (a 3D view could be built)", + "video_source": "the run source is a video file", + "sensor_dialog_open": "the sensor/calibration dialog has been built", +} + +#: Qt names its own internal children (``qt_spinbox_lineedit``, ``qt_splithandle_…``). They +#: are not part of the window's interface and are excluded from the drift report by prefix. +QT_INTERNAL_PREFIX = "qt_" + +_VALID_ROLES = ("window", "region", "pane", "element") +_SEGMENT = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)(\(\))?(?:\[(\d+)\])?$") + + +# ----------------------------------------------------------------------------- model + + +@dataclass(frozen=True) +class Element: + """One entry of the manifest: an element the window is supposed to have.""" + + id: str + object_name: str | None + class_name: str + purpose: str + path: str | None + views: tuple[str, ...] + condition: str | None = None + style: str | None = None + role: str = "element" + group: str = "" + attrs: tuple[tuple[str, Any], ...] = () + primary: bool = False + companion: bool = False + + @property + def key(self) -> str: + """What a failing assertion should name: the path when there is one.""" + return self.path or self.object_name or self.id + + @property + def attributes(self) -> dict[str, Any]: + return dict(self.attrs) + + +@dataclass(frozen=True) +class Region: + """A named part of the window (the brand bar, the rail, the dialog).""" + + id: str + class_name: str + purpose: str + path: str | None + object_name: str | None + views: tuple[str, ...] + elements: tuple[Element, ...] + condition: str | None = None + attrs: tuple[tuple[str, Any], ...] = () + + @property + def element(self) -> Element: + return Element( + id=self.id, + object_name=self.object_name, + class_name=self.class_name, + purpose=self.purpose, + path=self.path, + views=self.views, + condition=self.condition, + role="region", + group=self.id, + attrs=self.attrs, + ) + + +@dataclass(frozen=True) +class Pane: + """A panel in the content splitter: an element, plus the elements inside it.""" + + id: str + class_name: str + purpose: str + path: str | None + object_name: str | None + style: str | None + views: tuple[str, ...] + elements: tuple[Element, ...] + condition: str | None = None + + @property + def element(self) -> Element: + return Element( + id=self.id, + object_name=self.object_name, + class_name=self.class_name, + purpose=self.purpose, + path=self.path, + views=self.views, + condition=self.condition, + style=self.style, + role="pane", + group=self.id, + ) + + +@dataclass(frozen=True) +class ViewSpec: + """One top-level view: what leads, what accompanies it, and the recorded drift.""" + + id: str + purpose: str + primary: tuple[str, ...] + companions: tuple[str, ...] + unlisted_named_widgets: tuple[str, ...] + + @property + def panes(self) -> tuple[str, ...]: + return self.primary + self.companions + + +@dataclass(frozen=True) +class Manifest: + """The parsed manifest. Constructed by :func:`load`, not by hand.""" + + version: int + scope: str + window: Element + views: dict[str, ViewSpec] + region_list: tuple[Region, ...] + panes: tuple[Pane, ...] + styles: tuple[tuple[str, str], ...] + path: Path + note: str = "" + + # ------------------------------------------------------------- accessors + def view_ids(self) -> tuple[str, ...]: + return tuple(self.views) + + def view_spec(self, view: str) -> ViewSpec: + try: + return self.views[view] + except KeyError: + raise KeyError( + f"unknown view {view!r}; the manifest declares {list(self.views)}" + ) from None + + def regions(self, view: str | None = None) -> tuple[Region, ...]: + """Every region, or only those that appear in ``view``.""" + if view is None: + return self.region_list + return tuple(region for region in self.region_list if view in region.views) + + def panes_for(self, view: str) -> tuple[Pane, ...]: + return tuple(pane for pane in self.panes if view in pane.views) + + def elements_for(self, view: str) -> tuple[Element, ...]: + """Every element this view is supposed to show: regions, their members, the panes + and the panes' own contents. + + Membership is declared once, on each entry's own ``views``; the view entry holds the + *arrangement* (``primary``/``companions``) and the recorded drift, not a second copy + of the membership list. + """ + found: list[Element] = [] + for region in self.region_list: + if view not in region.views: + continue + found.append(region.element) + found.extend( + element for element in region.elements if view in element.views + ) + for pane in self.panes: + if view not in pane.views: + continue + found.append(pane.element) + found.extend(element for element in pane.elements if view in element.views) + return tuple(found) + + def all_elements(self) -> tuple[Element, ...]: + found = [self.window] + for region in self.region_list: + found.append(region.element) + found.extend(region.elements) + for pane in self.panes: + found.append(pane.element) + found.extend(pane.elements) + return tuple(found) + + def by_object_name(self) -> dict[str, tuple[Element, ...]]: + """Map a stable ``objectName`` to the elements that carry it. + + A tuple, not a single element: an ``objectName`` is not an identity, and two elements + sharing one is information (it means the name is a style) rather than an error. The + map covers recorded identities only; elements found by ``path`` are absent by design. + """ + found: dict[str, list[Element]] = {} + for element in self.all_elements(): + if element.object_name: + found.setdefault(element.object_name, []).append(element) + return {name: tuple(items) for name, items in found.items()} + + def style_tokens(self) -> dict[str, str]: + """The stylesheet vocabulary: objectNames that are styles, not identities.""" + return dict(self.styles) + + def by_id(self) -> dict[str, Element]: + """Every element by its stable id (ids are unique; ``problems()`` enforces it).""" + return {element.id: element for element in self.all_elements()} + + # ---------------------------------------------------------- self-check + def problems(self) -> list[str]: + """Structural defects in the manifest itself (not in the window). + + The test checks these too: a spec with a duplicated id, an unreachable lookup, an + unknown condition or a dangling pane reference would otherwise surface as a + mysterious window defect. + """ + problems: list[str] = [] + seen: dict[str, int] = {} + for element in self.all_elements(): + seen[element.id] = seen.get(element.id, 0) + 1 + for element_id, count in sorted(seen.items()): + if count > 1: + problems.append( + f"duplicate element id {element_id!r} ({count} entries)" + ) + paths: dict[str, str] = {} + for element in self.all_elements(): + if not element.path and not element.object_name: + problems.append(f"{element.id}: needs a path or an objectName") + if element.condition and element.condition not in CONDITIONS: + problems.append( + f"{element.id}: unknown condition {element.condition!r}" + ) + if element.role not in _VALID_ROLES: + problems.append(f"{element.id}: unknown role {element.role!r}") + for view in element.views: + if view not in self.views: + problems.append(f"{element.id}: unknown view {view!r}") + if element.path: + previous = paths.get(element.path) + if previous and previous != element.id: + problems.append( + f"path {element.path!r} is claimed by both {previous!r} and " + f"{element.id!r}" + ) + paths[element.path] = element.id + for name, elements in self.by_object_name().items(): + if len(elements) > 1 and any(not element.path for element in elements): + problems.append( + f"objectName {name!r} is shared by " + f"{[element.id for element in elements]} without paths" + ) + style_names = set(self.style_tokens()) + for element in self.all_elements(): + for token in (element.style or "").split("/"): + token = token.strip() + if token and token not in style_names: + problems.append( + f"{element.id}: style {token!r} is not in style_tokens" + ) + for name in self.style_tokens(): + if name in self.by_object_name(): + problems.append( + f"{name!r} is declared both as an element's objectName and as a style" + ) + pane_ids = {pane.id for pane in self.panes} + for spec in self.views.values(): + for pane_id in spec.panes: + if pane_id not in pane_ids: + problems.append(f"view {spec.id!r}: unknown pane {pane_id!r}") + for name in spec.unlisted_named_widgets: + if name in self.by_object_name() or name in style_names: + problems.append( + f"view {spec.id!r}: {name!r} is recorded as unlisted but the " + "manifest already accounts for it" + ) + for pane in self.panes: + listed = { + view_id for view_id, spec in self.views.items() if pane.id in spec.panes + } + if listed != set(pane.views): + problems.append( + f"pane {pane.id!r}: views {sorted(pane.views)} disagree with the view " + f"lists {sorted(listed)}" + ) + return problems + + +# ------------------------------------------------------------------------------ load + + +def _views_of(raw: Any, default_views: tuple[str, ...], where: str) -> tuple[str, ...]: + if raw is None or raw == "all": + return default_views + if isinstance(raw, str): + return (raw,) + if isinstance(raw, list) and all(isinstance(item, str) for item in raw): + return tuple(raw) + raise ValueError( + f"{where}: 'views' must be 'all', a view id, or a list of view ids" + ) + + +def _attrs_of(raw: Any, where: str) -> tuple[tuple[str, Any], ...]: + if raw is None: + return () + if not isinstance(raw, dict): + raise ValueError(f"{where}: 'attrs' must be an object") + for key, value in raw.items(): + if not isinstance(key, str) or not key: + raise ValueError(f"{where}: attribute names must be non-empty strings") + if isinstance(value, (dict, list)): + raise ValueError(f"{where}: attribute {key!r} must be a scalar") + return tuple((str(key), value) for key, value in raw.items()) + + +def _element( + raw: dict, + default_views: tuple[str, ...], + group: str, + role: str, + *, + default_condition: str | None = None, +) -> Element: + element_id = str(raw.get("id") or "") + if not element_id: + raise ValueError(f"{group}: an element has no id: {raw!r}") + where = f"{group}.{element_id}" + class_name = raw.get("class") + if not isinstance(class_name, str) or not class_name: + raise ValueError(f"{where}: 'class' is required") + purpose = raw.get("purpose") + if not isinstance(purpose, str) or not purpose: + raise ValueError(f"{where}: 'purpose' is required") + object_name = raw.get("objectName") + if object_name is not None and not isinstance(object_name, str): + raise ValueError(f"{where}: 'objectName' must be a string or null") + path = raw.get("path") + if path is not None and not isinstance(path, str): + raise ValueError(f"{where}: 'path' must be a string or null") + condition = raw.get("condition") + if condition is not None and not isinstance(condition, str): + raise ValueError(f"{where}: 'condition' must be a string or null") + return Element( + id=element_id, + object_name=object_name or None, + class_name=class_name, + purpose=purpose, + path=path or None, + views=_views_of(raw.get("views"), default_views, where), + # A condition declared on the region or pane applies to its contents: they cannot + # exist while it does not, and repeating it on every child would be noise. + condition=condition or default_condition, + style=raw.get("style") or None, + role=str(raw.get("role") or role), + group=group, + attrs=_attrs_of(raw.get("attrs"), where), + ) + + +def _rows( + entry: dict, + default_views: tuple[str, ...], + group: str, + role: str, + default_condition: str | None = None, +) -> tuple[Element, ...]: + """Expand a ``rows`` template into one element per row. + + The three force channels have identical controls, so the manifest describes the row once + with ``{i}``/``{name}`` placeholders instead of listing it three times. Expansion is a + deliberate, narrow exception to "what is written is what is checked": the placeholders are + substituted here, and every expanded element is an ordinary element afterwards. + """ + template = entry.get("rows") + if not template: + return () + if not isinstance(template, dict) or not isinstance(template.get("template"), dict): + raise ValueError(f"{group}: 'rows' needs a 'template' object") + values = template.get("values") + if not isinstance(values, list) or not values: + raise ValueError(f"{group}: 'rows.values' must be a non-empty list") + names = template.get("names") + if names is not None and (not isinstance(names, list) or len(names) != len(values)): + raise ValueError(f"{group}: 'rows.names' must match 'rows.values' in length") + keys = tuple(template.get("keys") or ()) + if not keys: + raise ValueError(f"{group}: 'rows.keys' must name the per-row entries") + expanded: list[Element] = [] + for index, value in enumerate(values): + fields = {"i": index, "value": value} + if names is not None: + fields["name"] = names[index] + for key in keys: + raw = template["template"].get(key) + if not isinstance(raw, dict): + raise ValueError(f"{group}: rows.template.{key} must be an object") + + def substitute(text: str, _fields=fields, _key=key) -> str: + try: + return text.format(**_fields) + except (KeyError, IndexError, ValueError) as exc: + raise ValueError( + f"{group}: rows.template.{_key} has a bad placeholder: {exc}" + ) from None + + item = { + name: substitute(value) if isinstance(value, str) else value + for name, value in raw.items() + } + expanded.append( + _element( + item, + default_views, + group, + role, + default_condition=default_condition, + ) + ) + return tuple(expanded) + + +def _elements_of( + entry: dict, + default_views: tuple[str, ...], + group: str, + role: str, + default_condition: str | None = None, +) -> tuple[Element, ...]: + elements = tuple( + _element(raw, default_views, group, role, default_condition=default_condition) + for raw in entry.get("elements") or () + ) + return elements + _rows(entry, default_views, group, role, default_condition) + + +def parse(payload: dict, path: Path | str = MANIFEST_PATH) -> Manifest: + """Turn a decoded manifest into the model, validating its shape as it goes.""" + default_views = tuple(payload.get("views") or ()) + if not default_views: + raise ValueError("manifest declares no views") + window = _element( + {**(payload.get("window") or {}), "views": "all"}, + default_views, + "window", + "window", + ) + regions: list[Region] = [] + for raw_region in payload.get("regions") or (): + region_id = str(raw_region.get("id") or "") + if not region_id: + raise ValueError("a region has no id") + region_class = raw_region.get("class") + if not isinstance(region_class, str) or not region_class: + raise ValueError(f"region {region_id}: 'class' is required") + region_purpose = raw_region.get("purpose") + if not isinstance(region_purpose, str) or not region_purpose: + raise ValueError(f"region {region_id}: 'purpose' is required") + region_views = _views_of( + raw_region.get("views"), default_views, f"region {region_id}" + ) + region_condition = raw_region.get("condition") or None + regions.append( + Region( + id=region_id, + class_name=region_class, + purpose=region_purpose, + path=raw_region.get("path") or None, + object_name=raw_region.get("objectName") or None, + views=region_views, + elements=_elements_of( + raw_region, region_views, region_id, "element", region_condition + ), + condition=region_condition, + attrs=_attrs_of(raw_region.get("attrs"), f"region {region_id}"), + ) + ) + panes: list[Pane] = [] + for raw_pane in payload.get("panes") or (): + pane_id = str(raw_pane.get("id") or "") + if not pane_id: + raise ValueError("a pane has no id") + pane_class = raw_pane.get("class") + if not isinstance(pane_class, str) or not pane_class: + raise ValueError(f"pane {pane_id}: 'class' is required") + pane_purpose = raw_pane.get("purpose") + if not isinstance(pane_purpose, str) or not pane_purpose: + raise ValueError(f"pane {pane_id}: 'purpose' is required") + pane_views = _views_of(raw_pane.get("views"), default_views, f"pane {pane_id}") + pane_condition = raw_pane.get("condition") or None + panes.append( + Pane( + id=pane_id, + class_name=pane_class, + purpose=pane_purpose, + path=raw_pane.get("path") or None, + object_name=raw_pane.get("objectName") or None, + style=raw_pane.get("style") or None, + views=pane_views, + elements=_elements_of( + raw_pane, pane_views, pane_id, "element", pane_condition + ), + condition=pane_condition, + ) + ) + views: dict[str, ViewSpec] = {} + for view_id, raw_spec in (payload.get("views") or {}).items(): + if not isinstance(raw_spec, dict): + raise ValueError(f"view {view_id!r}: must be an object") + views[view_id] = ViewSpec( + id=view_id, + purpose=str(raw_spec.get("purpose") or ""), + primary=tuple(raw_spec.get("primary") or ()), + companions=tuple(raw_spec.get("companions") or ()), + unlisted_named_widgets=tuple(raw_spec.get("unlisted_named_widgets") or ()), + ) + styles: list[tuple[str, str]] = [] + for raw_style in payload.get("style_tokens") or (): + if not isinstance(raw_style, dict) or not raw_style.get("name"): + raise ValueError("each style token needs a name") + styles.append((str(raw_style["name"]), str(raw_style.get("purpose") or ""))) + return Manifest( + version=int(payload.get("version") or 0), + scope=str(payload.get("scope") or ""), + window=window, + views=views, + region_list=tuple(regions), + panes=tuple(panes), + styles=tuple(styles), + path=Path(path), + note=str(payload.get("note") or ""), + ) + + +@lru_cache(maxsize=8) +def load(path: str | Path | None = None) -> Manifest: + """Read and parse the manifest (cached per path: it is read-only at runtime).""" + target = Path(path) if path is not None else MANIFEST_PATH + if not target.is_file(): + raise FileNotFoundError( + f"UI element manifest not found: {target}. It is an authored document; see " + "docs/ui-layout.md and tools/ui_elements_dump.py to draft a replacement." + ) + with target.open(encoding="utf-8") as handle: + payload = json.load(handle) + return parse(payload, target) + + +def manifest() -> Manifest: + """The project's manifest (the common case for callers).""" + return load() + + +def _default_manifest() -> Manifest: + """The manifest to compare against when a caller did not pass one.""" + return manifest() + + +# ------------------------------------------------------------------- window access + + +def _resolve_path(root: Any, path: str) -> Any | None: + if path == ".": + return root + node = root + for segment in path.split("."): + match = _SEGMENT.match(segment) + if match is None: + return None + name, call, index = match.groups() + try: + if not hasattr(node, name): + return None + node = getattr(node, name) + if call: + node = node() + if index is not None: + node = node[int(index)] + except Exception: # noqa: BLE001 - a path that cannot be walked is a missing element + return None + return node + + +def resolve(window: Any, element: Element) -> Any | None: + """The live object an element names, or ``None``. + + ``path`` wins because it is unambiguous; an element with only an ``objectName`` is looked + up among the window's descendants. A widget is returned as it is, including one that is + not visible: the manifest states what exists, and visibility belongs to the view. + """ + if window is None: + return None + if element.path: + return _resolve_path(window, element.path) + if not element.object_name: + return None + from PySide6.QtCore import QObject + + return window.findChild(QObject, element.object_name) + + +def condition_holds(window: Any, condition: str | None) -> bool: + """Whether a named condition is true for this window. + + ``None`` (an unconditional element) is always true. An unknown condition is false and is + reported by ``Manifest.problems()``, so a typo cannot silently skip an element. + """ + if not condition: + return True + if condition == "mesh3d_view": + return getattr(window.mesh3d_panel, "view", None) is not None + if condition == "mesh3d_panel": + view = getattr(window.mesh3d_panel, "view", None) + return getattr(view, "panel", None) is not None + if condition == "video_source": + return window.toolbar.source_mode() == "video" + if condition == "sensor_dialog_open": + return window.sensor_dialog is not None + return False + + +def descendants(window: Any) -> Iterator[Any]: + """Every ``QWidget`` below ``window``, the window itself first.""" + from PySide6.QtWidgets import QWidget + + yield window + yield from window.findChildren(QWidget) + + +def visible_named_widgets(window: Any) -> set[str]: + """Names of the visible widgets: what "on screen in this view" means here. + + Only visible widgets count, so the set describes the view rather than the whole + lazily-built tree, and Qt's own internal names are dropped because they are not part of + the interface. + """ + return { + widget.objectName() + for widget in descendants(window) + if widget.isVisible() + and widget.objectName() + and not widget.objectName().startswith(QT_INTERNAL_PREFIX) + } + + +def _accounted_names(manifest_obj: Manifest) -> set[str]: + """Names the manifest already explains: element identities and declared styles.""" + names = set(manifest_obj.style_tokens()) + names.update( + element.object_name + for element in manifest_obj.all_elements() + if element.object_name + ) + return names + + +# ---------------------------------------------------------------------- comparison + + +@dataclass(frozen=True) +class ClassMismatch: + """An element that resolved to the wrong Qt class.""" + + element: Element + found: str + + def __str__(self) -> str: + return ( + f"{self.element.id} ({self.element.key}): {self.found} != " + f"{self.element.class_name}" + ) + + +@dataclass(frozen=True) +class AttrMismatch: + """An element whose recorded attribute is not the value on the widget.""" + + element: Element + name: str + expected: Any + found: Any + + def __str__(self) -> str: + return ( + f"{self.element.id} ({self.element.key}).{self.name}: " + f"{self.found!r} != {self.expected!r}" + ) + + +@dataclass(frozen=True) +class Validation: + """The structured result of ``validate`` for one window and one view.""" + + view: str + checked: int + skipped: tuple[Element, ...] = () + missing: tuple[Element, ...] = () + wrong_class: tuple[ClassMismatch, ...] = () + wrong_attrs: tuple[AttrMismatch, ...] = () + unlisted: tuple[str, ...] = () + unlisted_recorded: tuple[str, ...] = () + pane_visibility: tuple[str, ...] = () + + @property + def new_unlisted(self) -> tuple[str, ...]: + """Named widgets present now that the manifest does not record.""" + return tuple(sorted(set(self.unlisted) - set(self.unlisted_recorded))) + + @property + def stale_unlisted(self) -> tuple[str, ...]: + """Names the manifest records that are no longer on screen.""" + return tuple(sorted(set(self.unlisted_recorded) - set(self.unlisted))) + + @property + def drift(self) -> bool: + """Whether the drift report differs from the drift the manifest records.""" + return bool(self.new_unlisted or self.stale_unlisted) + + @property + def ok(self) -> bool: + return not ( + self.missing + or self.wrong_class + or self.wrong_attrs + or self.pane_visibility + or self.drift + ) + + def summary(self) -> str: + lines = [ + f"view {self.view}: {self.checked} element(s) checked, " + f"{len(self.skipped)} skipped by condition" + ] + for element in self.missing: + lines.append(f" missing: {element.id} ({element.key})") + for mismatch in self.wrong_class: + lines.append(f" wrong class: {mismatch}") + for mismatch in self.wrong_attrs: + lines.append(f" wrong attribute: {mismatch}") + for problem in self.pane_visibility: + lines.append(f" pane visibility: {problem}") + for name in self.new_unlisted: + lines.append(f" unlisted named widget: {name!r}") + for name in self.stale_unlisted: + lines.append(f" recorded but no longer on screen: {name!r}") + return "\n".join(lines) + + +def missing( + window: Any, view: str, manifest_obj: Manifest | None = None +) -> list[Element]: + """Elements this view expects that the window does not have (condition permitting).""" + spec = (manifest_obj or _default_manifest()).elements_for(view) + return [ + element + for element in spec + if condition_holds(window, element.condition) + and resolve(window, element) is None + ] + + +def wrong_class( + window: Any, view: str, manifest_obj: Manifest | None = None +) -> list[ClassMismatch]: + """Elements that exist but are not the recorded Qt class.""" + found: list[ClassMismatch] = [] + for element in (manifest_obj or _default_manifest()).elements_for(view): + if not condition_holds(window, element.condition): + continue + widget = resolve(window, element) + if widget is None: + continue + actual = type(widget).__name__ + if actual != element.class_name: + found.append(ClassMismatch(element=element, found=actual)) + return found + + +def wrong_attrs( + window: Any, view: str, manifest_obj: Manifest | None = None +) -> list[AttrMismatch]: + """Recorded structural attributes (a table's column count, a splitter's child count) + that the live widget does not have. + + A recorded ``objectName`` is checked here too: the name is part of what the manifest + claims about the widget, so a rename is reported rather than silently ignored. + """ + found: list[AttrMismatch] = [] + for element in (manifest_obj or _default_manifest()).elements_for(view): + if not condition_holds(window, element.condition): + continue + widget = resolve(window, element) + if widget is None: + continue + checks = list(element.attrs) + if element.object_name: + checks.append(("objectName", element.object_name)) + for name, expected in checks: + actual = getattr(widget, name, None) + if callable(actual): + actual = actual() + if actual != expected: + found.append( + AttrMismatch( + element=element, name=name, expected=expected, found=actual + ) + ) + return found + + +def unlisted_named_widgets( + window: Any, view: str, manifest_obj: Manifest | None = None +) -> list[str]: + """Visible named widgets this view has that the manifest does not account for. + + A *report*, not a verdict: the manifest is expected to lag, so the suite compares this + with the drift the manifest records instead of requiring it to be empty. + """ + manifest_obj = manifest_obj or _default_manifest() + return sorted(visible_named_widgets(window) - _accounted_names(manifest_obj)) + + +def pane_visibility_problems( + window: Any, view: str, manifest_obj: Manifest | None = None +) -> list[str]: + """Panes on screen in a view that does not list them, or missing from the one that does. + + The view entry's ``primary`` and ``companions`` are the arrangement, so they are also the + statement of what is on screen: a pane listed there must be visible once its condition + holds, and a pane that is not listed must not be. + """ + manifest_obj = manifest_obj or _default_manifest() + spec = manifest_obj.view_spec(view) + problems: list[str] = [] + for pane in manifest_obj.panes: + widget = resolve(window, pane.element) + if widget is None: + continue + expected = pane.id in spec.panes and condition_holds(window, pane.condition) + visible = bool(widget.isVisible()) + if expected and not visible: + problems.append(f"{pane.id} is listed in {view} but is not visible") + elif visible and pane.id not in spec.panes: + problems.append(f"{pane.id} is visible in {view} but is not listed there") + return problems + + +def validate( + window: Any, view: str, manifest_obj: Manifest | None = None +) -> Validation: + """Everything the manifest can say about this window in this view, in one result.""" + manifest_obj = manifest_obj or _default_manifest() + elements = manifest_obj.elements_for(view) + skipped = tuple( + element + for element in elements + if not condition_holds(window, element.condition) + ) + return Validation( + view=view, + checked=len(elements) - len(skipped), + skipped=skipped, + missing=tuple(missing(window, view, manifest_obj)), + wrong_class=tuple(wrong_class(window, view, manifest_obj)), + wrong_attrs=tuple(wrong_attrs(window, view, manifest_obj)), + unlisted=tuple(unlisted_named_widgets(window, view, manifest_obj)), + unlisted_recorded=manifest_obj.view_spec(view).unlisted_named_widgets, + pane_visibility=tuple(pane_visibility_problems(window, view, manifest_obj)), + ) diff --git a/apps/studio/tests/test_ui_elements.py b/apps/studio/tests/test_ui_elements.py new file mode 100644 index 0000000..21044ab --- /dev/null +++ b/apps/studio/tests/test_ui_elements.py @@ -0,0 +1,301 @@ +"""The UI element manifest against a real window, in every view. + +``docs/ui-elements.json`` is the machine-readable half of the layout contract in +``docs/ui-layout.md``: the prose says who owns the arrangement, the manifest says *which +elements are supposed to exist*. These tests make that claim falsifiable against a real +``StudioMainWindow``: + +* every element the view declares exists, is reachable from the window and is the recorded + Qt class (plus the recorded structural attributes, and the recorded ``objectName``); +* the view's panes are the ones the manifest says are on screen; +* the drift report — visible named widgets the manifest accounts for neither as an element + identity nor as a declared stylesheet token — equals the drift the manifest records; +* every condition the manifest uses is exercised by some window here, so no element is + permanently skipped; +* the transport group the manifest declares is the behaviour of a *live* session: a running + session keeps the tracker reset available and reads "pause", and a paused session reads + "resume". That is the defect class this artifact exists to prevent — a state assertion + about widgets passing while the visible result is wrong. + +A deliberate asymmetry: the existence assertions are hard (a missing element fails), while +the drift report is compared with a record. The manifest is expected to lag the code, and the +point of the report is to *show* the difference rather than to pretend it does not exist. +""" + +from __future__ import annotations + +import pytest +import ui_check +from orisys_app_shared.i18n import t +from orisys_studio.ui import elements +from PySide6.QtWidgets import QApplication +from tests.conftest import finish_run, show_window + +VIEWS = ("live", "replay", "calibration") +SIZE = (1440, 880) +#: The four windows the conditional elements need. ``box`` needs nothing; the others are the +#: states a condition names (see ``elements.CONDITIONS``). +SCENARIOS = { + "box": {}, + "finger": {"sensor_type": "finger"}, + "video": {"sensor_type": "box", "source_mode": "video"}, + "dialog": {"sensor_type": "box"}, +} +#: The condition each scenario must satisfy, so a scenario cannot pass by satisfying nothing. +SCENARIO_CONDITIONS = { + "box": set(), + "finger": {"mesh3d_view", "mesh3d_panel"}, + "video": {"video_source"}, + "dialog": {"sensor_dialog_open"}, +} + + +def _prepare(scenario: str, window) -> None: + """Put a window into the state its scenario names.""" + if scenario == "video": + window.toolbar.set_video_path("C:/tmp/ui-elements-demo.mp4") + window._set_source_mode("video") + elif scenario == "dialog": + window._open_sensor_dialog() + QApplication.processEvents() + + +def _window(make_window, scenario: str, view: str): + window = show_window(make_window(**SCENARIOS[scenario]), SIZE) + window.layout_ctl.apply_preset(view) + _prepare(scenario, window) + return window + + +def _running_session(make_window): + """A window with a live session that has actually started. + + ``conftest.running_window`` states the phase directly, which skips the first-frame + transition (and with it the rail update) that this test is about; this is the same setup + without the shortcut, so the transport and the reset reach their states through the + application's own path. + """ + window = show_window(make_window(sensor_type="box", source_mode="camera"), SIZE) + stuck = ui_check._StuckWorker() + window._ensure_workers = lambda: None + window._box_worker = stuck + window._finger_worker = stuck + window._start_run() + window._run_timer.stop() + stuck.emit(ui_check._fake_packet(1)) + window._on_run_tick() + return window, stuck + + +# ------------------------------------------------------------------- the spec itself + + +def test_the_manifest_itself_is_sound(): + """A spec with a duplicated id, a dangling pane or an unknown condition is not a spec.""" + manifest = elements.manifest() + assert manifest.version == 1, "the manifest states a schema version" + assert manifest.scope == "apps/studio" + assert manifest.path.is_file(), manifest.path + assert manifest.problems() == [], manifest.problems() + assert manifest.regions(), "no regions declared" + assert manifest.panes, "no panes declared" + assert manifest.style_tokens(), ( + "the stylesheet vocabulary is what the audit subtracts" + ) + for view in VIEWS: + assert manifest.regions(view), f"{view} has no regions" + assert manifest.panes_for(view), f"{view} has no panes" + assert manifest.elements_for(view), f"{view} has no elements" + assert manifest.view_spec(view).purpose, f"{view} has no stated purpose" + by_name = manifest.by_object_name() + assert by_name, "no element records a stable objectName" + assert all(items for items in by_name.values()) + assert len(manifest.by_id()) == len(manifest.all_elements()) + + +def test_every_declared_condition_is_used_and_reaches_a_window(make_window): + """Conditions are the escape hatch for lazily-built elements, so prove each one opens. + + A manifest with a condition no window ever satisfies is a manifest that quietly checks + less than it appears to, and a condition the loader declares but the manifest never uses + is vocabulary that can rot. + """ + manifest = elements.manifest() + used = { + element.condition for element in manifest.all_elements() if element.condition + } + assert used <= set(elements.CONDITIONS), "undeclared condition in the manifest" + assert used == set(elements.CONDITIONS), ( + f"unused conditions in the loader: {sorted(set(elements.CONDITIONS) - used)}" + ) + exercised: set[str] = set() + for scenario, expected in SCENARIO_CONDITIONS.items(): + window = _window(make_window, scenario, "live") + result = elements.validate(window, "live") + assert result.ok, f"{scenario}:\n{result.summary()}" + held = { + name + for name in elements.CONDITIONS + if elements.condition_holds(window, name) + } + assert held == expected, f"{scenario}: satisfied {sorted(held)}" + exercised |= held + window.close() + window.deleteLater() + assert exercised == set(elements.CONDITIONS), ( + f"no window satisfies {sorted(set(elements.CONDITIONS) - exercised)}" + ) + + +# -------------------------------------------------------------- the elements themselves + + +@pytest.mark.parametrize("view", VIEWS) +def test_every_element_of_the_view_exists_is_reachable_and_is_the_recorded_class( + make_window, view +): + window = _window(make_window, "box", view) + try: + absent = elements.missing(window, view) + assert absent == [], [element.key for element in absent] + assert elements.wrong_class(window, view) == [] + result = elements.validate(window, view) + assert result.wrong_attrs == (), result.summary() + assert result.pane_visibility == (), result.summary() + # Nothing is silently skipped: the checked and the skipped elements are the manifest's + assert result.checked + len(result.skipped) == len( + elements.manifest().elements_for(view) + ) + finally: + window.close() + + +@pytest.mark.parametrize("view", VIEWS) +def test_the_named_widget_audit_matches_the_manifest(make_window, view): + """The drift report is compared with the recorded drift, in both directions. + + A new named widget changes the report and fails here until the manifest accounts for it + (as an element, or as a declared stylesheet token); a recorded name that disappears from + the window fails too. The report is deliberately not part of the existence test above. + """ + window = _window(make_window, "box", view) + try: + manifest = elements.manifest() + on_screen = elements.visible_named_widgets(window) + assert on_screen, "the audit would be vacuous: no visible named widget at all" + audit = elements.unlisted_named_widgets(window, view) + recorded = list(manifest.view_spec(view).unlisted_named_widgets) + assert audit == recorded, ( + f"{view}: named widgets the manifest does not account for: " + f"new={sorted(set(audit) - set(recorded))}, " + f"recorded but gone={sorted(set(recorded) - set(audit))}" + ) + result = elements.validate(window, view) + assert result.new_unlisted == () and result.stale_unlisted == () + assert result.drift is False + finally: + window.close() + + +# ------------------------------------------------------------------- the views' panes + + +@pytest.mark.parametrize("view", VIEWS) +def test_the_panes_on_screen_are_the_views_own(make_window, view): + """The view entry's primary/companion lists are the arrangement *and* what is visible.""" + window = _window(make_window, "finger", view) + try: + spec = elements.manifest().view_spec(view) + visible = { + pane.id + for pane in elements.manifest().panes + if (widget := elements.resolve(window, pane.element)) is not None + and widget.isVisible() + } + assert visible == set(spec.panes), ( + f"{view}: on screen {sorted(visible)} != declared {sorted(spec.panes)}" + ) + finally: + window.close() + + +# --------------------------------------------------------------- the transport contract + + +def test_the_transport_group_matches_the_live_session_states(make_window): + """The defect class: a view switch silently taking the session's reset away. + + The manifest declares the rail's transport group (``transport``, ``stop``, ``reset``, + ``record``); this drives the real session and reads those elements back, so the states + the group was fixed to produce are the states the manifest names. + """ + manifest = elements.manifest() + by_id = manifest.by_id() + group = ("transport", "stop", "reset", "record") + assert set(group) <= set(by_id), ( + "the manifest no longer declares the transport group" + ) + assert {by_id[name].group for name in group} == {"rail"}, ( + "the transport group left the rail" + ) + + window, stuck = _running_session(make_window) + try: + transport = elements.resolve(window, by_id["transport"]) + reset = elements.resolve(window, by_id["reset"]) + stop = elements.resolve(window, by_id["stop"]) + assert transport is window.toolbar.btn_run + assert reset is window.toolbar.btn_reset + assert window._session_phase == "running", window._session_phase + + # A running session: the transport reads pause and the reset is available. + assert transport.isChecked(), ( + "a running session must show the transport engaged" + ) + assert transport.text() == t("toolbar.pause"), transport.text() + assert reset.isEnabled(), "a running session must offer the tracker reset" + assert stop.isEnabled(), "a running session must offer stop" + + # Paused: the transport reads resume, and the device action stays available. + transport.setChecked(False) + assert window._display_paused is True, "unchecking the transport did not pause" + assert transport.text() == t("toolbar.resume_label"), transport.text() + assert reset.isEnabled(), ( + "pausing took the tracker reset away: the device is still open" + ) + + # The view switch that used to disable it: the session is unchanged throughout. + window.brand_bar.btn_calibration.click() + QApplication.processEvents() + assert window.layout_ctl.view_preset == "calibration" + assert reset.isEnabled(), "the calibration view disabled a live session's reset" + assert transport.text() == t("toolbar.resume_label"), ( + "the view switch changed what the transport says about the session" + ) + window.brand_bar.btn_live.click() + QApplication.processEvents() + assert reset.isEnabled(), "the return to the live view lost the session's reset" + + # Resuming: back to the acquiring state. + transport.setChecked(True) + assert window._display_paused is False + assert transport.text() == t("toolbar.pause"), transport.text() + finally: + finish_run(window, stuck) + window.close() + + +def test_the_transport_of_the_replay_view_is_not_the_device_reset(make_window): + """The same controls, the other view: an idle transport and no device to zero.""" + window = _window(make_window, "box", "replay") + try: + by_id = elements.manifest().by_id() + transport = elements.resolve(window, by_id["transport"]) + reset = elements.resolve(window, by_id["reset"]) + assert not transport.isChecked() + assert transport.text() == t("toolbar.playback"), transport.text() + assert not reset.isEnabled(), ( + "reset is offered where there is no tracker to zero" + ) + finally: + window.close() diff --git a/apps/studio/tools/ui_elements_dump.py b/apps/studio/tools/ui_elements_dump.py new file mode 100644 index 0000000..c4fbd77 --- /dev/null +++ b/apps/studio/tools/ui_elements_dump.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Draft a widget inventory of the Studio window, per view, as JSON. + +**The manifest is authored and reviewed, not generated.** ``docs/ui-elements.json`` is a +spec: it states which elements the window is *supposed* to have, so that a test can fail +when the code and the spec disagree. A generated file cannot do that — regenerating it +would silently accept whatever the code happens to contain, which is exactly the drift the +manifest exists to catch. This tool is therefore a **drafting aid**: run it to see the real +tree, then curate the output by hand into the manifest (choose stable ids, drop style +plumbing, write the one-line purpose, and record what is deliberately not modelled). Never +copy its output over the manifest. + +Two further consequences of the same rule, so the tool is not misread as a verifier: + +* it reports what *is* in the tree, including widgets the manifest deliberately leaves out + (styles such as ``contentPanel``, decorative labels, the settings dialog's copy); +* the ``visible_named_widgets`` list it prints is the raw vocabulary to account for — every + name on screen in that view. A name listed there is not a defect: the manifest accounts for + it either as an element identity or as a declared stylesheet token, and what is left over is + the ``views..unlisted_named_widgets`` drift the test compares. + +Usage:: + + python tools/ui_elements_dump.py # every view, box sensor + python tools/ui_elements_dump.py --sensor finger # the 3D pane exists here + python tools/ui_elements_dump.py --view live --out draft.json + +The layout of one view's output matches the manifest: a list of element records +(``id``/``objectName``/``class``/``path``/``parent``/``visible``/``purpose``) plus the +``visible_named_widgets`` list for that view. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +APP_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = APP_ROOT.parents[1] +for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"): + if _path.is_dir() and str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6") +if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: + _fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts" + if _fonts.is_dir(): + os.environ["QT_QPA_FONTDIR"] = str(_fonts) + +#: How deep the attribute walk follows containers. ``mesh3d_panel.view.panel.preset_combo`` +#: is depth 4, so four levels plus one is enough; a deeper tree is a signal to add a path +#: by hand rather than to raise this silently. +MAX_DEPTH = 5 + + +def _is_object(value) -> bool: + from PySide6.QtCore import QObject + + return isinstance(value, QObject) + + +def _is_widget(value) -> bool: + from PySide6.QtWidgets import QWidget + + return isinstance(value, QWidget) + + +def attribute_paths(root) -> dict[int, str]: + """Map ``id(obj)`` to a dotted attribute path from ``root``. + + Only the *first* path found for an object is kept, shortest first, because a widget + reachable two ways (``window.status_chip`` and ``window.toolbar.status_chip``) needs one + canonical name and the shorter one is the better spec. Qt's own getters are seeded by + hand: ``menuBar()`` and ``statusBar()`` are not instance attributes. + """ + from PySide6.QtWidgets import QMenuBar, QStatusBar, QWidget + + paths: dict[int, str] = {id(root): ""} + + def record(value, path: str) -> bool: + known = paths.get(id(value)) + if known is not None and len(known) <= len(path): + return False + paths[id(value)] = path + return True + + def walk(node, prefix: str, depth: int) -> None: + if depth > MAX_DEPTH: + return + try: + attributes = list(vars(node).items()) + except TypeError: + return + for name, value in attributes: + if name.startswith("__"): + continue + child_path = f"{prefix}.{name}" if prefix else name + if _is_object(value): + if getattr(value, "parent", None) is None and not _is_widget(value): + # A controller, a timer or a signal holder: not part of the tree. + continue + if record(value, child_path): + walk(value, child_path, depth + 1) + elif isinstance(value, (list, tuple)): + for index, item in enumerate(value): + if not _is_object(item): + continue + item_path = f"{child_path}[{index}]" + if record(item, item_path): + walk(item, item_path, depth + 1) + + record(root, "") + walk(root, "", 0) + # The two Qt getters that are not attributes: seeded so their children get real paths. + for getter, name in ((root.menuBar, "menuBar()"), (root.statusBar, "statusBar()")): + try: + child = getter() + except Exception: # noqa: BLE001 - a plain QWidget has neither of them + child = None + if child is None: + continue + if record(child, name): + walk(child, name, 1) + # Any other widget Qt owns that the walk did not reach, so the dump is complete. The + # fallback is recorded only where no attribute path was found: an empty path is the + # shortest of all and would otherwise overwrite every real one. + for child in root.findChildren(QWidget): + paths.setdefault(id(child), "") + for child in root.findChildren(QMenuBar) + root.findChildren(QStatusBar): + paths.setdefault(id(child), "") + return paths + + +def _parent_path(widget, paths: dict[int, str]) -> str: + node = widget.parent() if hasattr(widget, "parent") else None + while node is not None: + path = paths.get(id(node)) + if path is not None: + return path + node = node.parent() if hasattr(node, "parent") else None + return "" + + +def dump_view(window, view: str) -> dict: + """One view's element records, in the manifest's shape.""" + from orisys_studio.ui import elements as elements_mod + from PySide6.QtWidgets import QApplication + + window.layout_ctl.apply_preset(view) + for _ in range(3): + QApplication.processEvents() + + paths = attribute_paths(window) + records = [] + for widget in elements_mod.descendants(window): + path = paths.get(id(widget), "") + records.append( + { + "id": "", + "path": path, + "objectName": widget.objectName() or None, + "class": type(widget).__name__, + "parent": _parent_path(widget, paths), + "visible": bool(widget.isVisible()), + "purpose": "", + } + ) + records.sort(key=lambda item: (item["path"], item["class"])) + return { + "view": view, + "preset": window.layout_ctl.view_preset, + "elements": records, + "visible_named_widgets": sorted(elements_mod.visible_named_widgets(window)), + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--view", + action="append", + default=None, + help="view to dump (repeatable; default: all three)", + ) + parser.add_argument("--sensor", default="box", help="sensor type for the window") + parser.add_argument("--theme", default="dark", help="theme for the window") + parser.add_argument("--out", default=None, help="write here instead of stdout") + args = parser.parse_args(argv) + + from orisys_studio.app_window import StudioMainWindow + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() or QApplication(sys.argv) + window = StudioMainWindow( + prefs={ + "language": "zh_CN", + "theme": args.theme, + "sensor_type": args.sensor, + "source_mode": "camera", + } + ) + window.resize(1440, 880) + window.show() + app.processEvents() + try: + views = args.view or ["live", "replay", "calibration"] + payload = { + "window": { + "id": "main_window", + "class": type(window).__name__, + "objectName": window.objectName() or None, + }, + "sensor": args.sensor, + "theme": args.theme, + "views": {view: dump_view(window, view) for view in views}, + } + finally: + window.close() + + text = json.dumps(payload, indent=2, ensure_ascii=False) + if args.out: + Path(args.out).write_text(text + "\n", encoding="utf-8") + print(f"wrote {args.out}") + else: + print(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())