Files
ykcheungab 433b76e43c 修复 Studio 失败测试并新增合并门禁
把丢失的 box 录像放回 apps/studio/tests/data/,修掉第 3 节 3–10 号与两处过时测试桩,
注册 timing 标记并让根目录收集零错误;新增 tools/gate.ps1 与可选的 pre-push 钩子。
验证:studio 524 通过 19 跳过、shared 467 通过 18 跳过、ui_check 632 项 0 失败、gate.ps1 退出码 0。
细节见 docs/test-repair-plan.zh.md。
2026-10-18 19:24:31 +08:00

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 _studio_helpers 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()