6fb2f72c6e
The replay view disabled a control whose display reset is exactly what an operator wants there, and the bare "Reset" hid that the live one zeroes the sensor tracker; the label, tooltip and status line now name the target, and Ctrl+R follows the view. Verified with the studio suite, ui_check (628 checks) and real-GL rail snapshots of both views.
323 lines
14 KiB
Python
323 lines
14 KiB
Python
"""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"; in the replay view the same declared reset is the display reset and says so.
|
|
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 —
|
|
# and it says which of the two things it acts on (the sensor tracker).
|
|
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 window.toolbar.reset_target() == "sensor", (
|
|
"the live view's reset is the sensor tracker's"
|
|
)
|
|
assert reset.text() == t("toolbar.reset_sensor"), reset.text()
|
|
assert reset.toolTip() == t("toolbar.reset_sensor_tip"), reset.toolTip()
|
|
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 a reset that resets the display.
|
|
|
|
The manifest declares one ``reset`` element for both views, so this is where the
|
|
claim that it means *one* thing per view is checked: in the replay view it is the
|
|
display reset (named as such, and there is no device for it to touch), not a
|
|
disabled control and not a tracker 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 window.toolbar.reset_target() == "display", (
|
|
"the replay view's reset is the display's: this view owns no device"
|
|
)
|
|
assert reset.isEnabled(), (
|
|
"the display reset is available in the replay view (the reported defect)"
|
|
)
|
|
assert reset.text() == t("toolbar.reset_display"), reset.text()
|
|
assert reset.toolTip() == t("toolbar.reset_display_tip"), reset.toolTip()
|
|
assert window.act_reset.text() == t("menu.reset_display"), (
|
|
"Ctrl+R is the same control on a second surface and must be named the same way"
|
|
)
|
|
finally:
|
|
window.close()
|