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:
ykcheungab
2026-10-05 17:01:42 +08:00
parent 92b2f2fcd7
commit d11441cd79
7 changed files with 457 additions and 135 deletions
+15
View File
@@ -10,6 +10,21 @@ second opinion computed in the UI.
identity and its files instead of leaving both to be inferred, which is what lets the bundle keep identity and its files instead of leaving both to be inferred, which is what lets the bundle keep
its meaning when the folder is moved or renamed. its meaning when the folder is moved or renamed.
## Picker rows
A configuration row in the sensor dialog is **the bundle's name and nothing else**: the
descriptor's `display_name` when there is one, otherwise the folder name (`box`, `mini`, `finger`,
`finger-mems`, and whatever the operator called their own folder). Everything else — the origin,
the resolved root, the `profile_id`, the bundle shape, the SDK's verdict with what it means, the
calibration selection and every finding — is written in the details surface directly under the
selector, where the sentences fit.
One exception: two entries offered together that would otherwise read alike each get a suffix.
Across roots that is the origin (`Box (shipped)` beside `Box (user)`); when one root supplies both
(for example one `display_name` declared by two folders) the origin cannot separate them and the
folder is used instead. Nothing is collapsed or shadowed, and the same tree always produces the
same rows.
## The finger descriptor ## The finger descriptor
| Field | Value | Why | | Field | Value | Why |
@@ -22,6 +22,7 @@ from orisys_app_shared.sensors import (
SHAPE_COMPATIBILITY, SHAPE_COMPATIBILITY,
SHAPE_DESCRIPTOR, SHAPE_DESCRIPTOR,
SHAPE_UNREADABLE, SHAPE_UNREADABLE,
STATE_UNREADABLE,
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
@@ -42,6 +43,16 @@ _SHAPE_KEYS: dict[str, str] = {
SHAPE_UNREADABLE: "sensor.catalog_shape_unreadable", SHAPE_UNREADABLE: "sensor.catalog_shape_unreadable",
} }
#: The SDK's verdicts, and the catalog's own "nothing could be read here", as the sentence that
#: says what the verdict means for the operator. Same rule as the shapes: a literal key per state,
#: and an unknown state falls back to words rather than to the identifier the SDK happened to use.
_STATE_KEYS: dict[str, str] = {
"ok": "sensor.catalog_state_ok",
"degraded": "sensor.catalog_state_degraded",
"failed": "sensor.catalog_state_failed",
STATE_UNREADABLE: "sensor.catalog_state_invalid",
}
class SensorDialog(QDialog): class SensorDialog(QDialog):
"""Sensor type / config picker plus the calibration facts that follow it.""" """Sensor type / config picker plus the calibration facts that follow it."""
@@ -62,6 +73,17 @@ class SensorDialog(QDialog):
self.picker_row.addStretch(1) self.picker_row.addStretch(1)
root.addLayout(self.picker_row) root.addLayout(self.picker_row)
#: Everything behind the selected configuration, said in full: the bundle's own name and
#: the folder it lives in, the profile the SDK assigned, how the bundle was read, the
#: root it came from, the verdict with what it means, the calibration selection and every
#: finding — plus, when it must not be started, why. It sits directly under the selector
#: on purpose: the picker row carries the name alone, so this is the surface the rest has
#: to be readable on, without a hover and without hunting for it.
self.profile_label = ShrinkableLabel()
self.profile_label.setObjectName("panelMeta")
self.profile_label.setWordWrap(True)
root.addWidget(self.profile_label)
root.addWidget(self._section_rule()) root.addWidget(self._section_rule())
self.calib_title = QLabel() self.calib_title = QLabel()
self.calib_title.setObjectName("panelTitle") self.calib_title.setObjectName("panelTitle")
@@ -72,12 +94,6 @@ class SensorDialog(QDialog):
self.axes_label.setObjectName("panelMeta") self.axes_label.setObjectName("panelMeta")
self.config_label = ShrinkableLabel() self.config_label = ShrinkableLabel()
self.config_label.setObjectName("panelMeta") self.config_label.setObjectName("panelMeta")
#: What the catalog says the selected configuration *is*: family, profile, origin and
#: the SDK's verdict — plus, when it must not be started, why. The picker can only fit
#: a label on a 30 px rail; the reason needs this room.
self.profile_label = ShrinkableLabel()
self.profile_label.setObjectName("panelMeta")
self.profile_label.setWordWrap(True)
for label in (self.version_label, self.axes_label): for label in (self.version_label, self.axes_label):
root.addWidget(label) root.addWidget(label)
config_row = QHBoxLayout() config_row = QHBoxLayout()
@@ -88,7 +104,6 @@ class SensorDialog(QDialog):
config_row.addWidget(self.btn_reveal) config_row.addWidget(self.btn_reveal)
config_row.addStretch(1) config_row.addStretch(1)
root.addLayout(config_row) root.addLayout(config_row)
root.addWidget(self.profile_label)
root.addWidget(self._section_rule()) root.addWidget(self._section_rule())
self.export_title = QLabel() self.export_title = QLabel()
@@ -203,10 +218,11 @@ class SensorDialog(QDialog):
def _refresh_profile(self) -> None: def _refresh_profile(self) -> None:
"""State the selected catalog entry, and the reason when it cannot be started. """State the selected catalog entry, and the reason when it cannot be started.
This is the surface that keeps the facts the picker row no longer carries: the identity This is the surface that keeps the facts the picker row no longer carries: the name and
the SDK assigned, how the bundle was read and its validation. It is read from the picker the folder, the identity the SDK assigned, how the bundle was read, the root it was found
rather than re-derived here, so the window and the dialog can never say two different in, the verdict with what it means, the calibration selection, and every finding as a
things about one bundle. sentence. It is read from the picker rather than re-derived here, so the window and the
dialog can never say two different things about one bundle.
""" """
read_entry = getattr(self.picker, "selected_entry", None) read_entry = getattr(self.picker, "selected_entry", None)
entry = read_entry() if callable(read_entry) else None entry = read_entry() if callable(read_entry) else None
@@ -214,19 +230,40 @@ class SensorDialog(QDialog):
self.profile_label.setText("") self.profile_label.setText("")
self.profile_label.setToolTip("") self.profile_label.setToolTip("")
return return
line = t( lines = [
t("sensor.catalog_name", name=entry.name, folder=entry.folder),
t(
"sensor.catalog_profile", "sensor.catalog_profile",
profile_id=entry.profile_id or entry.folder, profile_id=entry.profile_id or entry.folder,
shape=t(_SHAPE_KEYS.get(entry.shape, _SHAPE_KEYS[SHAPE_UNREADABLE])), shape=t(_SHAPE_KEYS.get(entry.shape, _SHAPE_KEYS[SHAPE_UNREADABLE])),
origin=entry.origin, origin=entry.origin,
),
t("sensor.catalog_root", origin=entry.origin, path=entry.bundle_path),
t(
"sensor.catalog_state",
state=entry.state, state=entry.state,
) note=t(_STATE_KEYS.get(entry.state, "sensor.catalog_state_unknown")),
),
(
t("sensor.catalog_calibration_selected", name=entry.selected_calibration)
if entry.selected_calibration
else t("sensor.catalog_calibration_none")
),
]
if entry.duplicates:
lines.append(t("sensor.catalog_duplicate", refs=", ".join(entry.duplicates)))
blocked = entry.start_blocked_reason blocked = entry.start_blocked_reason
if blocked: if blocked:
# In the label, not only the tooltip: a refused configuration has to say why without # In the label, not only the tooltip: a refused configuration has to say why without
# being hovered over, and this is the surface that has room for the sentence. # being hovered over, and this is the surface that has room for the sentence.
line = f"{line}\n{t('sensor.catalog_blocked', reason=blocked)}" lines.append(t("sensor.catalog_blocked", reason=blocked))
self.profile_label.setText(line) lines.extend(
t("sensor.catalog_error", sentence=text) for text in entry.error_sentences()
)
lines.extend(
t("sensor.catalog_warning", sentence=text) for text in entry.warning_sentences()
)
self.profile_label.setText("\n".join(lines))
self.profile_label.setToolTip(entry.tooltip()) self.profile_label.setToolTip(entry.tooltip())
def retranslate(self) -> None: def retranslate(self) -> None:
@@ -1,18 +1,22 @@
"""Sensor type + config picker. """Sensor type + config picker.
One row, because it lives at the left of the status rail: a badge naming the One row, because it lives at the left of the status rail: a badge naming the
selected product, then the product/sensor choice and the config file that selected product, then the product/sensor choice and the configuration bundle
belongs to it. The order matters — picking ``box`` / ``mini`` / ``finger`` is that belongs to it. The order matters — picking ``box`` / ``mini`` / ``finger``
what decides which configs exist, so the two sit next to each other with their is what decides which configurations exist, so the two sit next to each other
own labels and tooltips instead of a bare "配置" combo. with their own labels and tooltips instead of a bare "配置" combo.
Both combos are filled from the configuration catalog Both combos are filled from the configuration catalog
(:mod:`orisys_app_shared.sensors`), not from a hard-coded product list: the type (:mod:`orisys_app_shared.sensors`), not from a hard-coded product list: the type
combo offers the families bundles actually exist for, and the config combo offers combo offers the families bundles actually exist for, and the config combo offers
the catalog's entries for the chosen family — each labelled with what it is, where the catalog's entries for the chosen family. A row is the **bundle's name** — the
it came from and what the calibration selection gives, so the choice is between descriptor's ``display_name``, else the folder name — and nothing else; the origin,
products and outcomes rather than between ``profile_id`` strings. An entry the SDK the calibration selection and the SDK's verdict are read in the dialog, whose
rejects stays listed, because that is how its reason reaches the operator, and details surface sits directly below this row. The one exception is a collision: two
entries offered together that would otherwise read identically (``shipped/box``
beside ``sensors-user/box``) each carry the suffix that tells them apart, so no
entry is collapsed or shadowed. An entry the SDK rejects stays listed too, because
that is how its reason reaches the operator, and
:meth:`SensorPickerWidget.start_blocked_reason` states why the current selection :meth:`SensorPickerWidget.start_blocked_reason` states why the current selection
must not be started (see also :meth:`selected_entry`). must not be started (see also :meth:`selected_entry`).
""" """
@@ -186,7 +190,7 @@ class SensorPickerWidget(QWidget):
self.config_combo.blockSignals(True) self.config_combo.blockSignals(True)
self.config_combo.clear() self.config_combo.clear()
for entry in entries: for entry in entries:
self.config_combo.addItem(_entry_label(entry), entry.key) self.config_combo.addItem(_entry_label(self._catalog, entry), entry.key)
self.config_combo.setItemData( self.config_combo.setItemData(
self.config_combo.count() - 1, self.config_combo.count() - 1,
entry.tooltip(), entry.tooltip(),
@@ -238,12 +242,14 @@ def _family_label(family: str) -> str:
return t("sensor.family_unidentified") if family == FAMILY_UNIDENTIFIED else family return t("sensor.family_unidentified") if family == FAMILY_UNIDENTIFIED else family
def _entry_label(entry: CatalogEntry) -> str: def _entry_label(catalog: SensorCatalog, entry: CatalogEntry) -> str:
"""One combo item: the catalog's operator label, with nothing internal in it. """One combo item: the catalog's row for an entry, with nothing internal in it.
The catalog names an entry by its display name, so the internal marker for "no product The row is the bundle's name, and the catalog appends the origin only where two visible
family" cannot reach the row today; it is still substituted here because a sentinel is entries would otherwise read alike, so the internal marker for "no product family" cannot
never something an operator should read, and the item text is the one place it could reach it today; it is still substituted here, where translating belongs, because a sentinel
surface if an entry ever had to fall back to its family. is never something an operator should read and the item text is the one place it could
surface.
""" """
return entry.label().replace(FAMILY_UNIDENTIFIED, t("sensor.family_unidentified")) row = catalog.row_label(entry)
return row.replace(FAMILY_UNIDENTIFIED, t("sensor.family_unidentified"))
+183 -22
View File
@@ -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 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 pointed at the widget, so the assertions are about the combo contents rather than the
filesystem. 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 from __future__ import annotations
import json import json
import re
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
import pytest import pytest
from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import FAMILY_UNIDENTIFIED, scan_sensor_catalog 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 import sensor_picker as picker_module
from orisys_studio.ui.sensor_dialog import SensorDialog
from orisys_studio.ui.sensor_picker import SensorPickerWidget from orisys_studio.ui.sensor_picker import SensorPickerWidget
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import QWidget
CONFIG = { CONFIG = {
"name": "fixture", "name": "fixture",
@@ -36,8 +46,8 @@ def _write(path: Path, payload: object) -> None:
path.write_text(json.dumps(payload, indent=2), encoding="utf-8") path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def _descriptor(profile_id: str) -> dict[str, object]: def _descriptor(profile_id: str, **overrides: object) -> dict[str, object]:
return { data: dict[str, object] = {
"schema_version": 1, "schema_version": 1,
"profile_id": profile_id, "profile_id": profile_id,
"display_name": "Mini lab", "display_name": "Mini lab",
@@ -45,6 +55,39 @@ def _descriptor(profile_id: str) -> dict[str, object]:
"processing_backend": "single_camera", "processing_backend": "single_camera",
"sensor_json": "sensor.json", "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]): 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 assert picker.config_combo.count() == 2
def test_a_single_valid_entry_is_listed_with_what_it_is_and_not_blocked( def test_a_single_valid_entry_is_listed_by_its_name_alone(qt_app, tmp_path, monkeypatch):
qt_app, tmp_path, monkeypatch
):
def build(root: Path) -> None: def build(root: Path) -> None:
_write(root / "mini-lab" / "sensor.json", CONFIG) _write(root / "mini-lab" / "sensor.json", CONFIG)
_write(root / "mini-lab" / "manifest.json", _descriptor("orisys.mini.lab-r1")) _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) picker = _make_picker(qt_app, tmp_path, monkeypatch, build)
assert picker.type_combo.findData("mini") >= 0 assert picker.type_combo.findData("mini") >= 0
picker.set_sensor_type("mini") picker.set_sensor_type("mini")
label = picker.config_combo.itemText(0) # The descriptor's display_name, with no origin, no calibration phrasing and no verdict.
# What it is, where it came from, what it gives — and not the profile_id the operator assert picker.config_combo.itemText(0) == "Mini lab"
# 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"
assert picker.start_blocked_reason() is None assert picker.start_blocked_reason() is None
entry = picker.selected_entry() entry = picker.selected_entry()
assert entry is not None and entry.profile_id == "orisys.mini.lab-r1" 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) tooltip = picker.config_combo.itemData(0, Qt.ItemDataRole.ToolTipRole)
assert "orisys.mini.lab-r1" in tooltip assert "orisys.mini.lab-r1" in tooltip
assert "descriptor bundle" in tooltip assert "descriptor bundle" in tooltip
picker.deleteLater() 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.""" """Every row of every family, not only the one the fixture is built around."""
picker = mixed_picker picker = mixed_picker
seen = 0 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) picker.set_sensor_type(family)
for index in range(picker.config_combo.count()): for index in range(picker.config_combo.count()):
picker.config_combo.setCurrentIndex(index) picker.config_combo.setCurrentIndex(index)
entry = picker.selected_entry()
if entry is None or not entry.valid:
continue
seen += 1 seen += 1
label = picker.config_combo.itemText(index) row = picker.config_combo.itemText(index)
assert entry.profile_id not in label, label entry = picker.selected_entry()
for word in ("ok", "degraded", "failed", "legacy"): assert entry is not None
assert word not in label, f"{word!r} reached the row: {label}" # The row is the name itself, or the name plus a collision suffix: nothing else can
assert seen, "the fixture offered no valid entry, so nothing was checked" # 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 = mixed_picker
picker.set_sensor_type("mini") 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)) first, second = (picker.config_combo.itemText(i) for i in (0, 1))
assert first == "Mini lab · User · Also provided by user/mini-b" assert first == "Mini lab (mini-a)"
assert second == "Mini lab · User · Also provided by user/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
+60 -38
View File
@@ -50,15 +50,16 @@ EXPECTED_SHIPPED: dict[str, tuple[str, str, str, bool, str]] = {
"mini": ("mini", "orisys.mini.legacy", "compound_eye", True, "degraded"), "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 #: What an operator reads in the picker, one row per shipped bundle: the bundle's name, and
#: from, and what the force numbers will be. ``box`` is the only shipped bundle that carries a #: nothing else. ``finger`` declares ``Finger R01`` in its descriptor; the other three have no
#: ``force_calibration.json``, so it is the only row naming a calibration; the other three are #: descriptor and are named by the folder they live in, as the SDK's built-in table words it.
#: stated as "no calibration selected — raw force" rather than as the SDK's ``degraded``. #: The origin, the calibration selection and the SDK's ``degraded`` verdict are not rows — they
EXPECTED_LABELS: dict[str, str] = { #: are read in the dialog's details surface, which sits under the selector.
"box": "Box · Shipped · Calibration: force_calibration.json", EXPECTED_ROWS: dict[str, str] = {
"finger": "Finger R01 · Shipped · No calibration selected — raw force", "box": "Box",
"finger-mems": "Finger (MEMS) · Shipped · No calibration selected — raw force", "finger": "Finger R01",
"mini": "Mini · Shipped · No calibration selected — raw force", "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"] assert shipped.families() == ["box", "mini", "finger", "finger-mems"]
def test_every_shipped_row_states_what_it_is_where_from_and_what_it_gives(shipped): def test_every_shipped_row_is_exactly_the_bundles_name(shipped):
"""The four real bundles, exactly as the picker renders them.""" """The four real bundles, each as the picker renders it: the name, and nothing else."""
for folder, expected in EXPECTED_LABELS.items(): for folder, expected in EXPECTED_ROWS.items():
entry = shipped.by_ref(f"shipped/{folder}") entry = shipped.by_ref(f"shipped/{folder}")
assert entry is not None, 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. """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", ``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 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: for entry in shipped.entries:
label = entry.label() row = shipped.row_label(entry)
assert entry.profile_id not in label, label assert entry.profile_id not in row, row
for word in ("ok", "degraded", "failed", "legacy"): for word in (
assert word not in label, f"{word!r} reached the row: {label}" "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): def test_the_shipped_details_keep_what_the_rows_dropped(shipped):
"""Calibration is a selection, so an absent one reads as raw force, not as a defect.""" """Every fact that left the row is still readable, in sentences, one hover away."""
for entry in shipped.entries: for entry in shipped.entries:
label = entry.label() detail = entry.tooltip()
selected = entry.selected_calibration assert entry.profile_id in detail, entry.ref
if selected: assert entry.folder in detail, entry.ref
assert f"Calibration: {selected}" in label, label assert str(entry.bundle_path) in detail, entry.ref
else: assert f"SDK validation: {entry.state}" in detail, entry.ref
assert "No calibration selected — raw force" in label, label assert "something optional is missing" in detail, entry.ref
# The SDK calls an absent calibration a warning and the bundle "degraded"; the row assert f"calibration selection: {entry.calibration_selection}" in detail, entry.ref
# 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
shape = ( shape = (
"descriptor bundle" "descriptor bundle"
if entry.profile_id == FINGER_PROFILE_ID if entry.profile_id == FINGER_PROFILE_ID
else "compatibility bundle, no descriptor" 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): def test_box_mini_and_finger_mems_are_legacy_sensor_json_bundles(shipped):
@@ -7,8 +7,9 @@ descriptor bundle, a legacy ``sensor.json`` bundle, or an entry that stays liste
with the SDK's own reason. with the SDK's own reason.
The two functions below stay the picker's compatibility surface. The picker itself uses the The two functions below stay the picker's compatibility surface. The picker itself uses the
catalog object directly, so it can show each entry's name, origin and calibration selection, catalog object directly, so it can show each entry's name — with the origin only where two rows
with the identity and the SDK's verdict in the tooltip. would otherwise read alike — and keep the identity and the SDK's verdict in the details surface
below the selector.
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,19 +30,23 @@ def list_sensor_types() -> list[str]:
def list_configs_for_type(sensor_type: str) -> list[tuple[str, str]]: def list_configs_for_type(sensor_type: str) -> list[tuple[str, str]]:
"""Return ``(display_name, config_key)`` entries for one family. """Return ``(row_name, config_key)`` entries for one family.
The key is the configuration file path the app already passes around (``sensor.json``, or The key is the configuration file path the app already passes around (``sensor.json``, or
the bundle folder for an entry that has none), so a selection keeps its meaning. When no the bundle folder for an entry that has none), so a selection keeps its meaning. The name is
bundle exists at all, a single default entry named after the type is offered — for the row the picker shows: the bundle's own name, with the origin appended only where two
preferences and a future Run — exactly as before. entries offered together would read alike (see
:func:`~orisys_app_shared.sensors.bundle_catalog.row_labels`). When no bundle exists at all, a
single default entry named after the type is offered — for preferences and a future Run —
exactly as before.
""" """
name = str(sensor_type or "").strip() name = str(sensor_type or "").strip()
if not name: if not name:
return [] return []
entries = scan_sensor_catalog().entries_for_family(name) catalog = scan_sensor_catalog()
entries = catalog.entries_for_family(name)
if entries: if entries:
return [(entry.label(), entry.key) for entry in entries] return [(catalog.row_label(entry), entry.key) for entry in entries]
if name == FAMILY_UNIDENTIFIED: if name == FAMILY_UNIDENTIFIED:
return [] return []
return [(name, name)] return [(name, name)]
+107 -31
View File
@@ -10,14 +10,17 @@ selected, and two bundles sharing one ``profile_id``.
The tree is built here, in the test's own temporary directory: a committed fixture would be a The tree is built here, in the test's own temporary directory: a committed fixture would be a
second copy of the bundle format that could drift from the real one without anyone noticing. second copy of the bundle format that could drift from the real one without anyone noticing.
The labels are asserted here too, because they are the catalog's whole operator-facing The rows are asserted here too, because they are the catalog's whole operator-facing contract: a
contract: what a configuration is, where it came from and what it will give — never a row is the bundle's name — the descriptor's ``display_name``, else the folder name — and never a
``profile_id``, an ``ok``/``degraded``/``failed`` verdict, or the word ``legacy``. ``profile_id``, an ``ok``/``degraded``/``failed`` verdict, the origin word, the calibration
phrasing or the word ``legacy``. A suffix is added in exactly one case, two visible entries that
would otherwise read alike, and the suffix is asserted to be the one that tells them apart.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -86,6 +89,11 @@ def _write(path: Path, payload: object) -> Path:
return path return path
def _WORD_RE(word: str) -> re.Pattern[str]:
"""``word`` matched whole: a folder may legitimately contain its letters (``broken-manifest``)."""
return re.compile(rf"\b{re.escape(word)}\b")
@pytest.fixture @pytest.fixture
def trees(tmp_path): def trees(tmp_path):
"""One shipped root and one user root covering every shape the catalog must handle.""" """One shipped root and one user root covering every shape the catalog must handle."""
@@ -256,31 +264,77 @@ def test_an_entry_the_sdk_rejects_is_never_reported_as_startable(trees):
assert (entry.start_blocked_reason is None) == expected, entry.ref assert (entry.start_blocked_reason is None) == expected, entry.ref
def test_the_label_states_what_it_is_where_it_came_from_and_what_it_gives(trees): def test_the_row_is_the_bundles_name_and_nothing_else(trees):
entry = trees.by_ref("user/mini-lab") """The picker row stopped carrying the origin, the calibration and the verdict.
A legacy bundle with no collision is named by the SDK's built-in table for its folder; that
name, and nothing after it, is the row. What moved out of the row is asserted where it has
to be readable instead: the dialog's details surface, in ``apps/studio/tests``.
"""
entry = trees.by_ref("shipped/box")
assert entry is not None assert entry is not None
assert entry.label() == "Mini Lab · User · No calibration selected — raw force" assert trees.row_label(entry) == "Box"
assert trees.row_label(entry) == entry.name
def test_a_startable_label_carries_no_identity_and_no_severity_word(trees): def test_no_row_carries_identity_origin_calibration_or_a_severity_word(trees):
"""What an operator chooses between is products and outcomes, not internal names.""" """What an operator chooses between is products, not internal names or severities.
Checked two ways: through the shape of the row — the name, or the name with a collision
suffix and nothing else, so nothing can have been appended — and as whole words, because a
folder called ``broken-manifest`` legitimately contains the letters of ``ok``.
"""
for entry in trees.entries: for entry in trees.entries:
if not entry.valid: row = trees.row_label(entry)
continue assert row == entry.name or row.startswith(f"{entry.name} ("), row
label = entry.label() if entry.profile_id:
assert entry.profile_id not in label, label assert entry.profile_id not in row, row
for word in ("ok", "degraded", "failed", "legacy"): for word in ("ok", "degraded", "failed", "legacy", "Shipped", "Calibration"):
assert word not in label, f"{word!r} reached the row: {label}" assert _WORD_RE(word).search(row) is None, f"{word!r} reached the row: {row}"
def test_no_calibration_selected_reads_as_raw_force_not_as_a_defect(trees): def test_two_roots_offering_one_name_are_told_apart_by_origin(tmp_path):
entry = trees.by_ref("user/mini-lab") """``shipped/box`` beside ``sensors-user/box``: two entries, two tellable rows."""
assert entry is not None shipped = tmp_path / "shipped"
# The SDK reports the absent calibration as a warning, which is the state the row used to user = tmp_path / "user"
# read as "degraded" — a severity word that never said "raw force" or "not selected". for root in (shipped, user):
assert entry.state == "degraded" _write(root / "box" / "sensor.json", _config(stitcher=True))
assert "No calibration selected — raw force" in entry.label()
assert entry.selected_calibration == "" catalog = scan_sensor_catalog(shipped_root=shipped, user_root=user)
first = catalog.by_ref("shipped/box")
second = catalog.by_ref("user/box")
assert first is not None and second is not None
assert first.name == second.name == "Box", "the premise of a collision"
assert catalog.row_label(first) == "Box (shipped)"
assert catalog.row_label(second) == "Box (user)"
# Neither is shadowed: both are offered by the family the picker lists them under.
offered = [entry.ref for entry in catalog.entries_for_family("box")]
assert offered == ["shipped/box", "user/box"]
def test_one_root_declaring_one_name_twice_is_told_apart_by_folder(trees):
"""The origin cannot separate two entries of one root, so the folder does.
``mini-lab`` and ``mini-cal`` both declare ``Mini Lab``: one name, one root, two bundles. A
row that reads the same as another row in the same list would be a silent collapse, so each
carries the folder — the identity that actually differs.
"""
mini_lab = trees.row_label(trees.by_ref("user/mini-lab"))
mini_cal = trees.row_label(trees.by_ref("user/mini-cal"))
assert mini_lab == "Mini Lab (mini-lab)"
assert mini_cal == "Mini Lab (mini-cal)"
assert mini_lab != mini_cal
def test_a_name_two_entries_share_is_suffixed_without_touching_the_other_families(trees):
"""Only the entries that collide carry a suffix; the picker is not made noisy for them."""
seen = {entry.key: trees.row_label(entry) for entry in trees.entries}
assert seen[trees.by_ref("user/missing-assets").key] == "Finger R01 (map missing)"
assert seen[trees.by_ref("shipped/box").key] == "Box"
# Every row of one family group is unique, which is the invariant the suffix exists for.
for family in {entry.family for entry in trees.entries}:
rows = [trees.row_label(entry) for entry in trees.entries_for_family(family)]
assert len(rows) == len(set(rows)), (family, rows)
def test_a_selected_calibration_reads_as_a_selection(trees): def test_a_selected_calibration_reads_as_a_selection(trees):
@@ -289,24 +343,44 @@ def test_a_selected_calibration_reads_as_a_selection(trees):
assert entry.state == "ok", entry.reason assert entry.state == "ok", entry.reason
assert entry.selected_calibration == "force_calibration.json" assert entry.selected_calibration == "force_calibration.json"
# Named, and said as something selected — never as a property of the bundle ... # Named, and said as something selected — never as a property of the bundle ...
assert entry.label() == "Mini Lab · User · Calibration: force_calibration.json" assert entry.calibration_selection == "force_calibration.json"
# ... and the "ok" the SDK calls that is not a word an operator needs to read. # ... and the "ok" the SDK calls that is not a word an operator needs to read.
assert "ok" not in entry.label() assert "ok" not in trees.row_label(entry)
def test_an_unstartable_label_says_cannot_start_with_the_sdks_own_reason(trees): def test_no_calibration_selected_reads_as_raw_force_not_as_a_defect(trees):
entry = trees.by_ref("user/mini-lab")
assert entry is not None
# The SDK reports the absent calibration as a warning, which the row used to read as
# "degraded" — a severity word that never said "raw force" or "not selected".
assert entry.state == "degraded"
assert entry.selected_calibration == ""
assert entry.calibration_selection == "No calibration selected — raw force"
def test_an_unstartable_entry_keeps_its_name_and_its_reason(trees):
"""The row is the name; the reason the entry cannot start is still the SDK's own."""
entry = trees.by_ref("user/missing-assets") entry = trees.by_ref("user/missing-assets")
assert entry is not None and not entry.valid assert entry is not None and not entry.valid
assert entry.label() == f"Finger R01 (map missing) · Cannot start: {entry.reason}" assert trees.row_label(entry) == "Finger R01 (map missing)"
assert "unroll_map" in entry.label() assert entry.start_blocked_reason == entry.reason
assert "unroll_map" in entry.start_blocked_reason
# The reason quotes the errors it was built from, so the sentence forms are the ones a
# finding that is *not* already in it still needs — here, the calibration warning.
assert entry.warning_sentences()
assert entry.error_sentences() == ()
def test_a_duplicate_label_names_the_twin(trees): def test_a_duplicate_keeps_its_name_and_names_its_twin(trees):
first = trees.by_ref("user/dup-a") first = trees.by_ref("user/dup-a")
second = trees.by_ref("user/dup-b") second = trees.by_ref("user/dup-b")
assert first is not None and second is not None assert first is not None and second is not None
assert first.label() == "Mini Lab · User · Also provided by user/dup-b" assert first.duplicates == ("user/dup-b",)
assert second.label() == "Mini Lab · User · Also provided by user/dup-a" assert second.duplicates == ("user/dup-a",)
assert trees.row_label(first) == "Mini Lab (dup-a)"
assert trees.row_label(second) == "Mini Lab (dup-b)"
assert "user/dup-b" in first.start_blocked_reason
assert "user/dup-a" in second.start_blocked_reason
def test_the_tooltip_keeps_the_identity_the_shape_the_state_and_the_findings(trees): def test_the_tooltip_keeps_the_identity_the_shape_the_state_and_the_findings(trees):
@@ -315,10 +389,12 @@ def test_the_tooltip_keeps_the_identity_the_shape_the_state_and_the_findings(tre
assert entry is not None assert entry is not None
tooltip = entry.tooltip() tooltip = entry.tooltip()
assert entry.profile_id in tooltip assert entry.profile_id in tooltip
assert entry.folder in tooltip, "the row may be a display name, so the folder must be here"
assert str(entry.bundle_path) in tooltip
assert "descriptor bundle" in tooltip assert "descriptor bundle" in tooltip
assert f"SDK validation: {entry.state}" in tooltip assert f"SDK validation: {entry.state}" in tooltip
assert "processing can start" in tooltip, "the verdict word is left unexplained" assert "processing can start" in tooltip, "the verdict word is left unexplained"
assert "calibration: none selected — raw force" in tooltip assert f"calibration selection: {entry.calibration_selection}" in tooltip
# A finding is a sentence: the code is dropped, the capability it names is not. # A finding is a sentence: the code is dropped, the capability it names is not.
assert "calibration.absent" not in tooltip assert "calibration.absent" not in tooltip
assert "calibrated force is unavailable; raw force is available" in tooltip assert "calibrated force is unavailable; raw force is available" in tooltip