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:
ykcheungab
2026-10-05 09:30:04 +08:00
parent a92a2ba237
commit 25ccffd814
11 changed files with 1513 additions and 43 deletions
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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"))
+110 -11
View File
@@ -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