Discover sensor bundles from the SDK catalog, not a product list
The picker listed a hard-coded product list and treated any *.json beside sensor.json as a configuration, so a folder of stray JSON could be offered as a sensor. Discovery now scans the shipped root and a writable user root beside it, keeps the SDK's verdict for every entry (listing rejected ones with their reason) and flags duplicate profile_ids instead of shadowing them; verified by shared/tests and apps/studio/tests.
This commit is contained in:
@@ -179,7 +179,10 @@
|
||||
"export_none": "No coefficients exported yet",
|
||||
"export_file": "file {name} ({size} KB, {time})",
|
||||
"export_identity": "file identity type {sensor_type} · SN {sn} · config {config}",
|
||||
"thumb_placeholder": "Product model"
|
||||
"thumb_placeholder": "Product model",
|
||||
"family_unidentified": "Unidentified",
|
||||
"catalog_profile": "Profile {profile_id} · family {family} · from {origin} · {state}",
|
||||
"catalog_blocked": "Cannot start: {reason}"
|
||||
},
|
||||
"panels": {
|
||||
"arrow": "2D image",
|
||||
|
||||
@@ -179,7 +179,10 @@
|
||||
"export_none": "还没有导出过系数",
|
||||
"export_file": "文件 {name}({size} KB,{time})",
|
||||
"export_identity": "文件身份 类型 {sensor_type} · SN {sn} · 配置 {config}",
|
||||
"thumb_placeholder": "产品型号"
|
||||
"thumb_placeholder": "产品型号",
|
||||
"family_unidentified": "未识别",
|
||||
"catalog_profile": "配置档案 {profile_id} · 家族 {family} · 来源 {origin} · 状态 {state}",
|
||||
"catalog_blocked": "无法启动:{reason}"
|
||||
},
|
||||
"panels": {
|
||||
"arrow": "2D 图像",
|
||||
|
||||
@@ -59,6 +59,12 @@ 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()
|
||||
@@ -69,6 +75,7 @@ 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()
|
||||
@@ -145,6 +152,7 @@ class SensorDialog(QDialog):
|
||||
f"{Path(config_ref).name if config_ref else t('status.config_none')}"
|
||||
)
|
||||
self.btn_reveal.setEnabled(bool(config_ref))
|
||||
self._refresh_profile()
|
||||
|
||||
export = str(getattr(window, "_last_calibration_export", "") or "")
|
||||
if not export:
|
||||
@@ -179,6 +187,33 @@ class SensorDialog(QDialog):
|
||||
)
|
||||
self.btn_apply.setEnabled(True)
|
||||
|
||||
def _refresh_profile(self) -> None:
|
||||
"""State the selected catalog entry, and the reason when it cannot be started.
|
||||
|
||||
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
|
||||
if entry is None:
|
||||
self.profile_label.setText("")
|
||||
self.profile_label.setToolTip("")
|
||||
return
|
||||
line = t(
|
||||
"sensor.catalog_profile",
|
||||
family=entry.product_family or t("sensor.family_unidentified"),
|
||||
profile_id=entry.profile_id or entry.folder,
|
||||
origin=entry.origin,
|
||||
state=entry.state,
|
||||
)
|
||||
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)
|
||||
self.profile_label.setToolTip(entry.tooltip())
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setWindowTitle(t("sensor.dialog_title"))
|
||||
self.calib_title.setText(t("sensor.calib_section"))
|
||||
|
||||
@@ -5,12 +5,26 @@ 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.
|
||||
|
||||
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 its profile,
|
||||
origin and the SDK's own verdict. An entry the SDK rejects stays listed, 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`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
|
||||
from orisys_app_shared.sensors import (
|
||||
FAMILY_UNIDENTIFIED,
|
||||
CatalogEntry,
|
||||
SensorCatalog,
|
||||
scan_sensor_catalog,
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
@@ -26,6 +40,10 @@ TYPE_WIDTH = 76
|
||||
CONFIG_WIDTH = 120
|
||||
BADGE_WIDTH = 46
|
||||
|
||||
#: The badge for a family group that is not a product at all. Short on purpose: the rail is
|
||||
#: sized for product names, and "?" states "not a product model" better than a truncated word.
|
||||
UNIDENTIFIED_BADGE = "?"
|
||||
|
||||
|
||||
class SensorPickerWidget(QWidget):
|
||||
sensor_type_changed = Signal(str)
|
||||
@@ -37,6 +55,11 @@ class SensorPickerWidget(QWidget):
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(6)
|
||||
|
||||
#: The catalog this picker lists. Rescanned by :meth:`reload_catalog`.
|
||||
self._catalog: SensorCatalog = scan_sensor_catalog()
|
||||
#: The entries behind the config items, by their selection key.
|
||||
self._entries_by_key: dict[str, CatalogEntry] = {}
|
||||
|
||||
#: The product badge that used to sit above the combos. Kept as an
|
||||
#: attribute: it states which sensor the whole window is configured for.
|
||||
self.thumb_label = QLabel()
|
||||
@@ -54,9 +77,10 @@ class SensorPickerWidget(QWidget):
|
||||
self.type_combo = QComboBox()
|
||||
self.type_combo.setMinimumWidth(TYPE_WIDTH)
|
||||
self.type_combo.setProperty("rail", "true")
|
||||
self.type_combo.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
for name in list_sensor_types():
|
||||
self.type_combo.addItem(name, name)
|
||||
self.type_combo.setSizePolicy(
|
||||
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
self._reload_families()
|
||||
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
|
||||
|
||||
self.config_label = QLabel()
|
||||
@@ -64,7 +88,9 @@ class SensorPickerWidget(QWidget):
|
||||
self.config_combo = QComboBox()
|
||||
self.config_combo.setMinimumWidth(CONFIG_WIDTH)
|
||||
self.config_combo.setProperty("rail", "true")
|
||||
self.config_combo.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
self.config_combo.setSizePolicy(
|
||||
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
|
||||
|
||||
layout.addWidget(self.thumb_label)
|
||||
@@ -77,6 +103,7 @@ class SensorPickerWidget(QWidget):
|
||||
self._refresh_thumb()
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------ selection
|
||||
def sensor_type(self) -> str:
|
||||
data = self.type_combo.currentData()
|
||||
return str(data or self.type_combo.currentText() or "box")
|
||||
@@ -87,6 +114,26 @@ class SensorPickerWidget(QWidget):
|
||||
return str(self.config_combo.currentText() or "")
|
||||
return str(data)
|
||||
|
||||
def selected_entry(self) -> CatalogEntry | None:
|
||||
"""The catalog entry behind the current configuration, when it is one."""
|
||||
return self._entries_by_key.get(self.config_name())
|
||||
|
||||
def start_blocked_reason(self) -> str | None:
|
||||
"""Why the selected configuration must not be started; ``None`` when it may be.
|
||||
|
||||
The verdict is the catalog's: it is non-``None`` for an entry the SDK's validation
|
||||
rejected and for one whose ``profile_id`` another bundle also declares. The entry stays
|
||||
listed and explainable either way.
|
||||
"""
|
||||
return self._catalog.start_blocked(self.config_name())
|
||||
|
||||
def reload_catalog(self) -> None:
|
||||
"""Rescan both roots, so a bundle added or removed since start-up appears."""
|
||||
self._catalog = scan_sensor_catalog()
|
||||
self._reload_families()
|
||||
self._reload_configs()
|
||||
self._refresh_thumb()
|
||||
|
||||
def set_sensor_type(self, sensor_type: str) -> None:
|
||||
wanted = str(sensor_type or "box")
|
||||
idx = self.type_combo.findData(wanted)
|
||||
@@ -110,24 +157,60 @@ class SensorPickerWidget(QWidget):
|
||||
self.config_combo.blockSignals(True)
|
||||
self.config_combo.setCurrentIndex(max(0, idx))
|
||||
self.config_combo.blockSignals(False)
|
||||
self._refresh_config_tooltip()
|
||||
|
||||
def set_picker_enabled(self, enabled: bool) -> None:
|
||||
"""Enable/disable both pickers (used while a session is running)."""
|
||||
self.type_combo.setEnabled(bool(enabled))
|
||||
self.config_combo.setEnabled(bool(enabled))
|
||||
|
||||
# ------------------------------------------------------------------ contents
|
||||
def _reload_families(self) -> None:
|
||||
"""Offer the families bundles exist for, the unidentified group last."""
|
||||
current = self.type_combo.currentData()
|
||||
self.type_combo.blockSignals(True)
|
||||
self.type_combo.clear()
|
||||
for family in self._catalog.families():
|
||||
self.type_combo.addItem(_family_label(family), family)
|
||||
if current is not None:
|
||||
idx = self.type_combo.findData(current)
|
||||
if idx >= 0:
|
||||
self.type_combo.setCurrentIndex(idx)
|
||||
self.type_combo.blockSignals(False)
|
||||
|
||||
def _reload_configs(self) -> None:
|
||||
family = self.sensor_type()
|
||||
entries = self._catalog.entries_for_family(family)
|
||||
self._entries_by_key = {entry.key: entry for entry in entries}
|
||||
self.config_combo.blockSignals(True)
|
||||
self.config_combo.clear()
|
||||
for display, key in list_configs_for_type(self.sensor_type()):
|
||||
self.config_combo.addItem(display, key)
|
||||
if self.config_combo.count() == 0:
|
||||
self.config_combo.addItem(self.sensor_type(), self.sensor_type())
|
||||
for entry in entries:
|
||||
self.config_combo.addItem(_entry_label(entry), entry.key)
|
||||
self.config_combo.setItemData(
|
||||
self.config_combo.count() - 1,
|
||||
entry.tooltip(),
|
||||
Qt.ItemDataRole.ToolTipRole,
|
||||
)
|
||||
if not entries:
|
||||
# No bundle for this family at all: keep the single fallback entry the old catalog
|
||||
# offered, so a preference (or a future Run) still has something to name.
|
||||
self.config_combo.addItem(family, family)
|
||||
self.config_combo.blockSignals(False)
|
||||
self._refresh_config_tooltip()
|
||||
|
||||
def _refresh_config_tooltip(self) -> None:
|
||||
entry = self.selected_entry()
|
||||
self.config_combo.setToolTip(
|
||||
entry.tooltip() if entry is not None else t("sensor.config_tip")
|
||||
)
|
||||
|
||||
def _refresh_thumb(self) -> None:
|
||||
self.thumb_label.setText(self.sensor_type().upper())
|
||||
family = self.sensor_type()
|
||||
self.thumb_label.setText(
|
||||
UNIDENTIFIED_BADGE if family == FAMILY_UNIDENTIFIED else family.upper()
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ signals
|
||||
def _on_type_index_changed(self, _index: int) -> None:
|
||||
self._reload_configs()
|
||||
self._refresh_thumb()
|
||||
@@ -135,6 +218,7 @@ class SensorPickerWidget(QWidget):
|
||||
self.config_changed.emit(self.config_name())
|
||||
|
||||
def _on_config_index_changed(self, _index: int) -> None:
|
||||
self._refresh_config_tooltip()
|
||||
self.config_changed.emit(self.config_name())
|
||||
|
||||
def retranslate(self) -> None:
|
||||
@@ -143,6 +227,21 @@ class SensorPickerWidget(QWidget):
|
||||
self.type_label.setToolTip(t("sensor.type_tip"))
|
||||
self.config_label.setToolTip(t("sensor.config_tip"))
|
||||
self.type_combo.setToolTip(t("sensor.type_tip"))
|
||||
self.config_combo.setToolTip(t("sensor.config_tip"))
|
||||
self.thumb_label.setToolTip(t("sensor.thumb_placeholder"))
|
||||
self._refresh_thumb()
|
||||
self._refresh_config_tooltip()
|
||||
|
||||
|
||||
def _family_label(family: str) -> str:
|
||||
"""A family as the operator reads it; the unidentified group is not a product name."""
|
||||
return t("sensor.family_unidentified") if family == FAMILY_UNIDENTIFIED else family
|
||||
|
||||
|
||||
def _entry_label(entry: CatalogEntry) -> str:
|
||||
"""One combo item: the catalog's label with its family group named in this locale.
|
||||
|
||||
The catalog's own label is the data form (and is what a log or a test should compare); the
|
||||
internal marker for "no product family" is replaced here, where translating belongs, so an
|
||||
operator never reads a sentinel.
|
||||
"""
|
||||
return entry.label().replace(FAMILY_UNIDENTIFIED, t("sensor.family_unidentified"))
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""The picker lists what the catalog returns, including what it refuses.
|
||||
|
||||
This is the selector side of the catalog contract: an entry the SDK rejects must still be
|
||||
listed (that is how its reason reaches the operator), a duplicate ``profile_id`` must be listed
|
||||
twice with both sides named, and ``start_blocked_reason()`` must be the answer a caller can
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
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_picker import SensorPickerWidget
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
CONFIG = {
|
||||
"name": "fixture",
|
||||
"camera": {
|
||||
"resolution_raw": [640, 480],
|
||||
"fps": 30,
|
||||
"resolution_stitch": [100, 100],
|
||||
},
|
||||
"tracker": {"algorithm": "dis"},
|
||||
}
|
||||
|
||||
|
||||
def _write(path: Path, payload: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _descriptor(profile_id: str) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"profile_id": profile_id,
|
||||
"display_name": "Mini lab",
|
||||
"product_family": "mini",
|
||||
"processing_backend": "single_camera",
|
||||
"sensor_json": "sensor.json",
|
||||
}
|
||||
|
||||
|
||||
def _make_picker(qt_app, tmp_path, monkeypatch, build: Callable[[Path], None]):
|
||||
"""A picker whose catalog is a fixture tree instead of the installed one."""
|
||||
root = tmp_path / "user"
|
||||
build(root)
|
||||
catalog = scan_sensor_catalog(
|
||||
shipped_root=tmp_path / "no-shipped-root", user_root=root
|
||||
)
|
||||
monkeypatch.setattr(picker_module, "scan_sensor_catalog", lambda: catalog)
|
||||
return SensorPickerWidget()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mixed_picker(qt_app, tmp_path, monkeypatch):
|
||||
"""Two bundles sharing a profile_id, plus a folder holding only a calibration."""
|
||||
|
||||
def build(root: Path) -> None:
|
||||
for name in ("mini-a", "mini-b"):
|
||||
_write(root / name / "sensor.json", CONFIG)
|
||||
_write(root / name / "manifest.json", _descriptor("orisys.mini.same-r1"))
|
||||
_write(root / "stray" / "force_calibration.json", {"schema_version": 2})
|
||||
|
||||
widget = _make_picker(qt_app, tmp_path, monkeypatch, build)
|
||||
yield widget
|
||||
widget.deleteLater()
|
||||
|
||||
|
||||
def test_a_calibration_folder_is_listed_as_invalid_with_its_reason(mixed_picker):
|
||||
picker = mixed_picker
|
||||
assert picker.type_combo.findData(FAMILY_UNIDENTIFIED) >= 0
|
||||
picker.set_sensor_type(FAMILY_UNIDENTIFIED)
|
||||
assert picker.config_combo.count() == 1
|
||||
entry = picker.selected_entry()
|
||||
assert entry is not None and not entry.valid
|
||||
# The internal "no product family" marker is named in the operator's language, not leaked.
|
||||
assert FAMILY_UNIDENTIFIED not in picker.config_combo.itemText(0)
|
||||
tooltip = picker.config_combo.itemData(0, Qt.ItemDataRole.ToolTipRole)
|
||||
assert "force_calibration.json" in tooltip
|
||||
assert "sensor.json or a manifest.json descriptor" in tooltip
|
||||
assert picker.start_blocked_reason() == entry.start_blocked_reason
|
||||
|
||||
|
||||
def test_a_shared_profile_id_is_listed_twice_and_can_be_refused(mixed_picker):
|
||||
picker = mixed_picker
|
||||
picker.set_sensor_type("mini")
|
||||
assert picker.config_combo.count() == 2, "one of the duplicates was shadowed"
|
||||
|
||||
first = picker.selected_entry()
|
||||
picker.config_combo.setCurrentIndex(1)
|
||||
second = picker.selected_entry()
|
||||
assert first is not None and second is not None
|
||||
assert {first.profile_id, second.profile_id} == {"orisys.mini.same-r1"}
|
||||
assert {first.folder, second.folder} == {"mini-a", "mini-b"}
|
||||
# Each side names the other, and the verdict follows the *selected* entry.
|
||||
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,)
|
||||
reason = picker.start_blocked_reason()
|
||||
assert reason is not None and other_ref in reason
|
||||
# Both stay selectable: the list shows the ambiguity instead of resolving it silently.
|
||||
assert picker.config_combo.count() == 2
|
||||
|
||||
|
||||
def test_a_single_valid_entry_is_listed_with_its_identity_and_not_blocked(
|
||||
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"))
|
||||
|
||||
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)
|
||||
assert "orisys.mini.lab-r1" in label
|
||||
assert "mini" in label and "user" in label and "degraded" in label
|
||||
assert picker.start_blocked_reason() is None
|
||||
entry = picker.selected_entry()
|
||||
assert entry is not None and entry.profile_id == "orisys.mini.lab-r1"
|
||||
picker.deleteLater()
|
||||
@@ -0,0 +1,271 @@
|
||||
"""The catalog against the configuration the studio actually ships, and the rename contract.
|
||||
|
||||
The first half asserts what ``apps/studio/config/sensors`` contains *today*, entry by entry, so
|
||||
a change to the shipped tree shows up here instead of only on a machine that happens to have
|
||||
the right folders.
|
||||
|
||||
``finger`` is the one shipped bundle that carries a descriptor, so its identity is the profile
|
||||
the descriptor declares instead of one derived from its folder name. Its declared asset paths
|
||||
are asserted to be the files in the tree, because a rename that silently resolved elsewhere is
|
||||
exactly the failure the descriptor exists to prevent.
|
||||
|
||||
The second half is the rename contract: a bundle keeps its identity when its folder is renamed.
|
||||
That is true because every descriptor path is relative to the bundle root, so it belongs to
|
||||
discovery — the catalog is where a renamed bundle is compared with the one it was copied from.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from orisys.sensors import DescriptorError, resolve_bundle
|
||||
from orisys.unroll import scaled_map_cache_path
|
||||
from orisys_app_shared.acquisition.finger_worker import resolve_finger_export_dir
|
||||
from orisys_app_shared.paths import sensors_dir
|
||||
from orisys_app_shared.sensors import (
|
||||
ORIGIN_SHIPPED,
|
||||
ROOT_KIND_FALLBACK,
|
||||
ROOT_KIND_SIBLING,
|
||||
scan_sensor_catalog,
|
||||
)
|
||||
|
||||
#: The identity the shipped finger descriptor declares. A copy of the bundle keeps it.
|
||||
FINGER_PROFILE_ID = "orisys.finger.r01"
|
||||
|
||||
#: What the shipped root provides today, as the SDK resolves it.
|
||||
EXPECTED_SHIPPED: dict[str, tuple[str, str, str, bool, str]] = {
|
||||
# folder: (family, profile_id, backend, valid, state)
|
||||
"box": ("box", "orisys.box.legacy", "compound_eye", True, "degraded"),
|
||||
"finger": ("finger", FINGER_PROFILE_ID, "uv_unroll", True, "degraded"),
|
||||
"finger-mems": (
|
||||
"finger-mems",
|
||||
"orisys.finger-mems.legacy",
|
||||
"compound_eye",
|
||||
True,
|
||||
"degraded",
|
||||
),
|
||||
"mini": ("mini", "orisys.mini.legacy", "compound_eye", True, "degraded"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def shipped():
|
||||
return scan_sensor_catalog()
|
||||
|
||||
|
||||
def test_the_shipped_root_is_the_one_the_app_resolves(shipped):
|
||||
assert shipped.shipped_root == sensors_dir()
|
||||
assert shipped.roots[0].exists is True
|
||||
|
||||
|
||||
def test_the_shipped_root_offers_exactly_its_four_bundles(shipped):
|
||||
refs = {entry.ref for entry in shipped.entries_for_origin(ORIGIN_SHIPPED)}
|
||||
assert refs == {
|
||||
"shipped/box",
|
||||
"shipped/finger",
|
||||
"shipped/finger-mems",
|
||||
"shipped/mini",
|
||||
}, sorted(refs)
|
||||
|
||||
|
||||
def test_every_shipped_entry_reports_its_family_profile_and_verdict(shipped):
|
||||
for folder, (family, profile_id, backend, valid, state) in EXPECTED_SHIPPED.items():
|
||||
entry = shipped.by_ref(f"shipped/{folder}")
|
||||
assert entry is not None, folder
|
||||
assert entry.product_family == family, folder
|
||||
assert entry.profile_id == profile_id, folder
|
||||
assert entry.processing_backend == backend, folder
|
||||
assert entry.valid is valid, f"{folder}: {entry.reason}"
|
||||
assert entry.state == state, f"{folder}: {entry.reason}"
|
||||
assert entry.origin == ORIGIN_SHIPPED
|
||||
# The verdict is the SDK's, not a second opinion computed here.
|
||||
assert (entry.start_blocked_reason is None) == (
|
||||
valid and not entry.duplicate
|
||||
), folder
|
||||
|
||||
|
||||
def test_the_shipped_families_keep_the_picker_order(shipped):
|
||||
assert shipped.families() == ["box", "mini", "finger", "finger-mems"]
|
||||
|
||||
|
||||
def test_box_mini_and_finger_mems_are_legacy_sensor_json_bundles(shipped):
|
||||
for folder in ("box", "mini", "finger-mems"):
|
||||
bundle = sensors_dir() / folder
|
||||
assert (bundle / "sensor.json").is_file(), folder
|
||||
assert not (bundle / "manifest.json").exists(), folder
|
||||
entry = shipped.by_ref(f"shipped/{folder}")
|
||||
assert entry.profile is not None and entry.profile.sensor_json_path.is_file(), (
|
||||
folder
|
||||
)
|
||||
|
||||
|
||||
def test_the_finger_bundle_ships_a_descriptor_naming_the_files_it_has(shipped):
|
||||
"""The shipped ``finger/manifest.json`` is a *descriptor*, not the legacy shape.
|
||||
|
||||
Its identity is declared rather than inferred from the folder name, and every path it
|
||||
declares is relative to the bundle root. The declared files are asserted to exist, so
|
||||
renaming a shipped map or scene without updating the descriptor fails here.
|
||||
"""
|
||||
bundle = sensors_dir() / "finger"
|
||||
manifest = json.loads((bundle / "manifest.json").read_text(encoding="utf-8"))
|
||||
assert manifest["schema_version"] == 1, "the descriptor contract is version 1"
|
||||
assert manifest["profile_id"] == FINGER_PROFILE_ID
|
||||
assert manifest["display_name"]
|
||||
assert manifest["product_family"] == "finger"
|
||||
assert manifest["processing_backend"] == "uv_unroll"
|
||||
assert manifest["sensor_json"] == "sensor.json"
|
||||
|
||||
declared = [
|
||||
manifest["sensor_json"],
|
||||
*manifest["computational_assets"].values(),
|
||||
*manifest["assets"].values(),
|
||||
]
|
||||
assert declared, "a descriptor that names no file describes nothing"
|
||||
for relative in declared:
|
||||
assert not Path(relative).is_absolute(), relative
|
||||
assert ".." not in Path(relative).parts, relative
|
||||
assert (bundle / relative).is_file(), f"declared but absent: {relative}"
|
||||
|
||||
entry = shipped.by_ref("shipped/finger")
|
||||
assert entry.profile is not None
|
||||
assert entry.profile.sensor_json_path == bundle / "sensor.json"
|
||||
assert entry.valid and entry.start_blocked_reason is None, entry.reason
|
||||
# The verdict is the SDK's, and the undeclared-asset failure is gone for good.
|
||||
assert "assets.computational" not in entry.reason, entry.reason
|
||||
|
||||
|
||||
def test_the_declared_assets_are_the_files_the_running_chain_loads(shipped):
|
||||
"""The descriptor's asset paths and the live chain agree, file name included.
|
||||
|
||||
The finger chain builds its UV map cache name for itself, from the export directory and
|
||||
``camera.source_scale`` (the SDK's ``scaled_map_cache_path``). That derivation and the
|
||||
descriptor are two statements of the same fact, so they are compared here: a bundle whose
|
||||
map was renamed would otherwise keep resolving to the name the chain still asks for.
|
||||
"""
|
||||
bundle = sensors_dir() / "finger"
|
||||
profile = shipped.by_ref("shipped/finger").profile
|
||||
assert profile is not None
|
||||
|
||||
export_dir = resolve_finger_export_dir(profile.sensor_json_path)
|
||||
assert export_dir == bundle / "assets" / "30fps-0703"
|
||||
chain_map = Path(
|
||||
scaled_map_cache_path(
|
||||
str(export_dir / "unroll_map_uv.npz"), profile.config["camera"]["source_scale"]
|
||||
)
|
||||
)
|
||||
declared_map = profile.computational_assets["unroll_map"]
|
||||
assert declared_map == chain_map
|
||||
# The scale suffix is the configuration's (0.42), never a neighbouring variant.
|
||||
assert declared_map.name == "unroll_map_uv_s0.42.npz"
|
||||
assert declared_map.is_file()
|
||||
assert not (export_dir / "unroll_map_uv_s0.4.npz").exists()
|
||||
|
||||
declared_scene = profile.assets["scene"]
|
||||
assert declared_scene == bundle / "scene" / "finger-display-1.0.0.oryscene"
|
||||
assert declared_scene.is_file()
|
||||
assert profile.capabilities.visualization_3d.available
|
||||
|
||||
|
||||
def test_a_calibration_file_is_never_offered_as_a_configuration(shipped):
|
||||
"""``box/`` holds ``sensor.json`` and ``force_calibration.json``: one entry, not two."""
|
||||
box = sensors_dir() / "box"
|
||||
assert (box / "force_calibration.json").is_file(), "the premise of this test"
|
||||
entries = shipped.entries_for_family("box")
|
||||
assert len(entries) == 1, [entry.key for entry in entries]
|
||||
assert Path(entries[0].key).name == "sensor.json"
|
||||
for entry in shipped.entries:
|
||||
assert Path(entry.key).name != "force_calibration.json"
|
||||
assert "force_calibration" not in entry.folder
|
||||
|
||||
|
||||
def test_the_user_root_is_absent_today_and_that_is_not_an_error(shipped):
|
||||
"""Nothing is offered from a user root that does not exist, and no error is raised.
|
||||
|
||||
W2 will import bundles into that root; when it does, this assertion is the one to revisit —
|
||||
the point being that an absent root is empty, not broken.
|
||||
"""
|
||||
assert shipped.user_root_kind in (ROOT_KIND_SIBLING, ROOT_KIND_FALLBACK)
|
||||
assert shipped.user_root_exists is False, shipped.user_root
|
||||
assert shipped.entries_for_origin(shipped.roots[1].origin) == ()
|
||||
# The shipped root is never the place user bundles go.
|
||||
assert shipped.shipped_root != shipped.user_root
|
||||
assert shipped.shipped_root not in shipped.user_root.parents
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ rename contract
|
||||
def _copy_finger(destination: Path, *, descriptor: bool = True) -> Path:
|
||||
"""Copy the shipped finger bundle, optionally stripping its descriptor."""
|
||||
shutil.copytree(sensors_dir() / "finger", destination)
|
||||
if not descriptor:
|
||||
(destination / "manifest.json").unlink()
|
||||
return destination
|
||||
|
||||
|
||||
def test_a_renamed_bundle_keeps_its_identity(tmp_path):
|
||||
"""The descriptor's relative paths are what make moving or renaming a bundle safe."""
|
||||
root = tmp_path / "bundles"
|
||||
first = _copy_finger(root / "finger-a")
|
||||
second = _copy_finger(root / "somewhere-else-entirely")
|
||||
assert first.name != second.name
|
||||
|
||||
catalog = scan_sensor_catalog(
|
||||
shipped_root=tmp_path / "no-shipped-root", user_root=root
|
||||
)
|
||||
a = catalog.by_ref("user/finger-a")
|
||||
b = catalog.by_ref("user/somewhere-else-entirely")
|
||||
assert a is not None and b is not None
|
||||
assert a.valid and b.valid, (a.reason, b.reason)
|
||||
|
||||
assert a.profile_id == b.profile_id == FINGER_PROFILE_ID
|
||||
assert a.product_family == b.product_family == "finger"
|
||||
assert a.processing_backend == b.processing_backend == "uv_unroll"
|
||||
assert a.display_name == b.display_name
|
||||
assert a.profile.processing_fingerprint == b.profile.processing_fingerprint
|
||||
# The reproducible snapshot is bundle-relative, so the folder name cannot appear in it.
|
||||
assert a.profile.inventory() == b.profile.inventory()
|
||||
inventory = a.profile.inventory()
|
||||
assert inventory["sensor_config"] == "sensor.json"
|
||||
assert inventory["computational_assets"]["unroll_map"]["path"] == (
|
||||
"assets/30fps-0703/unroll_map_uv_s0.42.npz"
|
||||
)
|
||||
assert inventory["visualization_assets"]["scene"] == (
|
||||
"scene/finger-display-1.0.0.oryscene"
|
||||
)
|
||||
assert str(tmp_path) not in json.dumps(inventory)
|
||||
|
||||
# Every resolved path stays inside the copy it was resolved from.
|
||||
for entry, copy in ((a, first), (b, second)):
|
||||
inside = copy.resolve()
|
||||
assert entry.profile.bundle_root == inside
|
||||
assert entry.profile.sensor_json_path.is_relative_to(inside)
|
||||
for role, path in entry.profile.computational_assets.items():
|
||||
assert path.is_file(), role
|
||||
assert path.is_relative_to(inside), path
|
||||
for role, path in entry.profile.assets.items():
|
||||
assert path.is_relative_to(inside), path
|
||||
|
||||
# One profile, two bundles: the catalog names the twin instead of picking one silently.
|
||||
assert a.key != b.key
|
||||
assert a.duplicates == ("user/somewhere-else-entirely",)
|
||||
assert b.duplicates == ("user/finger-a",)
|
||||
assert a.start_blocked_reason is not None
|
||||
|
||||
|
||||
def test_the_same_copy_without_a_descriptor_is_refused_by_its_folder_name(tmp_path):
|
||||
"""The negative control: rename a *legacy* bundle and it is no longer identifiable."""
|
||||
plain = _copy_finger(tmp_path / "finger-a", descriptor=False)
|
||||
with pytest.raises(DescriptorError) as failure:
|
||||
resolve_bundle(plain)
|
||||
assert "not a built-in bundle" in str(failure.value)
|
||||
|
||||
catalog = scan_sensor_catalog(
|
||||
shipped_root=tmp_path / "no-shipped-root", user_root=tmp_path
|
||||
)
|
||||
entry = catalog.by_ref("user/finger-a")
|
||||
assert entry is not None, "a bundle that cannot be resolved is still listed"
|
||||
assert not entry.valid
|
||||
assert "not a built-in bundle" in entry.reason
|
||||
assert entry.start_blocked_reason == entry.reason
|
||||
Reference in New Issue
Block a user