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
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
| Field | Value | Why |
@@ -22,6 +22,7 @@ from orisys_app_shared.sensors import (
SHAPE_COMPATIBILITY,
SHAPE_DESCRIPTOR,
SHAPE_UNREADABLE,
STATE_UNREADABLE,
)
from PySide6.QtWidgets import (
QDialog,
@@ -42,6 +43,16 @@ _SHAPE_KEYS: dict[str, str] = {
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):
"""Sensor type / config picker plus the calibration facts that follow it."""
@@ -62,6 +73,17 @@ class SensorDialog(QDialog):
self.picker_row.addStretch(1)
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())
self.calib_title = QLabel()
self.calib_title.setObjectName("panelTitle")
@@ -72,12 +94,6 @@ class SensorDialog(QDialog):
self.axes_label.setObjectName("panelMeta")
self.config_label = ShrinkableLabel()
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):
root.addWidget(label)
config_row = QHBoxLayout()
@@ -88,7 +104,6 @@ class SensorDialog(QDialog):
config_row.addWidget(self.btn_reveal)
config_row.addStretch(1)
root.addLayout(config_row)
root.addWidget(self.profile_label)
root.addWidget(self._section_rule())
self.export_title = QLabel()
@@ -203,10 +218,11 @@ class SensorDialog(QDialog):
def _refresh_profile(self) -> None:
"""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
the SDK assigned, how the bundle was read and its validation. 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.
This is the surface that keeps the facts the picker row no longer carries: the name and
the folder, the identity the SDK assigned, how the bundle was read, the root it was found
in, the verdict with what it means, the calibration selection, and every finding as a
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)
entry = read_entry() if callable(read_entry) else None
@@ -214,19 +230,40 @@ class SensorDialog(QDialog):
self.profile_label.setText("")
self.profile_label.setToolTip("")
return
line = t(
"sensor.catalog_profile",
profile_id=entry.profile_id or entry.folder,
shape=t(_SHAPE_KEYS.get(entry.shape, _SHAPE_KEYS[SHAPE_UNREADABLE])),
origin=entry.origin,
state=entry.state,
)
lines = [
t("sensor.catalog_name", name=entry.name, folder=entry.folder),
t(
"sensor.catalog_profile",
profile_id=entry.profile_id or entry.folder,
shape=t(_SHAPE_KEYS.get(entry.shape, _SHAPE_KEYS[SHAPE_UNREADABLE])),
origin=entry.origin,
),
t("sensor.catalog_root", origin=entry.origin, path=entry.bundle_path),
t(
"sensor.catalog_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
if blocked:
# 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.
line = f"{line}\n{t('sensor.catalog_blocked', reason=blocked)}"
self.profile_label.setText(line)
lines.append(t("sensor.catalog_blocked", reason=blocked))
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())
def retranslate(self) -> None:
@@ -1,18 +1,22 @@
"""Sensor type + config picker.
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
belongs to it. The order matters — picking ``box`` / ``mini`` / ``finger`` is
what decides which configs exist, so the two sit next to each other with their
own labels and tooltips instead of a bare "配置" combo.
selected product, then the product/sensor choice and the configuration bundle
that belongs to it. The order matters — picking ``box`` / ``mini`` / ``finger``
is what decides which configurations exist, so the two sit next to each other
with their own labels and tooltips instead of a bare "配置" combo.
Both combos are filled from the configuration catalog
(: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
the catalog's entries for the chosen family — each labelled with what it is, where
it came from and what the calibration selection gives, so the choice is between
products and outcomes rather than between ``profile_id`` strings. An entry the SDK
rejects stays listed, because that is how its reason reaches the operator, and
the catalog's entries for the chosen family. A row is the **bundle's name** — the
descriptor's ``display_name``, else the folder name — and nothing else; the origin,
the calibration selection and the SDK's verdict are read in the dialog, whose
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
must not be started (see also :meth:`selected_entry`).
"""
@@ -186,7 +190,7 @@ class SensorPickerWidget(QWidget):
self.config_combo.blockSignals(True)
self.config_combo.clear()
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.count() - 1,
entry.tooltip(),
@@ -238,12 +242,14 @@ def _family_label(family: str) -> str:
return t("sensor.family_unidentified") if family == FAMILY_UNIDENTIFIED else family
def _entry_label(entry: CatalogEntry) -> str:
"""One combo item: the catalog's operator label, with nothing internal in it.
def _entry_label(catalog: SensorCatalog, entry: CatalogEntry) -> str:
"""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
family" cannot reach the row today; it is still substituted here because a sentinel is
never something an operator should read, and the item text is the one place it could
surface if an entry ever had to fall back to its family.
The row is the bundle's name, and the catalog appends the origin only where two visible
entries would otherwise read alike, so the internal marker for "no product family" cannot
reach it today; it is still substituted here, where translating belongs, because a sentinel
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
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
+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"),
}
#: 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):
@@ -7,8 +7,9 @@ descriptor bundle, a legacy ``sensor.json`` bundle, or an entry that stays liste
with the SDK's own reason.
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,
with the identity and the SDK's verdict in the tooltip.
catalog object directly, so it can show each entry's name — with the origin only where two rows
would otherwise read alike — and keep the identity and the SDK's verdict in the details surface
below the selector.
"""
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]]:
"""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 bundle folder for an entry that has none), so a selection keeps its meaning. 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.
the bundle folder for an entry that has none), so a selection keeps its meaning. The name is
the row the picker shows: the bundle's own name, with the origin appended only where two
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()
if not name:
return []
entries = scan_sensor_catalog().entries_for_family(name)
catalog = scan_sensor_catalog()
entries = catalog.entries_for_family(name)
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:
return []
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
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
contract: what a configuration is, where it came from and what it will give — never a
``profile_id``, an ``ok``/``degraded``/``failed`` verdict, or the word ``legacy``.
The rows are asserted here too, because they are the catalog's whole operator-facing contract: 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, 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
import json
import re
from pathlib import Path
from typing import Any
@@ -86,6 +89,11 @@ def _write(path: Path, payload: object) -> 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
def trees(tmp_path):
"""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
def test_the_label_states_what_it_is_where_it_came_from_and_what_it_gives(trees):
entry = trees.by_ref("user/mini-lab")
def test_the_row_is_the_bundles_name_and_nothing_else(trees):
"""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.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):
"""What an operator chooses between is products and outcomes, not internal names."""
def test_no_row_carries_identity_origin_calibration_or_a_severity_word(trees):
"""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:
if not entry.valid:
continue
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 = trees.row_label(entry)
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"):
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):
entry = trees.by_ref("user/mini-lab")
assert entry is not None
# The SDK reports the absent calibration as a warning, which is the state the row used to
# read as "degraded" — a severity word that never said "raw force" or "not selected".
assert entry.state == "degraded"
assert "No calibration selected — raw force" in entry.label()
assert entry.selected_calibration == ""
def test_two_roots_offering_one_name_are_told_apart_by_origin(tmp_path):
"""``shipped/box`` beside ``sensors-user/box``: two entries, two tellable rows."""
shipped = tmp_path / "shipped"
user = tmp_path / "user"
for root in (shipped, user):
_write(root / "box" / "sensor.json", _config(stitcher=True))
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):
@@ -289,24 +343,44 @@ def test_a_selected_calibration_reads_as_a_selection(trees):
assert entry.state == "ok", entry.reason
assert entry.selected_calibration == "force_calibration.json"
# 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.
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")
assert entry is not None and not entry.valid
assert entry.label() == f"Finger R01 (map missing) · Cannot start: {entry.reason}"
assert "unroll_map" in entry.label()
assert trees.row_label(entry) == "Finger R01 (map missing)"
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")
second = trees.by_ref("user/dup-b")
assert first is not None and second is not None
assert first.label() == "Mini Lab · User · Also provided by user/dup-b"
assert second.label() == "Mini Lab · User · Also provided by user/dup-a"
assert first.duplicates == ("user/dup-b",)
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):
@@ -315,10 +389,12 @@ def test_the_tooltip_keeps_the_identity_the_shape_the_state_and_the_findings(tre
assert entry is not None
tooltip = entry.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 f"SDK validation: {entry.state}" in tooltip
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.
assert "calibration.absent" not in tooltip
assert "calibrated force is unavailable; raw force is available" in tooltip