Show only the bundle's name in the sensor configuration row
The row carried the origin, the calibration selection and the SDK's verdict, which a 30 px line cannot explain; it is now the descriptor's display_name, else the folder name, and the sensor dialog states the rest under the selector — name and folder, profile, shape, root, verdict, calibration and every finding — with a suffix only where two rows would otherwise read alike. Verified with the studio suite, the catalog tests in shared/tests, ui_check and the lint gate.
This commit is contained in:
@@ -6,19 +6,29 @@ twice with both sides named, and ``start_blocked_reason()`` must be the answer a
|
||||
refuse to start on. The catalog itself is tested in ``shared/tests``; here it is a fixture tree
|
||||
pointed at the widget, so the assertions are about the combo contents rather than the
|
||||
filesystem.
|
||||
|
||||
What a row says is part of that contract: the row is the bundle's name and nothing else, so the
|
||||
tests here also state what a row must *not* contain (the origin, the calibration phrasing, the
|
||||
SDK's verdict, a ``profile_id``). Everything that left the row is asserted on the surface that
|
||||
took it — the dialog's details label, rendered beside the same picker — because that is where an
|
||||
operator reads it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.sensors import FAMILY_UNIDENTIFIED, scan_sensor_catalog
|
||||
from orisys_studio.ui import sensor_picker as picker_module
|
||||
from orisys_studio.ui.sensor_dialog import SensorDialog
|
||||
from orisys_studio.ui.sensor_picker import SensorPickerWidget
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
CONFIG = {
|
||||
"name": "fixture",
|
||||
@@ -36,8 +46,8 @@ def _write(path: Path, payload: object) -> None:
|
||||
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _descriptor(profile_id: str) -> dict[str, object]:
|
||||
return {
|
||||
def _descriptor(profile_id: str, **overrides: object) -> dict[str, object]:
|
||||
data: dict[str, object] = {
|
||||
"schema_version": 1,
|
||||
"profile_id": profile_id,
|
||||
"display_name": "Mini lab",
|
||||
@@ -45,6 +55,39 @@ def _descriptor(profile_id: str) -> dict[str, object]:
|
||||
"processing_backend": "single_camera",
|
||||
"sensor_json": "sensor.json",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
class _Host(QWidget):
|
||||
"""Stand-in for the window the dialog reads its calibration facts from.
|
||||
|
||||
``SensorDialog`` reaches the shell through ``getattr`` for everything but the "reveal the
|
||||
config file" action, which it connects to while it is built — so a bare widget with that one
|
||||
method is enough to render the details surface a test wants to read.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.revealed = 0
|
||||
|
||||
def _on_reveal_config(self) -> None:
|
||||
self.revealed += 1
|
||||
|
||||
|
||||
def _details_dialog(picker) -> SensorDialog:
|
||||
"""The real dialog, rendering the details surface for whatever the picker has selected.
|
||||
|
||||
Not a rendering of the dialog's strings: this is what the operator reads below the row, which
|
||||
is the surface the row's dropped facts had to move into.
|
||||
"""
|
||||
host = _Host()
|
||||
return SensorDialog(window=host, picker=picker)
|
||||
|
||||
|
||||
def _details(picker) -> str:
|
||||
"""The details surface as text, for the current pick."""
|
||||
return _details_dialog(picker).profile_label.text()
|
||||
|
||||
|
||||
def _make_picker(qt_app, tmp_path, monkeypatch, build: Callable[[Path], None]):
|
||||
@@ -110,9 +153,7 @@ def test_a_shared_profile_id_is_listed_twice_and_can_be_refused(mixed_picker):
|
||||
assert picker.config_combo.count() == 2
|
||||
|
||||
|
||||
def test_a_single_valid_entry_is_listed_with_what_it_is_and_not_blocked(
|
||||
qt_app, tmp_path, monkeypatch
|
||||
):
|
||||
def test_a_single_valid_entry_is_listed_by_its_name_alone(qt_app, tmp_path, monkeypatch):
|
||||
def build(root: Path) -> None:
|
||||
_write(root / "mini-lab" / "sensor.json", CONFIG)
|
||||
_write(root / "mini-lab" / "manifest.json", _descriptor("orisys.mini.lab-r1"))
|
||||
@@ -120,21 +161,19 @@ def test_a_single_valid_entry_is_listed_with_what_it_is_and_not_blocked(
|
||||
picker = _make_picker(qt_app, tmp_path, monkeypatch, build)
|
||||
assert picker.type_combo.findData("mini") >= 0
|
||||
picker.set_sensor_type("mini")
|
||||
label = picker.config_combo.itemText(0)
|
||||
# What it is, where it came from, what it gives — and not the profile_id the operator
|
||||
# cannot act on, nor the SDK's verdict word, which says nothing about the force.
|
||||
assert label == "Mini lab · User · No calibration selected — raw force"
|
||||
# The descriptor's display_name, with no origin, no calibration phrasing and no verdict.
|
||||
assert picker.config_combo.itemText(0) == "Mini lab"
|
||||
assert picker.start_blocked_reason() is None
|
||||
entry = picker.selected_entry()
|
||||
assert entry is not None and entry.profile_id == "orisys.mini.lab-r1"
|
||||
# The identity the row dropped is one hover away.
|
||||
# The identity and the shape the row dropped are on the picker's own item tooltip ...
|
||||
tooltip = picker.config_combo.itemData(0, Qt.ItemDataRole.ToolTipRole)
|
||||
assert "orisys.mini.lab-r1" in tooltip
|
||||
assert "descriptor bundle" in tooltip
|
||||
picker.deleteLater()
|
||||
|
||||
|
||||
def test_no_valid_row_shows_internal_identity_or_a_severity_word(mixed_picker):
|
||||
def test_no_row_shows_origin_calibration_identity_or_a_verdict_word(mixed_picker):
|
||||
"""Every row of every family, not only the one the fixture is built around."""
|
||||
picker = mixed_picker
|
||||
seen = 0
|
||||
@@ -142,20 +181,142 @@ def test_no_valid_row_shows_internal_identity_or_a_severity_word(mixed_picker):
|
||||
picker.set_sensor_type(family)
|
||||
for index in range(picker.config_combo.count()):
|
||||
picker.config_combo.setCurrentIndex(index)
|
||||
entry = picker.selected_entry()
|
||||
if entry is None or not entry.valid:
|
||||
continue
|
||||
seen += 1
|
||||
label = picker.config_combo.itemText(index)
|
||||
assert entry.profile_id not in label, label
|
||||
for word in ("ok", "degraded", "failed", "legacy"):
|
||||
assert word not in label, f"{word!r} reached the row: {label}"
|
||||
assert seen, "the fixture offered no valid entry, so nothing was checked"
|
||||
row = picker.config_combo.itemText(index)
|
||||
entry = picker.selected_entry()
|
||||
assert entry is not None
|
||||
# The row is the name itself, or the name plus a collision suffix: nothing else can
|
||||
# have been appended to it.
|
||||
assert row == entry.name or row.startswith(f"{entry.name} ("), row
|
||||
if entry.profile_id:
|
||||
assert entry.profile_id not in row, row
|
||||
for word in (
|
||||
"ok",
|
||||
"degraded",
|
||||
"failed",
|
||||
"legacy",
|
||||
"Shipped",
|
||||
"Calibration",
|
||||
"Cannot start",
|
||||
"Also provided by",
|
||||
):
|
||||
assert re.search(rf"\b{re.escape(word)}\b", row) is None, (
|
||||
f"{word!r} reached the row: {row}"
|
||||
)
|
||||
assert seen, "the fixture offered no entry, so nothing was checked"
|
||||
|
||||
|
||||
def test_a_duplicate_row_names_its_twin(mixed_picker):
|
||||
def test_a_name_two_entries_share_is_told_apart_without_hiding_either(mixed_picker):
|
||||
"""``mini-a`` and ``mini-b`` declare one ``Mini lab``; one root, so the folder separates."""
|
||||
picker = mixed_picker
|
||||
picker.set_sensor_type("mini")
|
||||
assert picker.config_combo.count() == 2, "one of the two was shadowed"
|
||||
first, second = (picker.config_combo.itemText(i) for i in (0, 1))
|
||||
assert first == "Mini lab · User · Also provided by user/mini-b"
|
||||
assert second == "Mini lab · User · Also provided by user/mini-a"
|
||||
assert first == "Mini lab (mini-a)"
|
||||
assert second == "Mini lab (mini-b)"
|
||||
|
||||
|
||||
def test_the_same_name_in_both_roots_is_told_apart_by_origin(qt_app, tmp_path, monkeypatch):
|
||||
"""The operator's ``box`` beside the shipped one: two rows, each naming its root."""
|
||||
shipped = tmp_path / "shipped"
|
||||
user = tmp_path / "user"
|
||||
config = dict(CONFIG, stitcher={"n": [1, 1]})
|
||||
for root in (shipped, user):
|
||||
_write(root / "box" / "sensor.json", config)
|
||||
catalog = scan_sensor_catalog(shipped_root=shipped, user_root=user)
|
||||
monkeypatch.setattr(picker_module, "scan_sensor_catalog", lambda: catalog)
|
||||
|
||||
picker = SensorPickerWidget()
|
||||
picker.set_sensor_type("box")
|
||||
rows = [
|
||||
picker.config_combo.itemText(index)
|
||||
for index in range(picker.config_combo.count())
|
||||
]
|
||||
assert rows == ["Box (shipped)", "Box (user)"], "an entry was collapsed into the other"
|
||||
picker.deleteLater()
|
||||
|
||||
|
||||
def test_the_details_carry_the_name_the_folder_the_profile_shape_state_and_root(
|
||||
qt_app, tmp_path, monkeypatch
|
||||
):
|
||||
"""What left the row is readable below it, in sentences, without a hover."""
|
||||
|
||||
def build(root: Path) -> None:
|
||||
_write(root / "mini-lab" / "sensor.json", CONFIG)
|
||||
_write(root / "mini-lab" / "manifest.json", _descriptor("orisys.mini.lab-r1"))
|
||||
|
||||
picker = _make_picker(qt_app, tmp_path, monkeypatch, build)
|
||||
picker.set_sensor_type("mini")
|
||||
entry = picker.selected_entry()
|
||||
assert entry is not None
|
||||
details = _details(picker)
|
||||
# The declared name and the folder it actually lives in, which the row may not distinguish.
|
||||
assert t("sensor.catalog_name", name=entry.name, folder=entry.folder) in details
|
||||
assert entry.folder in details
|
||||
# The identity, how the bundle was read, and where it was found.
|
||||
assert entry.profile_id in details
|
||||
assert t("sensor.catalog_shape_descriptor") in details
|
||||
assert t("sensor.catalog_root", origin=entry.origin, path=entry.bundle_path) in details
|
||||
# The verdict with what it means, and the calibration as a selection.
|
||||
assert t(
|
||||
"sensor.catalog_state",
|
||||
state=entry.state,
|
||||
note=t("sensor.catalog_state_degraded"),
|
||||
) in details
|
||||
assert t("sensor.catalog_calibration_none") in details
|
||||
# A warning is a sentence about the force, not the SDK's issue code.
|
||||
assert "calibrated force is unavailable; raw force is available" in details
|
||||
|
||||
|
||||
def test_the_details_of_an_unstartable_entry_say_cannot_start_with_the_sdk_reason(
|
||||
qt_app, tmp_path, monkeypatch
|
||||
):
|
||||
def build(root: Path) -> None:
|
||||
_write(root / "broken" / "sensor.json", CONFIG)
|
||||
_write(
|
||||
root / "broken" / "manifest.json",
|
||||
_descriptor(
|
||||
"orisys.finger.broken-r1",
|
||||
display_name="Finger R01 (map missing)",
|
||||
product_family="finger",
|
||||
processing_backend="uv_unroll",
|
||||
computational_assets={"unroll_map": "assets/not-here.npz"},
|
||||
),
|
||||
)
|
||||
|
||||
picker = _make_picker(qt_app, tmp_path, monkeypatch, build)
|
||||
picker.set_sensor_type("finger")
|
||||
entry = picker.selected_entry()
|
||||
assert entry is not None and not entry.valid, entry.reason
|
||||
# The row stays selectable and keeps the name; the refusal is stated below it.
|
||||
assert picker.config_combo.itemText(0) == "Finger R01 (map missing)"
|
||||
assert picker.config_combo.isEnabled()
|
||||
assert picker.start_blocked_reason() == entry.start_blocked_reason
|
||||
details = _details(picker)
|
||||
assert t("sensor.catalog_blocked", reason=entry.start_blocked_reason) in details
|
||||
assert "unroll_map" in details, "the SDK's own reason is not quoted"
|
||||
|
||||
|
||||
def test_the_details_sit_directly_below_the_selector(mixed_picker):
|
||||
"""The warning does not have to be in the row, because it is the next thing read."""
|
||||
dialog = _details_dialog(mixed_picker)
|
||||
root = dialog.layout()
|
||||
assert root.itemAt(0).layout() is dialog.picker_row, "the selector is not the first row"
|
||||
assert root.itemAt(1).widget() is dialog.profile_label, (
|
||||
"the details are not directly under the selector"
|
||||
)
|
||||
|
||||
|
||||
def test_the_details_of_a_duplicate_name_the_twin(mixed_picker):
|
||||
picker = mixed_picker
|
||||
picker.set_sensor_type("mini")
|
||||
for index, other_ref in ((0, "user/mini-b"), (1, "user/mini-a")):
|
||||
picker.config_combo.setCurrentIndex(index)
|
||||
entry = picker.selected_entry()
|
||||
assert entry is not None and entry.duplicates == (other_ref,)
|
||||
details = _details(picker)
|
||||
assert t("sensor.catalog_duplicate", refs=other_ref) in details
|
||||
# The row says nothing about it: the condition is unmissable below the selector.
|
||||
row = picker.config_combo.itemText(index)
|
||||
assert "Also provided by" not in row
|
||||
assert other_ref not in row
|
||||
|
||||
@@ -50,15 +50,16 @@ EXPECTED_SHIPPED: dict[str, tuple[str, str, str, bool, str]] = {
|
||||
"mini": ("mini", "orisys.mini.legacy", "compound_eye", True, "degraded"),
|
||||
}
|
||||
|
||||
#: What an operator reads in the picker, one row per shipped bundle: the product, where it came
|
||||
#: from, and what the force numbers will be. ``box`` is the only shipped bundle that carries a
|
||||
#: ``force_calibration.json``, so it is the only row naming a calibration; the other three are
|
||||
#: stated as "no calibration selected — raw force" rather than as the SDK's ``degraded``.
|
||||
EXPECTED_LABELS: dict[str, str] = {
|
||||
"box": "Box · Shipped · Calibration: force_calibration.json",
|
||||
"finger": "Finger R01 · Shipped · No calibration selected — raw force",
|
||||
"finger-mems": "Finger (MEMS) · Shipped · No calibration selected — raw force",
|
||||
"mini": "Mini · Shipped · No calibration selected — raw force",
|
||||
#: What an operator reads in the picker, one row per shipped bundle: the bundle's name, and
|
||||
#: nothing else. ``finger`` declares ``Finger R01`` in its descriptor; the other three have no
|
||||
#: descriptor and are named by the folder they live in, as the SDK's built-in table words it.
|
||||
#: The origin, the calibration selection and the SDK's ``degraded`` verdict are not rows — they
|
||||
#: are read in the dialog's details surface, which sits under the selector.
|
||||
EXPECTED_ROWS: dict[str, str] = {
|
||||
"box": "Box",
|
||||
"finger": "Finger R01",
|
||||
"finger-mems": "Finger (MEMS)",
|
||||
"mini": "Mini",
|
||||
}
|
||||
|
||||
|
||||
@@ -102,53 +103,74 @@ def test_the_shipped_families_keep_the_picker_order(shipped):
|
||||
assert shipped.families() == ["box", "mini", "finger", "finger-mems"]
|
||||
|
||||
|
||||
def test_every_shipped_row_states_what_it_is_where_from_and_what_it_gives(shipped):
|
||||
"""The four real bundles, exactly as the picker renders them."""
|
||||
for folder, expected in EXPECTED_LABELS.items():
|
||||
def test_every_shipped_row_is_exactly_the_bundles_name(shipped):
|
||||
"""The four real bundles, each as the picker renders it: the name, and nothing else."""
|
||||
for folder, expected in EXPECTED_ROWS.items():
|
||||
entry = shipped.by_ref(f"shipped/{folder}")
|
||||
assert entry is not None, folder
|
||||
assert entry.label() == expected, folder
|
||||
assert shipped.row_label(entry) == expected, folder
|
||||
# The name comes from the descriptor when there is one, else from the folder.
|
||||
assert expected == (entry.display_name or entry.folder), folder
|
||||
|
||||
|
||||
def test_no_shipped_row_carries_identity_severity_or_the_word_legacy(shipped):
|
||||
def test_no_shipped_row_carries_origin_calibration_identity_or_a_verdict(shipped):
|
||||
"""The row is the first thing an operator reads; none of these belong in it.
|
||||
|
||||
``legacy`` means "resolved through the compatibility adapter because it has no descriptor",
|
||||
which is not what the word says to a reader — and the finger bundle's ``profile_id`` is not
|
||||
even that shape, so the column mixed two kinds of string.
|
||||
even that shape, so the column mixed two kinds of string. The origin, the calibration
|
||||
phrasing and the SDK's verdict moved into the details surface under the selector.
|
||||
"""
|
||||
for entry in shipped.entries:
|
||||
label = entry.label()
|
||||
assert entry.profile_id not in label, label
|
||||
for word in ("ok", "degraded", "failed", "legacy"):
|
||||
assert word not in label, f"{word!r} reached the row: {label}"
|
||||
row = shipped.row_label(entry)
|
||||
assert entry.profile_id not in row, row
|
||||
for word in (
|
||||
"ok",
|
||||
"degraded",
|
||||
"failed",
|
||||
"legacy",
|
||||
"Shipped",
|
||||
"User",
|
||||
"Calibration",
|
||||
"Cannot start",
|
||||
"Also provided by",
|
||||
):
|
||||
assert word not in row, f"{word!r} reached the row: {row}"
|
||||
|
||||
|
||||
def test_every_shipped_row_says_what_the_calibration_selection_is(shipped):
|
||||
"""Calibration is a selection, so an absent one reads as raw force, not as a defect."""
|
||||
def test_the_shipped_details_keep_what_the_rows_dropped(shipped):
|
||||
"""Every fact that left the row is still readable, in sentences, one hover away."""
|
||||
for entry in shipped.entries:
|
||||
label = entry.label()
|
||||
selected = entry.selected_calibration
|
||||
if selected:
|
||||
assert f"Calibration: {selected}" in label, label
|
||||
else:
|
||||
assert "No calibration selected — raw force" in label, label
|
||||
# The SDK calls an absent calibration a warning and the bundle "degraded"; the row
|
||||
# says what that means for the operator instead of repeating the verdict.
|
||||
assert entry.state == "degraded", entry.ref
|
||||
|
||||
|
||||
def test_the_shipped_tooltips_keep_the_facts_the_rows_dropped(shipped):
|
||||
for entry in shipped.entries:
|
||||
tooltip = entry.tooltip()
|
||||
assert entry.profile_id in tooltip, entry.ref
|
||||
assert f"SDK validation: {entry.state}" in tooltip, entry.ref
|
||||
detail = entry.tooltip()
|
||||
assert entry.profile_id in detail, entry.ref
|
||||
assert entry.folder in detail, entry.ref
|
||||
assert str(entry.bundle_path) in detail, entry.ref
|
||||
assert f"SDK validation: {entry.state}" in detail, entry.ref
|
||||
assert "something optional is missing" in detail, entry.ref
|
||||
assert f"calibration selection: {entry.calibration_selection}" in detail, entry.ref
|
||||
shape = (
|
||||
"descriptor bundle"
|
||||
if entry.profile_id == FINGER_PROFILE_ID
|
||||
else "compatibility bundle, no descriptor"
|
||||
)
|
||||
assert shape in tooltip, entry.ref
|
||||
assert shape in detail, entry.ref
|
||||
|
||||
|
||||
def test_the_shipped_calibration_selection_is_stated_as_a_selection(shipped):
|
||||
"""Calibration is a selection, so an absent one reads as raw force, not as a defect."""
|
||||
for entry in shipped.entries:
|
||||
selected = entry.selected_calibration
|
||||
if selected:
|
||||
assert entry.calibration_selection == selected, entry.ref
|
||||
else:
|
||||
assert entry.calibration_selection == "No calibration selected — raw force", (
|
||||
entry.ref
|
||||
)
|
||||
# The SDK calls an absent calibration a warning and the bundle "degraded"; the
|
||||
# details say what that means for the operator instead of repeating the verdict.
|
||||
assert entry.state == "degraded", entry.ref
|
||||
# ``box`` is the only shipped bundle that carries a ``force_calibration.json``.
|
||||
assert shipped.by_ref("shipped/box").selected_calibration == "force_calibration.json"
|
||||
|
||||
|
||||
def test_box_mini_and_finger_mems_are_legacy_sensor_json_bundles(shipped):
|
||||
|
||||
Reference in New Issue
Block a user