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:
ykcheungab
2026-10-01 15:18:16 +08:00
parent e0156a14c6
commit eb70ac7638
6 changed files with 2013 additions and 0 deletions
@@ -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)),
)