add the Studio element manifest and its window-matching test
ui-layout.md named the arrangement's owners but nothing knew which elements exist, so a view switch could silently take a running session's tracker reset away: docs/ui-elements.json now states all 195 by region, pane and view, ui/elements.py loads it, and tools/ui_elements_dump.py drafts it. Verified: apps/studio 393 items (13 new) and ui_check 600/0, autocal 358, shared 390 with its 2 known wall-clock failures, lint and doc gates clean; a removed element, a renamed objectName and a new named widget each fail the suite.
This commit is contained in:
@@ -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.<view>.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)),
|
||||
)
|
||||
Reference in New Issue
Block a user