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
+292
View File
@@ -0,0 +1,292 @@
"""The sensor configuration catalog: discovery, validity and duplicate profile ids.
The catalog replaced two rules that were wrong in the same direction — a hard-coded product
list, and "any ``*.json`` beside a ``sensor.json`` is another configuration". These tests build
a tree containing exactly the shapes those rules mishandled: a descriptor bundle, a legacy
``sensor.json`` bundle, a folder holding only ``force_calibration.json``, a malformed
``manifest.json``, a bundle whose declared asset is missing, 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.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from orisys_app_shared.sensors import (
FAMILY_UNIDENTIFIED,
ORIGIN_FALLBACK,
ORIGIN_SHIPPED,
ORIGIN_USER,
ROOT_KIND_FALLBACK,
ROOT_KIND_SIBLING,
STATE_UNREADABLE,
resolve_user_root,
scan_sensor_catalog,
)
CAMERA_OK = {"resolution_raw": [640, 480], "fps": 30, "resolution_stitch": [100, 100]}
TRACKER_OK = {"algorithm": "dis"}
def _config(
*, declared_type: str | None = None, stitcher: bool = False
) -> dict[str, Any]:
data: dict[str, Any] = {
"name": "fixture",
"camera": CAMERA_OK,
"tracker": TRACKER_OK,
}
if declared_type:
data["type"] = declared_type
if stitcher:
data["stitcher"] = {"n": [1, 1]}
return data
def _descriptor(profile_id: str, **overrides: Any) -> dict[str, Any]:
data: dict[str, Any] = {
"schema_version": 1,
"profile_id": profile_id,
"display_name": profile_id,
"product_family": "mini",
"processing_backend": "single_camera",
"sensor_json": "sensor.json",
}
data.update(overrides)
return data
def _write(path: Path, payload: object) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
text = payload if isinstance(payload, str) else json.dumps(payload, indent=2)
path.write_text(text, encoding="utf-8")
return path
@pytest.fixture
def trees(tmp_path):
"""One shipped root and one user root covering every shape the catalog must handle."""
shipped = tmp_path / "shipped"
user = tmp_path / "user"
# A legacy bundle: a built-in folder name, sensor.json, and no manifest. The config carries
# a stitcher block, which is what the SDK's own rule reads to reach compound_eye.
_write(shipped / "box" / "sensor.json", _config(stitcher=True))
# Arbitrary JSON only: not a configuration.
_write(shipped / "calib-only" / "force_calibration.json", {"axes": ["fn", "fx"]})
# A descriptor bundle.
_write(user / "mini-lab" / "sensor.json", _config())
_write(user / "mini-lab" / "manifest.json", _descriptor("orisys.mini.lab-r1"))
# A manifest that is not JSON at all.
_write(
user / "broken-manifest" / "manifest.json",
'{"schema_version": 1, "profile_id":',
)
# A descriptor whose declared computational asset is absent.
_write(user / "missing-assets" / "sensor.json", _config())
_write(
user / "missing-assets" / "manifest.json",
_descriptor(
"orisys.finger.missing-r1",
product_family="finger",
processing_backend="uv_unroll",
computational_assets={"unroll_map": "assets/not-here.npz"},
),
)
# Two bundles declaring one profile_id.
for name in ("dup-a", "dup-b"):
_write(user / name / "sensor.json", _config())
_write(user / name / "manifest.json", _descriptor("orisys.mini.duplicate-r1"))
# No JSON document at all: a folder, not a bundle.
_write(user / "notes" / "readme.txt", "not a bundle")
return scan_sensor_catalog(shipped_root=shipped, user_root=user)
def test_every_bundle_shape_is_discovered_with_its_origin(trees):
refs = {entry.ref for entry in trees.entries}
assert refs == {
"shipped/box",
"shipped/calib-only",
"user/mini-lab",
"user/broken-manifest",
"user/missing-assets",
"user/dup-a",
"user/dup-b",
}, f"a folder with no JSON document is not a bundle candidate: {sorted(refs)}"
assert trees.by_ref("shipped/box").origin == ORIGIN_SHIPPED
assert trees.by_ref("user/mini-lab").origin == ORIGIN_USER
def test_a_calibration_document_is_not_offered_as_a_configuration(trees):
entry = trees.by_ref("shipped/calib-only")
assert entry is not None, "the folder is retained, so its reason can be read"
assert not entry.valid
assert "force_calibration.json" in entry.reason
assert "not offered as one" in entry.reason
# Nothing about it may be presented as a product identity.
assert entry.product_family == ""
assert entry.family == FAMILY_UNIDENTIFIED
assert entry.profile_id == ""
assert entry.start_blocked_reason == entry.reason
def test_a_malformed_manifest_is_invalid_with_the_sdks_own_reason(trees):
entry = trees.by_ref("user/broken-manifest")
assert entry is not None and not entry.valid
assert entry.state == STATE_UNREADABLE
assert "unreadable descriptor" in entry.reason, entry.reason
def test_the_two_invalid_shapes_carry_distinct_reasons(trees):
calibration_only = trees.by_ref("shipped/calib-only").reason
malformed = trees.by_ref("user/broken-manifest").reason
assert calibration_only != malformed
assert "force_calibration.json" in calibration_only
assert "force_calibration.json" not in malformed
assert "unreadable descriptor" in malformed
assert "unreadable descriptor" not in calibration_only
def test_a_descriptor_and_a_legacy_bundle_both_resolve(trees):
descriptor = trees.by_ref("user/mini-lab")
assert descriptor.valid and descriptor.profile is not None
assert descriptor.profile_id == "orisys.mini.lab-r1"
assert descriptor.product_family == "mini"
assert descriptor.processing_backend == "single_camera"
assert descriptor.sensor_json_path == descriptor.bundle_path / "sensor.json"
assert descriptor.start_blocked_reason is None
legacy = trees.by_ref("shipped/box")
assert legacy.valid and legacy.profile is not None
# The identity is the SDK's built-in one for that folder ...
assert legacy.profile_id == "orisys.box.legacy"
assert legacy.product_family == "box"
# ... and the backend is the configuration's (the stitcher block), decided by the SDK.
assert legacy.processing_backend == "compound_eye"
assert legacy.sensor_json_path == legacy.bundle_path / "sensor.json"
def test_a_legacy_config_that_contradicts_the_builtin_table_is_listed_with_the_sdks_reason(
tmp_path,
):
"""A folder name identifies a legacy bundle; it must not decide its backend.
The configuration wins — the SDK says so and this asserts it — but the SDK then validates
the backend requirements against the *descriptor's* pre-override backend, so a legacy
bundle whose config contradicts the built-in table fails a check that does not apply to the
backend it actually uses. That is an SDK-side finding, reported rather than worked around
here: the catalog's job is to pass the verdict on, not to second-guess it. If this test ever
fails because the entry became valid, the SDK fixed it: delete the ``not entry.valid``
assertion and record where.
"""
shipped = tmp_path / "shipped"
_write(shipped / "box" / "sensor.json", _config(declared_type="single_camera"))
entry = scan_sensor_catalog(
shipped_root=shipped, user_root=tmp_path / "user"
).by_ref("shipped/box")
assert entry.profile is not None
assert entry.profile.processing_backend == "single_camera", (
"the config decides the backend"
)
assert entry.product_family == "box", "the folder name decides the legacy identity"
assert not entry.valid
assert "stitcher" in entry.reason
assert entry.start_blocked_reason == entry.reason
def test_a_shared_profile_id_is_flagged_and_both_entries_stay_listed(trees):
first = trees.by_ref("user/dup-a")
second = trees.by_ref("user/dup-b")
assert first.valid and second.valid, "sharing a profile_id is not itself a failure"
assert first.profile_id == second.profile_id == "orisys.mini.duplicate-r1"
assert first.duplicates == ("user/dup-b",)
assert second.duplicates == ("user/dup-a",)
assert first.duplicate and second.duplicate
assert "user/dup-b" in first.start_blocked_reason
assert "user/dup-a" in second.start_blocked_reason
# Nothing is shadowed: both are still offered under their family, and by key.
offered = {entry.key for entry in trees.entries_for_family("mini")}
assert {first.key, second.key} <= offered
assert trees.start_blocked(first.key) == first.start_blocked_reason
assert trees.start_blocked(second.key) == second.start_blocked_reason
def test_a_missing_computational_asset_is_reported_and_keeps_its_identity(trees):
entry = trees.by_ref("user/missing-assets")
assert not entry.valid
assert entry.state == "failed", "the SDK's own state, not the catalog's"
assert entry.profile_id == "orisys.finger.missing-r1"
assert entry.product_family == "finger"
assert "unroll_map" in entry.reason
assert entry.start_blocked_reason
def test_an_entry_the_sdk_rejects_is_never_reported_as_startable(trees):
for entry in trees.entries:
expected = entry.valid and not entry.duplicate
assert (entry.start_blocked_reason is None) == expected, entry.ref
def test_the_label_states_family_profile_origin_and_validity(trees):
entry = trees.by_ref("user/missing-assets")
label = entry.label()
assert "finger" in label
assert entry.profile_id in label
assert ORIGIN_USER in label
assert entry.state in label
def test_the_root_decision_names_the_root_in_use():
decision = resolve_user_root()
assert decision.kind in (ROOT_KIND_SIBLING, ROOT_KIND_FALLBACK)
assert str(decision.path) in decision.reason
def test_an_unwritable_sibling_falls_back_to_the_per_user_root(tmp_path, monkeypatch):
from orisys_app_shared.sensors import bundle_catalog
sibling = tmp_path / "no" / "such" / "sensors-user"
fallback = tmp_path / "per-user"
monkeypatch.setattr(bundle_catalog, "user_sensors_dir", lambda: sibling)
monkeypatch.setattr(bundle_catalog, "fallback_user_sensors_dir", lambda: fallback)
decision = bundle_catalog.resolve_user_root()
assert decision.kind == ROOT_KIND_FALLBACK
assert decision.path == fallback
assert decision.writable is True
assert str(sibling) in decision.reason
def test_entries_under_the_fallback_root_keep_that_origin(tmp_path, monkeypatch):
from orisys_app_shared.sensors import bundle_catalog
shipped = tmp_path / "shipped"
fallback = tmp_path / "per-user"
_write(fallback / "mini-lab" / "sensor.json", _config())
_write(fallback / "mini-lab" / "manifest.json", _descriptor("orisys.mini.lab-r1"))
monkeypatch.setattr(
bundle_catalog, "user_sensors_dir", lambda: tmp_path / "no" / "such"
)
monkeypatch.setattr(bundle_catalog, "fallback_user_sensors_dir", lambda: fallback)
catalog = bundle_catalog.scan_sensor_catalog(shipped_root=shipped)
assert [entry.ref for entry in catalog.entries_for_origin(ORIGIN_FALLBACK)] == [
"fallback/mini-lab"
]
assert catalog.user_root_kind == ROOT_KIND_FALLBACK
def test_an_absent_root_is_not_an_error(tmp_path):
catalog = scan_sensor_catalog(
shipped_root=tmp_path / "gone", user_root=tmp_path / "also-gone"
)
assert catalog.entries == ()
assert catalog.roots[0].exists is False
assert catalog.user_root_exists is False
assert catalog.families() == []