73592f085f
What
- record/index.py: RecordingIndex streams forces.csv once and keeps only the
small things — one time, one validity flag, one frame id per row plus a byte
offset every 4096 rows (~6.5 MB per 500k rows). row_range() bisects the times,
read_rows() seeks to the nearest checkpoint and scans forward, and overview()
folds every row into its bucket during the same pass to build a per-axis
min/max envelope.
- read_rows(max_points, unit) decimates **while streaming**: each bucket keeps
the extreme row of every axis in the requested unit, so a long interval never
has to exist in memory in full and a shear spike survives a flat normal channel.
- RecordingReader: info() decimates above max_rows (default 200k) and marks
itself decimated, with total_rows and a duration taken from the frame index
rather than from the view; window()/window_all()/overview()/statistics() read
the interval being asked about. list/window semantics unchanged for small
recordings.
- force/source.py: StoreCurveSource (live memory) and RecordingCurveSource (disk
interval + cached envelope) implement one interface — extent, window,
envelope, statistics, duration_s — so ForcePlotsPanel asks a *source* instead
of knowing where the data lives.
- force_plots: reads through the source (window, envelope, statistics, extent),
which also replaces its last direct store accesses.
- playback_dialog: draws the whole-session view through window_all() in the unit
being shown (bounded by CHART_MAX_POINTS), and says so when the view is
decimated ("显示 N / 共 M 行") instead of letting the operator count points.
- experiment._run: a failure that lands while the files are opening can no longer
be overwritten by the writer's own transition to "recording" (found by a flaky
test; the state check now covers STATE_FAILED).
Why
The store releases old blocks above ~4.6 h and the replay loaded every CSV row
into Python lists before drawing anything, so neither the live browser nor the
replay could show an earlier interval of a long recording. The index makes the
disk the data source and memory a cache of what is on screen.
Verification
- shared/tests/test_history_source.py (new, 8 cases) over a 12 000-row recording:
the index reports its rows and reads a bounded interval; a large recording is
marked decimated with its true duration and total rows; a small one is not; an
interval read sees the exact rows including a single peak; a decimated window
keeps the peak; the envelope fits the whole session in ≤200 points, keeps the
spike and is cached; the disk and memory sources agree on extent, values and
statistics; the whole session can be read without holding it all. Shared
suites: 146 tests, 0 failures (three consecutive runs).
- apps/studio/tools/ui_check.py: the replay section now checks the index row
count, that an interval read spans only the interval, the envelope size and
total_rows. 324 checks, 0 failures.
- apps/studio/tests/test_ui_smoke.py: 43 tests, 36 pass; the 7 failures are the
unchanged sandbox/harness artifacts.
- D:/orisys_ws/_review_phase12/reproduce.py: all ten lines unchanged;
compileall, locale and prefs parity clean.
- apps/studio/docs/record-channel.md §8 documents the index, the reader API and
the unified source, and lists what is still hardware work.
Not in this change
- Maximum RSS / backlog / loss measurements at real resolution on the target ARM
board (needs the hardware), and range CSV export.
1852 lines
68 KiB
Python
1852 lines
68 KiB
Python
#!/usr/bin/env python3
|
|
"""Dependency-free smoke run for the studio UI.
|
|
|
|
``pytest`` is the canonical runner (``apps/studio/tests``), but this script
|
|
needs nothing beyond the app's own runtime dependencies, so it can be used in
|
|
minimal environments:
|
|
|
|
python tools/ui_check.py
|
|
|
|
It builds the real main window offscreen and asserts the layout, theming and
|
|
status-feedback invariants that the UI work depends on.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
APP_ROOT = Path(__file__).resolve().parents[1]
|
|
REPO_ROOT = APP_ROOT.parents[1]
|
|
for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"):
|
|
if _path.is_dir() and str(_path) not in sys.path:
|
|
sys.path.insert(0, str(_path))
|
|
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
|
|
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
|
|
if _fonts.is_dir():
|
|
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
|
|
os.chdir(APP_ROOT)
|
|
|
|
from orisys_app_shared.acquisition.sinks import RecordSinkMixin # noqa: E402
|
|
from orisys_app_shared.record import FORCES_HEADER # noqa: E402
|
|
|
|
FAILURES: list[str] = []
|
|
CHECKS = 0
|
|
|
|
|
|
def check(condition: bool, message: str) -> None:
|
|
global CHECKS
|
|
CHECKS += 1
|
|
if not condition:
|
|
FAILURES.append(message)
|
|
|
|
|
|
def _inside_scroll_area(widget) -> bool:
|
|
"""True when ``widget`` lives inside a QScrollArea's content."""
|
|
from PyQt5.QtWidgets import QScrollArea
|
|
|
|
node = widget
|
|
while node is not None:
|
|
if isinstance(node, QScrollArea):
|
|
return True
|
|
node = node.parentWidget() if hasattr(node, "parentWidget") else None
|
|
return False
|
|
|
|
|
|
def section(name: str) -> None:
|
|
print(f"\n== {name} ==")
|
|
|
|
|
|
#: Sequences that only appear when UTF-8 text was decoded with a single-byte
|
|
#: codepage and re-encoded. A PowerShell ``Get-Content -Raw | Set-Content``
|
|
#: round trip does that and silently mangles every non-ASCII string, which is
|
|
#: how the Chinese UI once ended up unreadable.
|
|
#:
|
|
#: Written as escapes, with no literal examples in the comments, so that this
|
|
#: guard cannot itself be corrupted by the same mistake and so that a scan of
|
|
#: the tree for these sequences stays clean.
|
|
MOJIBAKE_MARKERS = (
|
|
"\u00c3",
|
|
"\u00c2",
|
|
"\u00e2\u20ac",
|
|
"\u00e3\u20ac",
|
|
"\u00ef\u00bf\u00bd",
|
|
)
|
|
|
|
|
|
def audit_locale_encoding() -> list[str]:
|
|
"""Locale files must be BOM-free UTF-8 and not mojibake."""
|
|
import json
|
|
|
|
problems: list[str] = []
|
|
locales = APP_ROOT / "config" / "locales"
|
|
for name in ("zh_CN", "en_US"):
|
|
path = locales / f"{name}.json"
|
|
if not path.is_file():
|
|
problems.append(f"{name}.json is missing")
|
|
continue
|
|
raw = path.read_bytes()
|
|
if raw.startswith(b"\xef\xbb\xbf"):
|
|
problems.append(f"{name}.json starts with a UTF-8 BOM")
|
|
try:
|
|
text = raw.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
problems.append(f"{name}.json is not valid UTF-8: {exc}")
|
|
continue
|
|
for marker in MOJIBAKE_MARKERS:
|
|
if marker in text:
|
|
problems.append(
|
|
f"{name}.json contains {marker!r} — written by a tool that "
|
|
"decoded UTF-8 with the wrong codepage"
|
|
)
|
|
break
|
|
try:
|
|
json.loads(text)
|
|
except json.JSONDecodeError as exc:
|
|
problems.append(f"{name}.json is not valid JSON: {exc}")
|
|
# A corrupted zh_CN.json still parses, so check that it holds Chinese text.
|
|
try:
|
|
import json as _json
|
|
|
|
zh = _json.loads((locales / "zh_CN.json").read_text(encoding="utf-8"))
|
|
sample = zh["menu"]["theme_dark"]
|
|
if not any("\u4e00" <= char <= "\u9fff" for char in sample):
|
|
problems.append(f"zh_CN.menu.theme_dark has no Chinese text: {sample!r}")
|
|
except Exception as exc: # noqa: BLE001
|
|
problems.append(f"could not verify the Chinese locale: {exc}")
|
|
return problems
|
|
|
|
|
|
def _calibration_sample(index: int, *, fn_raw: float, fx_raw: float = 0.4, fy_raw: float = -0.2):
|
|
"""A sample shaped for the calibration recorder."""
|
|
from orisys_app_shared.force.session import SessionSample
|
|
|
|
return SessionSample(
|
|
frame_id=index,
|
|
t_rel_s=index / 100.0,
|
|
fn_raw=fn_raw,
|
|
fx_raw=fx_raw,
|
|
fy_raw=fy_raw,
|
|
fn_n=fn_raw / 100.0,
|
|
fx_n=fx_raw / 100.0,
|
|
fy_n=fy_raw / 100.0,
|
|
valid=True,
|
|
)
|
|
|
|
|
|
def _fake_packet(
|
|
frame_id: int,
|
|
*,
|
|
valid: bool = True,
|
|
depth=None,
|
|
reason: str = "",
|
|
calibrated_axes=None,
|
|
timestamp: float | None = None,
|
|
mono_t: float | None = None,
|
|
shear_n: float | None = None,
|
|
):
|
|
"""A packet shaped like the real worker's, for the record-channel checks."""
|
|
import numpy as np
|
|
|
|
from orisys_app_shared.acquisition.sdk_worker import SdkPacket
|
|
|
|
nan = float("nan")
|
|
blank = np.zeros((8, 8, 3), np.uint8)
|
|
# A single tall sample makes it easy to prove decimation keeps peaks.
|
|
fn_raw = 99.0 if frame_id == 700 else (9.0 + frame_id * 0.01)
|
|
return SdkPacket(
|
|
raw_frame=blank,
|
|
processed_frame=blank,
|
|
timestamp=1000.0 + frame_id / 30.0 if timestamp is None else timestamp,
|
|
mono_t=mono_t,
|
|
fps=30.0,
|
|
process_ms=1.0,
|
|
normal_force=(2.0 + frame_id * 0.01) if valid else nan,
|
|
shear_x=(0.5 if shear_n is None else shear_n) if valid else nan,
|
|
shear_y=-0.5 if valid else nan,
|
|
normal_force_raw=(9.0 + frame_id * 0.1) if valid else nan,
|
|
shear_x_raw=0.4 if valid else nan,
|
|
shear_y_raw=-0.4 if valid else nan,
|
|
force_unit="N",
|
|
force_calibrated=True,
|
|
calibrated_axes=tuple(calibrated_axes or ()),
|
|
session_id=1,
|
|
frame_id=frame_id,
|
|
valid=valid,
|
|
invalid_reason=reason,
|
|
raw_available=True,
|
|
depth=depth,
|
|
)
|
|
|
|
|
|
class _StuckWorker(RecordSinkMixin):
|
|
"""A worker whose device open never returns.
|
|
|
|
Mirrors the real worker contract — including the record-channel sink API —
|
|
so the window can be exercised against the failure mode the Stop button
|
|
exists to survive.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._init_sinks()
|
|
self._thread: threading.Thread | None = None
|
|
self._end_reason: str | None = None
|
|
self._latest = None
|
|
self.thread_still_owned = False
|
|
self.stop_blocked_for = 0.0
|
|
#: Released by ``release()`` so daemon threads do not outlive the run.
|
|
self._release = threading.Event()
|
|
#: Calibration requests this worker was asked to apply.
|
|
self.calibration_requests: list[str] = []
|
|
#: Zeroing requests, and the confirmation callback the window installs.
|
|
self.reset_requests = 0
|
|
self.on_reset_applied = None
|
|
|
|
def set_pending_calibration(self, path) -> None:
|
|
self.calibration_requests.append(str(path))
|
|
|
|
def reset(self) -> None:
|
|
"""Request a zeroing; a real device confirms it from its own thread.
|
|
|
|
This stub confirms immediately (a fast device), so the window's event path
|
|
— request, confirmation, recorded event — is exercised end to end.
|
|
"""
|
|
self.reset_requests += 1
|
|
if callable(self.on_reset_applied):
|
|
packet = self._latest
|
|
frame_id = int(getattr(packet, "frame_id", -1))
|
|
mono_t = float(getattr(packet, "mono_t", 0.0) or 0.0)
|
|
self.on_reset_applied(frame_id, mono_t, 1)
|
|
|
|
def start(self, _source, *, config_ref=None) -> None:
|
|
self._release.clear()
|
|
self._thread = threading.Thread(target=self._hang, daemon=True)
|
|
self._thread.start()
|
|
|
|
def _hang(self) -> None:
|
|
self._release.wait(timeout=600.0)
|
|
|
|
def release(self) -> None:
|
|
"""Let the stuck thread finish so the process can exit cleanly."""
|
|
self._release.set()
|
|
if self._thread is not None:
|
|
self._thread.join(timeout=5.0)
|
|
|
|
def emit(self, packet) -> None:
|
|
"""Publish a packet exactly like the acquisition loop would."""
|
|
self._latest = packet
|
|
self._emit(packet)
|
|
|
|
@property
|
|
def is_running(self) -> bool:
|
|
return self._thread is not None and self._thread.is_alive()
|
|
|
|
@property
|
|
def phase(self) -> str:
|
|
return "connecting"
|
|
|
|
def latest(self):
|
|
return self._latest
|
|
|
|
def consume_end_reason(self):
|
|
reason, self._end_reason = self._end_reason, None
|
|
return reason
|
|
|
|
def stop(self, *, wait: bool = False) -> None:
|
|
self._end_reason = self._end_reason or "stopped"
|
|
started = time.perf_counter()
|
|
if wait and self._thread is not None and self._thread.is_alive():
|
|
self._thread.join(timeout=5.0)
|
|
if self._thread is None or not self._thread.is_alive():
|
|
self._thread = None
|
|
self.thread_still_owned = self._thread is not None
|
|
self.stop_blocked_for = time.perf_counter() - started
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Palette rules
|
|
#
|
|
# These are the WCAG / colour-separation invariants the theme tokens must
|
|
# satisfy. ``tests/test_ui_smoke.py`` imports :func:`audit_palette` from here so
|
|
# both runners share one source of truth.
|
|
# --------------------------------------------------------------------------
|
|
|
|
TEXT_MIN_RATIO = 4.5
|
|
GRAPHIC_MIN_RATIO = 3.0
|
|
VIZ_MIN_HUE_GAP = 90.0
|
|
VIZ_MIN_LUMINANCE_RATIO = 1.2
|
|
RAMP_BLEND_STOPS = 2 # the low stops may merge into the canvas
|
|
|
|
TEXT_PAIRS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
("text_primary", (
|
|
"window_bg", "panel_bg", "card_bg", "toolbar_bg", "input_bg",
|
|
"table_header_bg", "table_row_alt", "menu_bg", "button_bg",
|
|
"table_selected", "scrollbar_bg",
|
|
)),
|
|
("text_secondary", (
|
|
"window_bg", "panel_bg", "card_bg", "toolbar_bg",
|
|
"table_header_bg", "button_bg", "input_bg", "menu_bg",
|
|
)),
|
|
("text_muted", ("window_bg", "panel_bg", "card_bg", "toolbar_bg", "input_bg")),
|
|
("hint_text", ("card_bg", "panel_bg", "window_bg", "input_bg")),
|
|
("accent_text", ("accent", "accent_hover", "danger_fill", "danger_fill_hover")),
|
|
("danger_text", ("danger_fill", "danger_fill_hover")),
|
|
("danger", ("card_bg", "panel_bg")),
|
|
("danger_hover", ("card_bg", "panel_bg")),
|
|
("warning", ("card_bg", "panel_bg")),
|
|
("info", ("card_bg", "panel_bg")),
|
|
("success", ("card_bg", "panel_bg")),
|
|
("status_idle", ("panel_bg",)),
|
|
("status_connecting", ("panel_bg",)),
|
|
("status_running", ("panel_bg",)),
|
|
("status_error", ("panel_bg",)),
|
|
("brand_bar_fg", ("brand_bar_bg",)),
|
|
("brand_bar_fg_muted", ("brand_bar_bg",)),
|
|
)
|
|
|
|
GRAPHIC_PAIRS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
("plot_fn", ("plot_bg",)),
|
|
("plot_fx", ("plot_bg",)),
|
|
("plot_fy", ("plot_bg",)),
|
|
("scrollbar_handle", ("scrollbar_bg",)),
|
|
("scrollbar_handle_hover", ("scrollbar_bg",)),
|
|
("accent", ("panel_bg", "window_bg", "toolbar_bg")),
|
|
("accent_hover", ("panel_bg", "toolbar_bg")),
|
|
("focus_ring", ("window_bg", "input_bg", "panel_bg", "card_bg")),
|
|
("input_border", ("input_bg", "panel_bg", "card_bg", "toolbar_bg")),
|
|
)
|
|
|
|
VIZ_KEYS = ("plot_fn", "plot_fx", "plot_fy")
|
|
|
|
# Deliberate, documented exemptions (see apps/studio/docs/ui-palette.md):
|
|
# decorative 1px container outlines, and disabled states which WCAG 1.4.3
|
|
# explicitly exempts.
|
|
DECORATIVE_PAIRS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
("card_border", ("card_bg",)),
|
|
("panel_border", ("panel_bg", "window_bg")),
|
|
("plot_grid", ("plot_bg",)),
|
|
("separator", ("toolbar_bg", "panel_bg")),
|
|
)
|
|
|
|
|
|
def _srgb_channel(value: int) -> float:
|
|
channel = value / 255.0
|
|
if channel <= 0.03928:
|
|
return channel / 12.92
|
|
return ((channel + 0.055) / 1.055) ** 2.4
|
|
|
|
|
|
def _rgb(hex_color: str) -> tuple[int, int, int]:
|
|
text = str(hex_color).strip().lstrip("#")
|
|
return tuple(int(text[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value]
|
|
|
|
|
|
def relative_luminance(hex_color: str) -> float:
|
|
red, green, blue = _rgb(hex_color)
|
|
return (
|
|
0.2126 * _srgb_channel(red)
|
|
+ 0.7152 * _srgb_channel(green)
|
|
+ 0.0722 * _srgb_channel(blue)
|
|
)
|
|
|
|
|
|
def contrast_ratio(first: str, second: str) -> float:
|
|
high = relative_luminance(first)
|
|
low = relative_luminance(second)
|
|
if high < low:
|
|
high, low = low, high
|
|
return (high + 0.05) / (low + 0.05)
|
|
|
|
|
|
def hue_degrees(hex_color: str) -> float:
|
|
red, green, blue = (value / 255.0 for value in _rgb(hex_color))
|
|
high, low = max(red, green, blue), min(red, green, blue)
|
|
delta = high - low
|
|
if delta == 0:
|
|
return 0.0
|
|
if high == red:
|
|
return (60 * ((green - blue) / delta) + 360) % 360
|
|
if high == green:
|
|
return 60 * ((blue - red) / delta) + 120
|
|
return 60 * ((red - green) / delta) + 240
|
|
|
|
|
|
def _hue_gap(first: str, second: str) -> float:
|
|
delta = abs(hue_degrees(first) - hue_degrees(second))
|
|
return min(delta, 360 - delta)
|
|
|
|
|
|
def audit_palette(verbose: bool = False) -> list[str]:
|
|
"""Return the list of palette-rule violations (empty when compliant)."""
|
|
from orisys_app_shared.theme import load_colors
|
|
|
|
failures: list[str] = []
|
|
themes = ("dark", "light")
|
|
palettes = {theme: load_colors(theme) for theme in themes}
|
|
|
|
dark_keys, light_keys = set(palettes["dark"]), set(palettes["light"])
|
|
if dark_keys != light_keys:
|
|
failures.append(f"token keys differ between themes: {sorted(dark_keys ^ light_keys)}")
|
|
|
|
for theme, palette in palettes.items():
|
|
if verbose:
|
|
print(f" -- {theme} --")
|
|
for key, value in sorted(palette.items()):
|
|
if key == "flow_ramp":
|
|
stops = [stop.strip() for stop in value.split(",") if stop.strip()]
|
|
if not stops:
|
|
failures.append(f"{theme}.flow_ramp is empty")
|
|
for stop in stops:
|
|
if not (stop.startswith("#") and len(stop) == 7):
|
|
failures.append(f"{theme}.flow_ramp stop {stop!r} is not #RRGGBB")
|
|
elif not (str(value).startswith("#") and len(str(value)) == 7):
|
|
failures.append(f"{theme}.{key} = {value!r} is not #RRGGBB")
|
|
|
|
for key, backgrounds in TEXT_PAIRS:
|
|
for background in backgrounds:
|
|
ratio = contrast_ratio(palette[key], palette[background])
|
|
if verbose:
|
|
print(f" text {key:19s} on {background:17s} {ratio:5.2f}")
|
|
if ratio < TEXT_MIN_RATIO:
|
|
failures.append(
|
|
f"{theme}: text {key} on {background} = {ratio:.2f} "
|
|
f"(need {TEXT_MIN_RATIO})"
|
|
)
|
|
|
|
for key, backgrounds in GRAPHIC_PAIRS:
|
|
for background in backgrounds:
|
|
ratio = contrast_ratio(palette[key], palette[background])
|
|
if verbose:
|
|
print(f" graph {key:19s} on {background:17s} {ratio:5.2f}")
|
|
if ratio < GRAPHIC_MIN_RATIO:
|
|
failures.append(
|
|
f"{theme}: graphic {key} on {background} = {ratio:.2f} "
|
|
f"(need {GRAPHIC_MIN_RATIO})"
|
|
)
|
|
|
|
for index, first in enumerate(VIZ_KEYS):
|
|
for second in VIZ_KEYS[index + 1 :]:
|
|
gap = _hue_gap(palette[first], palette[second])
|
|
high = max(relative_luminance(palette[first]), relative_luminance(palette[second]))
|
|
low = min(relative_luminance(palette[first]), relative_luminance(palette[second]))
|
|
ratio = (high + 0.05) / (low + 0.05)
|
|
if verbose:
|
|
print(
|
|
f" viz {first:19s} vs {second:17s} "
|
|
f"dHue={gap:5.1f} LrelRatio={ratio:4.2f}"
|
|
)
|
|
if gap < VIZ_MIN_HUE_GAP and ratio < VIZ_MIN_LUMINANCE_RATIO:
|
|
failures.append(
|
|
f"{theme}: {first}/{second} are too similar "
|
|
f"(hue gap {gap:.0f} deg, luminance ratio {ratio:.2f})"
|
|
)
|
|
|
|
stops = [stop.strip() for stop in palette["flow_ramp"].split(",") if stop.strip()]
|
|
if len(stops) >= 2:
|
|
luminances = [relative_luminance(stop) for stop in stops]
|
|
if verbose:
|
|
print(f" ramp luminances {[round(value, 4) for value in luminances]}")
|
|
if not all(
|
|
luminances[i] < luminances[i + 1] for i in range(len(luminances) - 1)
|
|
):
|
|
failures.append(f"{theme}: flow_ramp is not monotone in luminance")
|
|
for stop in stops[RAMP_BLEND_STOPS:]:
|
|
ratio = contrast_ratio(stop, palette["arrow_canvas"])
|
|
if ratio < GRAPHIC_MIN_RATIO:
|
|
failures.append(
|
|
f"{theme}: flow_ramp stop {stop} on arrow_canvas = "
|
|
f"{ratio:.2f} (need {GRAPHIC_MIN_RATIO})"
|
|
)
|
|
|
|
for theme, palette in palettes.items():
|
|
if palette["brand_bar_bg"] == palette["window_bg"]:
|
|
failures.append(f"{theme}: brand_bar_bg collides with window_bg")
|
|
if verbose:
|
|
print(f" -- {theme} documented exemptions --")
|
|
for key, backgrounds in DECORATIVE_PAIRS:
|
|
for background in backgrounds:
|
|
print(
|
|
f" exempt {key:19s} on {background:17s} "
|
|
f"{contrast_ratio(palette[key], palette[background]):5.2f}"
|
|
)
|
|
|
|
return failures
|
|
|
|
|
|
def main() -> int:
|
|
from PyQt5.QtWidgets import QApplication
|
|
|
|
from orisys_app_shared.force.calibration_io import CalSample
|
|
from orisys_app_shared.i18n import set_locale, t
|
|
from orisys_app_shared.theme import build_qss, load_colors
|
|
from orisys_studio.app_window import StudioMainWindow
|
|
|
|
app = QApplication.instance() or QApplication(sys.argv)
|
|
set_locale("zh_CN")
|
|
|
|
section("theme tokens")
|
|
dark = set(load_colors("dark"))
|
|
light = set(load_colors("light"))
|
|
check(dark == light, f"token keys differ: {sorted(dark ^ light)}")
|
|
for theme in ("dark", "light"):
|
|
qss = build_qss(theme)
|
|
check(len(qss) > 1000, f"{theme} qss suspiciously short")
|
|
check(
|
|
load_colors(theme)["arrow_canvas"] != load_colors(theme)["accent"],
|
|
f"{theme}: arrow canvas collides with accent",
|
|
)
|
|
check("QProgressBar::chunk" in qss, f"{theme}: progress bar not styled")
|
|
check("QHeaderView::section" in qss, f"{theme}: table header not styled")
|
|
check("QRadioButton::indicator" in qss, f"{theme}: radio not styled")
|
|
check("QScrollBar::handle" in qss, f"{theme}: scrollbar not styled")
|
|
check("QFrame#brandBar" in qss, f"{theme}: brand bar not styled")
|
|
check("brand_bar_bg" not in qss, f"{theme}: qss leaked a token name")
|
|
|
|
section("palette rules")
|
|
palette_failures = audit_palette(verbose=True)
|
|
for failure in palette_failures:
|
|
check(False, failure)
|
|
if not palette_failures:
|
|
check(True, "palette rules hold for both themes")
|
|
|
|
section("locale encoding")
|
|
encoding_failures = audit_locale_encoding()
|
|
for failure in encoding_failures:
|
|
check(False, failure)
|
|
if not encoding_failures:
|
|
check(True, "locale files are BOM-free UTF-8 and not mojibake")
|
|
|
|
scene_count = 0
|
|
for theme in ("dark", "light"):
|
|
for sensor in ("box", "finger"):
|
|
section(f"window {theme}/{sensor}")
|
|
scene_count += 1
|
|
app.setStyleSheet(build_qss(theme))
|
|
window = StudioMainWindow(
|
|
prefs={
|
|
"language": "zh_CN",
|
|
"theme": theme,
|
|
"sensor_type": sensor,
|
|
"show_cal_panel": True,
|
|
}
|
|
)
|
|
window.resize(1440, 880)
|
|
window.show()
|
|
app.processEvents()
|
|
|
|
check(window.windowTitle() != "", "window title empty")
|
|
check(
|
|
window.status_chip.text() != "",
|
|
"status chip has no phase text",
|
|
)
|
|
check(
|
|
window.toolbar.btn_run.text().strip() not in ("", "▶"),
|
|
f"run button is unlabelled: {window.toolbar.btn_run.text()!r}",
|
|
)
|
|
|
|
brand_bar = window.brand_bar
|
|
check(brand_bar.height() >= 28, "brand bar is too short to read")
|
|
check(brand_bar.name.text() != "", "brand bar wordmark empty")
|
|
check(brand_bar.tagline.text() != "", "brand bar tagline empty")
|
|
check(brand_bar.product.text() == t("app.title"), "brand bar product name wrong")
|
|
logo = brand_bar.logo.pixmap()
|
|
if logo is not None and not logo.isNull():
|
|
image = logo.toImage()
|
|
pixels = [
|
|
image.pixelColor(x, y).alpha()
|
|
for y in range(image.height())
|
|
for x in range(image.width())
|
|
]
|
|
check(any(a < 32 for a in pixels), "brand logo lost its transparency")
|
|
check(any(a >= 200 for a in pixels), "brand logo renders empty")
|
|
else:
|
|
check(not brand_bar.logo.isVisible(), "hidden logo still shown")
|
|
|
|
panels = [
|
|
window.mesh3d_panel,
|
|
window.arrow_panel,
|
|
window.force_plots,
|
|
window.cal_panel,
|
|
]
|
|
for panel, width in zip(panels, window.content_splitter.sizes()):
|
|
if panel.isVisible():
|
|
check(
|
|
width >= panel.minimumWidth(),
|
|
f"{type(panel).__name__} width {width} < min {panel.minimumWidth()}",
|
|
)
|
|
|
|
check(
|
|
window.force_plots.width() >= window.force_plots.minimumWidth(),
|
|
"force plots narrower than its minimum",
|
|
)
|
|
|
|
# value cards must keep their text visible
|
|
for card, label in zip(
|
|
window.force_plots.value_cards, window.force_plots.value_labels
|
|
):
|
|
check(
|
|
label.height() >= label.fontMetrics().height(),
|
|
"value card text is clipped",
|
|
)
|
|
check(label.text() != "", "value card is empty")
|
|
|
|
# demo data flows through the plots and the calibration table
|
|
for i in range(90):
|
|
window.force_plots.push(i / 30.0, float(i % 7), -1.0, 1.0, unit="N")
|
|
app.processEvents()
|
|
check(
|
|
window.force_plots.history.sample_count > 0,
|
|
"plots did not accept samples",
|
|
)
|
|
|
|
window.cal_panel.set_running(True)
|
|
check(window.cal_panel.btn_record.isEnabled(), "record button stays disabled")
|
|
check(
|
|
window.cal_panel.table.columnCount() == 6,
|
|
"the calibration table is no longer the six-column diagnostic view",
|
|
)
|
|
|
|
# Drive a real point through the recorder: the table is a view over
|
|
# the recorded points, not over a separate sample list.
|
|
window.cal_recorder.start(
|
|
channel="fn", reference_load_n=1.0, duration_s=1.0
|
|
)
|
|
for index in range(40):
|
|
window.cal_recorder.feed(
|
|
_calibration_sample(index, fn_raw=100.0 + index * 0.01)
|
|
)
|
|
point = window.cal_recorder.finish_now()
|
|
window.cal_panel.on_record_finished(point)
|
|
app.processEvents()
|
|
check(window.cal_panel.table.rowCount() == 1, "calibration row missing")
|
|
check(
|
|
not window.cal_panel.empty_hint.isVisible(),
|
|
"empty hint still visible with rows",
|
|
)
|
|
check(
|
|
window.cal_panel.fit_label.text() != "",
|
|
"the fit summary is empty",
|
|
)
|
|
|
|
# Four points, one of them off-trend (raw 400 where the others imply
|
|
# ~200). The exported model is piecewise-linear, so it passes through
|
|
# every point it is built from: the training residual is zero by
|
|
# construction and the off-trend point can only show up as a
|
|
# *validation* error. The summary must describe that model, not a
|
|
# private least-squares line.
|
|
for load, raw in ((2.0, 400.0), (3.0, 300.6), (5.0, 500.9)):
|
|
window.cal_recorder.start(
|
|
channel="fn", reference_load_n=load, duration_s=1.0
|
|
)
|
|
for index in range(40):
|
|
window.cal_recorder.feed(_calibration_sample(index, fn_raw=raw))
|
|
window.cal_panel.on_record_finished(window.cal_recorder.finish_now())
|
|
app.processEvents()
|
|
check(window.cal_panel.table.rowCount() == 4, "calibration rows missing")
|
|
|
|
fit = window.cal_recorder.fit("fn")
|
|
check(
|
|
fit.method == "piecewise_linear",
|
|
f"the summary is not about the exported model: {fit.method!r}",
|
|
)
|
|
check(
|
|
fit.breakpoints == 4,
|
|
f"the exported model has {fit.breakpoints} breakpoints, not 4",
|
|
)
|
|
check(
|
|
fit.max_residual_n <= 1e-9,
|
|
"the exported model does not reproduce its own calibration points: "
|
|
f"max residual {fit.max_residual_n}",
|
|
)
|
|
|
|
# Hold the off-trend point out: the exported model is then built
|
|
# without it and the misfit becomes visible as a validation error.
|
|
window.cal_panel.table.setCurrentCell(1, 0)
|
|
window.cal_panel._on_toggle_validation()
|
|
app.processEvents()
|
|
check(
|
|
t("cal.fit_validation").split("{")[0].strip()
|
|
in window.cal_panel.fit_label.text(),
|
|
"no validation summary after marking a validation point",
|
|
)
|
|
validation = window.cal_recorder.fit("fn").validation_error_n
|
|
check(
|
|
validation > 0.05,
|
|
f"the off-trend point produced no validation error: {validation}",
|
|
)
|
|
window.cal_panel._on_toggle_validation()
|
|
|
|
# Excluding a point removes it from the build, so the exported model
|
|
# no longer passes through it.
|
|
window.cal_panel._on_toggle_excluded()
|
|
app.processEvents()
|
|
check(
|
|
window.cal_panel.table.item(1, 5).text() == t("cal.state_excluded"),
|
|
"the excluded row does not show its state",
|
|
)
|
|
from orisys_app_shared.force.calibration_io import (
|
|
build_export_model,
|
|
build_force_calibration_v2 as _build_v2,
|
|
predict_channel,
|
|
)
|
|
import orisys.calibration as _sdk_calibration
|
|
|
|
store = window.cal_panel._store
|
|
model = build_export_model(
|
|
{key: store.channel(key) for key in ("fn", "fx", "fy")}
|
|
)
|
|
predicted = predict_channel(model, "fn", 400.0)
|
|
check(
|
|
abs(predicted - 2.0) > 0.05,
|
|
f"excluding a point left the exported model unchanged: {predicted}",
|
|
)
|
|
# One preparation: the store the panel shows is the prepared model's
|
|
# input, and the export serialises that same object.
|
|
prepared = window.cal_panel._prepared
|
|
check(
|
|
prepared is not None and prepared.ok,
|
|
"the panel has no prepared calibration to show or export",
|
|
)
|
|
check(
|
|
list(store.channel("fn")) == list(prepared.samples["fn"]),
|
|
"the panel store is not the prepared model's input",
|
|
)
|
|
doc, _warnings = _build_v2(prepared)
|
|
export_dir = APP_ROOT / "outputs" / "_cal_export_check"
|
|
if export_dir.exists():
|
|
for stale in export_dir.iterdir():
|
|
stale.unlink()
|
|
export_dir.mkdir(parents=True, exist_ok=True)
|
|
written = export_dir / "exported_calibration.json"
|
|
import json as _cal_json
|
|
|
|
written.write_text(_cal_json.dumps(doc), encoding="utf-8")
|
|
loaded = _sdk_calibration.load(written, strict=True)
|
|
for point in window.cal_recorder.points:
|
|
if point.excluded:
|
|
continue
|
|
raw = float(point.loaded_stats().mean)
|
|
check(
|
|
abs(
|
|
float(loaded.get("fn").convert(raw))
|
|
- prepared.predict("fn", raw)
|
|
)
|
|
<= 1e-9,
|
|
"the exported file and the panel disagree about the model",
|
|
)
|
|
check(
|
|
len(prepared.samples["fn"]) == 3,
|
|
f"the prepared build has {len(prepared.samples['fn'])} points, not 3",
|
|
)
|
|
window.cal_recorder.reset_session()
|
|
window.cal_panel._refresh_table()
|
|
|
|
window._set_phase("running")
|
|
check("status_running" not in window.status_chip.text(), "chip not localized")
|
|
window._set_theme("light" if theme == "dark" else "dark")
|
|
app.processEvents()
|
|
|
|
window._persist_prefs()
|
|
check(
|
|
len(window.prefs.get("splitter_sizes", [])) == 4,
|
|
"splitter sizes not persisted",
|
|
)
|
|
window.close()
|
|
app.processEvents()
|
|
|
|
section("small window")
|
|
app.setStyleSheet(build_qss("dark"))
|
|
window = StudioMainWindow(
|
|
prefs={
|
|
"language": "en_US",
|
|
"theme": "dark",
|
|
"sensor_type": "finger",
|
|
"show_cal_panel": True,
|
|
"show_finger_3d": True,
|
|
}
|
|
)
|
|
window.resize(1280, 720)
|
|
window.show()
|
|
app.processEvents()
|
|
for panel, width in zip(
|
|
[
|
|
window.mesh3d_panel,
|
|
window.arrow_panel,
|
|
window.force_plots,
|
|
window.cal_panel,
|
|
],
|
|
window.content_splitter.sizes(),
|
|
):
|
|
if panel.isVisible():
|
|
check(width >= panel.minimumWidth(), f"1280px: {type(panel).__name__} squeezed")
|
|
oversize = []
|
|
for child in window.findChildren(object):
|
|
if not hasattr(child, "isVisible") or not child.isVisible():
|
|
continue
|
|
if not hasattr(child, "geometry"):
|
|
continue
|
|
parent = child.parentWidget()
|
|
if parent is None or not parent.isVisible():
|
|
continue
|
|
if _inside_scroll_area(child):
|
|
# A scroll area's content is *meant* to exceed its viewport (the
|
|
# toolbar scrolls horizontally on narrow windows).
|
|
continue
|
|
geometry = child.geometry()
|
|
if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1:
|
|
oversize.append(
|
|
f"{type(child).__name__}({getattr(child, 'objectName', lambda: '')()})"
|
|
f" {geometry.width()}x{geometry.height()}"
|
|
f" in {type(parent).__name__} {parent.width()}x{parent.height()}"
|
|
)
|
|
check(not oversize, f"widgets overflow their parent: {oversize}")
|
|
window.close()
|
|
app.processEvents()
|
|
|
|
section("stopping a hung connect")
|
|
stuck = _StuckWorker()
|
|
window = StudioMainWindow(
|
|
prefs={
|
|
"language": "zh_CN",
|
|
"theme": "dark",
|
|
"sensor_type": "box",
|
|
"source_mode": "camera",
|
|
}
|
|
)
|
|
window.resize(1440, 880)
|
|
window.show()
|
|
window._ensure_workers = lambda: None
|
|
window._box_worker = stuck
|
|
window._finger_worker = stuck
|
|
app.processEvents()
|
|
|
|
check(not window.act_stop.isEnabled(), "Stop shortcut is live before a session")
|
|
window.toolbar.btn_run.setChecked(True)
|
|
app.processEvents()
|
|
|
|
check(window._session_phase == "connecting", "run did not enter the connecting phase")
|
|
check(
|
|
window.toolbar.btn_run.isEnabled(),
|
|
"Stop is disabled while connecting — a hung connect could not be cancelled",
|
|
)
|
|
check(window.toolbar.btn_run.isChecked(), "run button did not latch into the stop state")
|
|
check(
|
|
window.toolbar.btn_run.text() == t("toolbar.cancel_label"),
|
|
f"connecting label is not the cancel label: {window.toolbar.btn_run.text()!r}",
|
|
)
|
|
check(
|
|
window.toolbar.btn_run.toolTip() == t("toolbar.cancel_tip"),
|
|
"connecting tooltip does not explain how to cancel",
|
|
)
|
|
check(window.act_stop.isEnabled(), "Stop shortcut is disabled while connecting")
|
|
|
|
started = time.perf_counter()
|
|
window.toolbar.btn_run.setChecked(False)
|
|
elapsed = time.perf_counter() - started
|
|
check(elapsed < 0.5, f"Stop blocked the GUI for {elapsed:.2f}s on a hung connect")
|
|
check(not window._running, "session stayed active after Stop")
|
|
check(window._session_phase == "idle", "phase did not return to idle after Stop")
|
|
check(
|
|
stuck.thread_still_owned,
|
|
"Stop abandoned the still-running worker thread",
|
|
)
|
|
|
|
# The slow-connect hint must point at the escape hatch.
|
|
window.toolbar.btn_run.setChecked(True)
|
|
app.processEvents()
|
|
window._connect_started_at = time.perf_counter() - 3600.0
|
|
window._on_run_tick()
|
|
check(
|
|
window.statusBar().currentMessage() == t("status.connect_slow"),
|
|
f"no slow-connect hint: {window.statusBar().currentMessage()!r}",
|
|
)
|
|
|
|
escaped = time.perf_counter()
|
|
window._on_stop_shortcut()
|
|
check(
|
|
time.perf_counter() - escaped < 0.5,
|
|
"Esc did not cancel the hung connect immediately",
|
|
)
|
|
check(not window._running, "Esc left the session running")
|
|
window.close()
|
|
app.processEvents()
|
|
|
|
# Let the deliberately-stuck thread finish before the interpreter tears
|
|
# down Qt, otherwise it would outlive the widgets it can reach.
|
|
stuck.release()
|
|
|
|
section("record channel")
|
|
import numpy as np
|
|
|
|
stuck = _StuckWorker()
|
|
window = StudioMainWindow(
|
|
prefs={
|
|
"language": "zh_CN",
|
|
"theme": "dark",
|
|
"sensor_type": "box",
|
|
"source_mode": "camera",
|
|
}
|
|
)
|
|
window.resize(1440, 880)
|
|
window.show()
|
|
window._ensure_workers = lambda: None
|
|
window._box_worker = stuck
|
|
window._finger_worker = stuck
|
|
window.toolbar.btn_run.setChecked(True)
|
|
app.processEvents()
|
|
|
|
check(
|
|
window._on_packet_recorded in stuck.sinks(),
|
|
"the record channel is not attached to the running worker",
|
|
)
|
|
|
|
# The record channel must not depend on the UI refresh timer at all.
|
|
for index in range(90):
|
|
stuck.emit(_fake_packet(index))
|
|
check(
|
|
window.session_store.count == 90,
|
|
f"record channel dropped samples without a UI tick: {window.session_store.count}",
|
|
)
|
|
|
|
# Invalid readouts must never be averaged in as zeros.
|
|
for index in range(10):
|
|
stuck.emit(_fake_packet(200 + index, valid=False, reason="read failed"))
|
|
slice_ = window.session_store.data()
|
|
check(int(slice_.valid.sum()) == 90, "invalid samples were not marked invalid")
|
|
check(
|
|
not bool(slice_.valid[-1]) and slice_.raw[-1][0] != slice_.raw[-1][0],
|
|
"a failed readout was stored as a number instead of NaN",
|
|
)
|
|
|
|
# Calibration sampling must survive the panel being hidden.
|
|
window._show_cal = False
|
|
window._apply_panel_visibility()
|
|
check(not window.cal_panel.isVisible(), "the calibration panel is still visible")
|
|
window.cal_recorder.start(channel="fn", reference_load_n=2.0, duration_s=1.0)
|
|
for index in range(40):
|
|
stuck.emit(_fake_packet(300 + index))
|
|
point = window.cal_recorder.finish_now()
|
|
check(point is not None and point.n_valid == 40, "hidden panel stopped the sampling")
|
|
check(
|
|
point is not None and point.status == "ok",
|
|
f"calibration point was not usable: {getattr(point, 'status', None)}",
|
|
)
|
|
|
|
# While recording, the record button must stay enabled — it is the cancel.
|
|
window.cal_panel.set_running(True)
|
|
window.cal_panel._on_record_clicked()
|
|
app.processEvents()
|
|
check(window.cal_panel._recording, "recording did not start")
|
|
check(
|
|
window.cal_panel.btn_record.isEnabled(),
|
|
"the record button is disabled while recording, so it cannot cancel",
|
|
)
|
|
check(
|
|
window.cal_panel.btn_record.text() == t("cal.recording_stop"),
|
|
f"record button does not show the stop label: {window.cal_panel.btn_record.text()!r}",
|
|
)
|
|
window.cal_panel._on_record_clicked()
|
|
app.processEvents()
|
|
check(not window.cal_panel._recording, "the recording could not be cancelled")
|
|
|
|
# The depth view must not fall back to the processed frame.
|
|
window.arrow_panel.set_view_mode("depth")
|
|
window._last_rendered_view_mode = None
|
|
stuck.emit(_fake_packet(400, depth=None))
|
|
window._on_run_tick()
|
|
app.processEvents()
|
|
check(
|
|
window.arrow_panel.image_label.text() == t("panels.depth_unavailable"),
|
|
f"missing depth was not reported: {window.arrow_panel.image_label.text()!r}",
|
|
)
|
|
window._last_rendered_view_mode = None
|
|
stuck.emit(_fake_packet(401, depth=np.linspace(0.0, 1.0, 64).reshape(8, 8)))
|
|
window._on_run_tick()
|
|
first_scale = window.arrow_panel.depth_scale_label.text()
|
|
window._last_rendered_view_mode = None
|
|
stuck.emit(_fake_packet(402, depth=np.linspace(0.0, 9.0, 64).reshape(8, 8)))
|
|
window._on_run_tick()
|
|
check(
|
|
window.arrow_panel.depth_scale_label.text() == first_scale and first_scale != "",
|
|
"the depth colour scale re-fitted per frame instead of staying fixed",
|
|
)
|
|
|
|
window.close()
|
|
app.processEvents()
|
|
stuck.release()
|
|
|
|
section("experiment record")
|
|
import shutil as _shutil
|
|
|
|
scratch = APP_ROOT / "outputs" / "_record_check"
|
|
_shutil.rmtree(scratch, ignore_errors=True)
|
|
scratch.mkdir(parents=True, exist_ok=True)
|
|
import orisys_studio.app_window as _app_module
|
|
|
|
_app_module.recordings_dir = lambda: scratch
|
|
|
|
stuck = _StuckWorker()
|
|
window = StudioMainWindow(
|
|
prefs={
|
|
"language": "zh_CN",
|
|
"theme": "dark",
|
|
"sensor_type": "box",
|
|
"source_mode": "camera",
|
|
}
|
|
)
|
|
window.resize(1440, 880)
|
|
window.show()
|
|
window._ensure_workers = lambda: None
|
|
window._box_worker = stuck
|
|
window._finger_worker = stuck
|
|
window._start_run()
|
|
window._run_timer.stop()
|
|
window._set_phase("running")
|
|
app.processEvents()
|
|
|
|
check(
|
|
window.toolbar.btn_record.isEnabled(),
|
|
"the record control is disabled while a session is running",
|
|
)
|
|
check(
|
|
window.toolbar.btn_record.text() == t("record.start_label"),
|
|
f"record button label is wrong: {window.toolbar.btn_record.text()!r}",
|
|
)
|
|
check(
|
|
window.toolbar.record_mode() == "video_data",
|
|
"the default recording mode is not video+data",
|
|
)
|
|
|
|
window.toolbar.btn_record.setChecked(True)
|
|
app.processEvents()
|
|
check(window.recorder.is_recording, "the recording did not start")
|
|
check(
|
|
window.toolbar.btn_record.isEnabled()
|
|
and window.toolbar.btn_record.text() == t("record.stop_label"),
|
|
"the record button is not a usable stop control",
|
|
)
|
|
check(
|
|
not window.toolbar.record_mode_combo.isEnabled(),
|
|
"the recording mode can be changed mid-recording",
|
|
)
|
|
|
|
for index in range(30):
|
|
stuck.emit(_fake_packet(index))
|
|
window._on_run_tick()
|
|
stuck.emit(_fake_packet(90, valid=False, reason="read failed"))
|
|
window._on_run_tick()
|
|
# The zeroing is a *request*; the event is recorded only once the acquisition
|
|
# thread confirms it took effect (the stub confirms immediately).
|
|
stuck.on_reset_applied = window._on_reset_applied
|
|
window._on_reset()
|
|
window._on_run_tick()
|
|
app.processEvents()
|
|
|
|
window.toolbar.btn_record.setChecked(False)
|
|
app.processEvents()
|
|
report = window._record_last_report
|
|
check(not window.recorder.is_recording, "the recording did not stop")
|
|
check(report is not None and report.ok, f"recording reported problems: {getattr(report, 'problems', None)}")
|
|
check(
|
|
not window.toolbar.btn_record.isChecked(),
|
|
"the record button still looks active after the recording stopped",
|
|
)
|
|
check(
|
|
window._record_report_pending is False,
|
|
"a finished recording is still waiting for a report",
|
|
)
|
|
|
|
import csv as _csv
|
|
import json as _json
|
|
|
|
directory = report.directory
|
|
names = sorted(path.name for path in directory.iterdir())
|
|
check(
|
|
names == ["events.csv", "forces.csv", "frames.csv", "raw_0001.mp4", "session.json"],
|
|
f"unexpected experiment layout: {names}",
|
|
)
|
|
with (directory / "forces.csv").open(encoding="utf-8") as handle:
|
|
rows = list(_csv.DictReader(handle))
|
|
check(tuple(rows[0].keys()) == FORCES_HEADER, "forces.csv header drifted from the spec")
|
|
check(
|
|
rows[-1]["valid"] == "0" and rows[-1]["FN_raw"] == "",
|
|
"a failed readout was written as a number instead of being left empty",
|
|
)
|
|
with (directory / "frames.csv").open(encoding="utf-8") as handle:
|
|
frames = list(_csv.DictReader(handle))
|
|
check(len(frames) == len(rows), "forces.csv and frames.csv disagree on the frame count")
|
|
check(
|
|
all(f["frame_id"] == r["frame_id"] for f, r in zip(frames, rows)),
|
|
"the frame index does not share frame ids with the data",
|
|
)
|
|
meta = _json.loads((directory / "session.json").read_text(encoding="utf-8"))
|
|
check(meta.get("ended_at"), "session.json was not finalised")
|
|
check(meta["format_version"], "session.json has no format version")
|
|
check(
|
|
[event["kind"] for event in meta["events"]] == ["record_started", "zeroing", "record_stopped"],
|
|
f"events not recorded: {[e['kind'] for e in meta['events']]}",
|
|
)
|
|
check(meta["stats"]["frames"] == 31, f"unexpected frame count: {meta['stats']['frames']}")
|
|
|
|
window.close()
|
|
app.processEvents()
|
|
stuck.release()
|
|
_shutil.rmtree(scratch, ignore_errors=True)
|
|
|
|
section("record failure states")
|
|
|
|
from unittest.mock import patch as _patch
|
|
|
|
from orisys_app_shared.record.experiment import _SessionWriter as _Writer
|
|
|
|
scratch_fail = APP_ROOT / "outputs" / "_record_fail"
|
|
_shutil.rmtree(scratch_fail, ignore_errors=True)
|
|
scratch_fail.mkdir(parents=True, exist_ok=True)
|
|
stuck_fail = _StuckWorker()
|
|
fail_window = StudioMainWindow(
|
|
prefs={
|
|
"language": "zh_CN",
|
|
"theme": "dark",
|
|
"sensor_type": "box",
|
|
"source_mode": "camera",
|
|
}
|
|
)
|
|
fail_window.resize(1440, 880)
|
|
fail_window.show()
|
|
fail_window._ensure_workers = lambda: None
|
|
fail_window._box_worker = stuck_fail
|
|
fail_window._finger_worker = stuck_fail
|
|
fail_window._start_run()
|
|
fail_window._run_timer.stop()
|
|
fail_window._set_phase("running")
|
|
|
|
# A directory that cannot be created is a terminal, retryable failure.
|
|
blocked = scratch_fail / "blocked"
|
|
blocked.write_text("", encoding="utf-8")
|
|
_app_module.recordings_dir = lambda: blocked
|
|
fail_window.toolbar.btn_record.setChecked(True)
|
|
app.processEvents()
|
|
check(
|
|
not fail_window.recorder.is_recording,
|
|
"a recording started even though its directory could not be created",
|
|
)
|
|
check(
|
|
not fail_window.toolbar.btn_record.isChecked(),
|
|
"the record button stayed active after the start failed",
|
|
)
|
|
check(
|
|
fail_window.recorder.state == "failed",
|
|
f"a failed start left the recorder in {fail_window.recorder.state!r}",
|
|
)
|
|
|
|
# ...and the operator can simply try again.
|
|
_app_module.recordings_dir = lambda: scratch_fail
|
|
fail_window.toolbar.btn_record.setChecked(True)
|
|
app.processEvents()
|
|
check(
|
|
fail_window.recorder.is_recording,
|
|
"the recorder refused to retry after a failed directory",
|
|
)
|
|
|
|
# A writer that keeps failing must fail the recording, and the window must
|
|
# consume that report even though ``is_recording`` is already False.
|
|
with _patch.object(_Writer, "write_sample", side_effect=OSError("disk full")):
|
|
for index in range(8):
|
|
stuck_fail.emit(_fake_packet(index))
|
|
fail_window._on_run_tick()
|
|
deadline = time.monotonic() + 3.0
|
|
while (
|
|
fail_window.recorder.stats().write_errors < 5
|
|
and time.monotonic() < deadline
|
|
):
|
|
app.processEvents()
|
|
time.sleep(0.01)
|
|
check(
|
|
fail_window.recorder.stats().failed,
|
|
"repeated write failures never failed the recording",
|
|
)
|
|
check(
|
|
not fail_window.recorder.is_recording,
|
|
"a dead writer still claimed to be recording",
|
|
)
|
|
fail_window._stop_recording()
|
|
app.processEvents()
|
|
failed_report = fail_window._record_last_report
|
|
check(
|
|
failed_report is not None and failed_report.stats.failed,
|
|
"the window did not consume the failure report",
|
|
)
|
|
check(
|
|
not fail_window.toolbar.btn_record.isChecked(),
|
|
"the record button stayed active after the recording failed",
|
|
)
|
|
check(
|
|
fail_window._session_phase == "error",
|
|
f"the session phase is {fail_window._session_phase!r} after a failed recording",
|
|
)
|
|
|
|
fail_window.close()
|
|
app.processEvents()
|
|
stuck_fail.release()
|
|
_app_module.recordings_dir = lambda: scratch
|
|
_shutil.rmtree(scratch_fail, ignore_errors=True)
|
|
|
|
section("calibration record identity")
|
|
|
|
import json as _cal_json
|
|
from unittest.mock import patch as _patch_mismatch
|
|
|
|
import orisys_studio.ui.cal_panel as _cal_panel_module
|
|
import orisys_studio.calibration_service as _cal_service_module
|
|
from PyQt5.QtWidgets import QMessageBox as _QMessageBox
|
|
|
|
from orisys_app_shared.force.calibration_io import (
|
|
build_force_calibration_v2 as _build_v2b,
|
|
write_force_calibration as _write_cal,
|
|
)
|
|
from orisys_app_shared.force.calibration_session import (
|
|
build_session_document as _build_session_doc,
|
|
)
|
|
|
|
record_dir = APP_ROOT / "outputs" / "_cal_record_check"
|
|
if record_dir.exists():
|
|
for stale in record_dir.iterdir():
|
|
stale.unlink()
|
|
record_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
identity_window = StudioMainWindow(
|
|
prefs={
|
|
"language": "zh_CN",
|
|
"theme": "dark",
|
|
"sensor_type": "box",
|
|
"source_mode": "camera",
|
|
}
|
|
)
|
|
identity_window.resize(1400, 880)
|
|
identity_window.show()
|
|
identity_window.cal_panel.set_running(True)
|
|
taken_points = []
|
|
for load, raw in ((1.0, 100.0), (3.0, 300.0)):
|
|
identity_window.cal_recorder.start(
|
|
channel="fn", reference_load_n=load, duration_s=1.0
|
|
)
|
|
for index in range(20):
|
|
identity_window.cal_recorder.feed(
|
|
_calibration_sample(index, fn_raw=raw)
|
|
)
|
|
taken_points.append(identity_window.cal_recorder.finish_now())
|
|
device_a = record_dir / "device_a_session.json"
|
|
device_a.write_text(
|
|
_cal_json.dumps(
|
|
_build_session_doc(
|
|
taken_points,
|
|
meta={
|
|
"sensor_type": "box",
|
|
"sn": "SN-A",
|
|
"config_ref": "D:/lab/deviceA/sensor.json",
|
|
},
|
|
)
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
identity_window.cal_recorder.clear_points()
|
|
|
|
# The live device is B; opening A's record must not restamp it.
|
|
panel = identity_window.cal_panel
|
|
panel.set_meta_context(sensor_type="finger", config_ref="D:/lab/deviceB/sensor.json")
|
|
panel.sn_edit.setText("SN-B")
|
|
panel._on_open_record(str(device_a))
|
|
app.processEvents()
|
|
check(
|
|
panel.sn_edit.text() == "SN-A",
|
|
f"the reopened record's SN was overwritten: {panel.sn_edit.text()!r}",
|
|
)
|
|
meta = panel._export_meta()
|
|
check(meta["sensor_type"] == "box", "the record's sensor type was overwritten")
|
|
check(meta["sn"] == "SN-A", "the record's SN was not kept")
|
|
check(
|
|
meta["config_ref"] == "D:/lab/deviceA/sensor.json",
|
|
f"the record's config was overwritten: {meta['config_ref']!r}",
|
|
)
|
|
check(
|
|
str(meta.get("source_record", "")).endswith(device_a.name),
|
|
"the export does not reference the source record",
|
|
)
|
|
saved = sorted(record_dir.glob("calibration_session_box_*.json"))
|
|
with _patch_mismatch.object(
|
|
_cal_panel_module, "calibration_export_dir", lambda: record_dir
|
|
):
|
|
with _patch_mismatch.object(
|
|
_cal_service_module, "calibration_export_dir", lambda: record_dir
|
|
):
|
|
with _patch_mismatch.object(
|
|
_cal_panel_module.QMessageBox, "information", lambda *a, **k: None
|
|
):
|
|
panel._on_save_record()
|
|
saved = sorted(record_dir.glob("calibration_session_box_*.json"))
|
|
check(bool(saved), "saving a reopened record produced no file")
|
|
if saved:
|
|
saved_meta = _cal_json.loads(saved[-1].read_text(encoding="utf-8"))["meta"]
|
|
check(
|
|
saved_meta.get("sn") == "SN-A" and saved_meta.get("sensor_type") == "box",
|
|
f"the saved record was restamped: {saved_meta.get('sn')!r}",
|
|
)
|
|
doc, _warnings = _build_v2b(panel._prepared)
|
|
check(
|
|
doc["meta"]["sn"] == "SN-A" and doc["meta"]["sensor_type"] == "box",
|
|
"the exported coefficients carry the wrong device identity",
|
|
)
|
|
|
|
# A reopened point with only its samples can still be inspected.
|
|
reopened = identity_window.cal_recorder.points[0]
|
|
for entry in _build_session_doc([reopened])["points"]:
|
|
entry["trace"] = {}
|
|
dialog = panel.point_dialog(reopened)
|
|
check(dialog is not None, "a reopened point could not be inspected")
|
|
if dialog is not None:
|
|
check(
|
|
len(dialog.plot_widget.ci.items) == 3,
|
|
"the rebuilt diagnostic is not one trace per axis",
|
|
)
|
|
dialog.close()
|
|
|
|
# Applying a calibration that names another device is flagged and defaults
|
|
# to "no".
|
|
stuck_identity = _StuckWorker()
|
|
identity_window._ensure_workers = lambda: None
|
|
identity_window._box_worker = stuck_identity
|
|
identity_window._finger_worker = stuck_identity
|
|
coefficients = record_dir / "device_a_coefficients.json"
|
|
_write_cal(doc, coefficients)
|
|
check(
|
|
bool(
|
|
identity_window.calibration_target_mismatch(
|
|
str(coefficients),
|
|
sensor_type="finger",
|
|
config_ref="D:/lab/B/sensor.json",
|
|
)
|
|
),
|
|
"a calibration for another device was not reported as a mismatch",
|
|
)
|
|
check(
|
|
not identity_window.calibration_target_mismatch(
|
|
str(coefficients),
|
|
sensor_type="box",
|
|
config_ref="D:/lab/deviceA/sensor.json",
|
|
),
|
|
"matching identities were reported as a mismatch",
|
|
)
|
|
with _patch_mismatch.object(
|
|
_QMessageBox, "question", staticmethod(lambda *a, **k: _QMessageBox.No)
|
|
):
|
|
identity_window._on_calibration_apply(str(coefficients))
|
|
check(
|
|
not stuck_identity.calibration_requests,
|
|
"a mismatched calibration was applied after the user declined",
|
|
)
|
|
check(
|
|
identity_window.statusBar().currentMessage() == t("cal.apply_mismatch_skipped"),
|
|
f"the refusal was not reported: {identity_window.statusBar().currentMessage()!r}",
|
|
)
|
|
# A matching calibration still goes through the normal confirmation.
|
|
identity_window.sensor_picker.set_sensor_type("box")
|
|
with _patch_mismatch.object(
|
|
_QMessageBox, "question", staticmethod(lambda *a, **k: _QMessageBox.Yes)
|
|
):
|
|
identity_window._on_calibration_apply(str(coefficients))
|
|
check(
|
|
stuck_identity.calibration_requests == [str(coefficients)],
|
|
f"a matching calibration was not requested: {stuck_identity.calibration_requests}",
|
|
)
|
|
|
|
identity_window.close()
|
|
app.processEvents()
|
|
_shutil.rmtree(record_dir, ignore_errors=True)
|
|
|
|
section("display units and time base")
|
|
import math as _math
|
|
|
|
stuck_units = _StuckWorker()
|
|
units_window = StudioMainWindow(
|
|
prefs={
|
|
"language": "zh_CN",
|
|
"theme": "dark",
|
|
"sensor_type": "box",
|
|
"source_mode": "camera",
|
|
}
|
|
)
|
|
units_window.resize(1440, 880)
|
|
units_window.show()
|
|
units_window._ensure_workers = lambda: None
|
|
units_window._box_worker = stuck_units
|
|
units_window._finger_worker = stuck_units
|
|
units_window._start_run()
|
|
units_window._run_timer.stop()
|
|
units_window._set_phase("running")
|
|
units_plots = units_window.force_plots
|
|
units_window.toolbar.units_combo.setCurrentIndex(0) # newton
|
|
units_plots.reset()
|
|
wall = 1_700_000_000.0
|
|
for index in range(90):
|
|
stuck_units.emit(
|
|
_fake_packet(index, timestamp=wall + index / 30.0, mono_t=index / 30.0)
|
|
)
|
|
units_window._on_run_tick()
|
|
before = units_plots.history.state()
|
|
end_before = float(before.times[-1]) if before is not None else -1.0
|
|
check(
|
|
before is not None and end_before < 60.0,
|
|
f"the live X axis is not session-relative (ends at {end_before})",
|
|
)
|
|
for units_index in (1, 0, 1, 0):
|
|
units_window.toolbar.units_combo.setCurrentIndex(units_index)
|
|
app.processEvents()
|
|
after = units_plots.history.state()
|
|
check(
|
|
after is not None and abs(float(after.times[-1]) - end_before) < 1.0,
|
|
"the X axis jumped across a raw/N switch: "
|
|
f"{end_before} -> {None if after is None else after.times[-1]}",
|
|
)
|
|
check(
|
|
units_plots.history.sample_count > 0,
|
|
"the rebuilt history lost every sample",
|
|
)
|
|
units_window.close()
|
|
units_window._box_worker = None
|
|
stuck_units.release()
|
|
app.processEvents()
|
|
|
|
# A device with only its normal-force axis calibrated: FN in newtons, and the
|
|
# other two axes explicitly unavailable instead of showing raw counts.
|
|
stuck_one = _StuckWorker()
|
|
one_window = StudioMainWindow(
|
|
prefs={
|
|
"language": "zh_CN",
|
|
"theme": "dark",
|
|
"sensor_type": "box",
|
|
"source_mode": "camera",
|
|
}
|
|
)
|
|
one_window.resize(1440, 880)
|
|
one_window.show()
|
|
one_window._ensure_workers = lambda: None
|
|
one_window._box_worker = stuck_one
|
|
one_window._finger_worker = stuck_one
|
|
one_window._start_run()
|
|
one_window._run_timer.stop()
|
|
one_window._set_phase("running")
|
|
one_window.toolbar.units_combo.setCurrentIndex(0) # newton
|
|
one_window.force_plots.reset()
|
|
for index in range(10):
|
|
stuck_one.emit(
|
|
_fake_packet(index, calibrated_axes=("fn",), shear_n=float("nan"))
|
|
)
|
|
one_window._on_run_tick()
|
|
app.processEvents()
|
|
one_plots = one_window.force_plots
|
|
check(
|
|
one_plots._available == (True, False, False),
|
|
f"per-axis availability is wrong: {one_plots._available}",
|
|
)
|
|
check(
|
|
one_plots.value_labels[0].text() != t("chart.value_unavailable"),
|
|
"the calibrated axis was reported as unavailable",
|
|
)
|
|
check(
|
|
all(
|
|
one_plots.value_labels[index].text() == t("chart.value_unavailable")
|
|
for index in (1, 2)
|
|
),
|
|
"an axis without a curve showed a number in newton mode",
|
|
)
|
|
one_state = one_plots.history.state()
|
|
check(
|
|
one_state is not None
|
|
and _math.isfinite(float(one_state.values[:, 0].max())),
|
|
"the calibrated axis was not plotted",
|
|
)
|
|
check(
|
|
one_state is not None
|
|
and not any(_math.isfinite(v) for v in one_state.values[:, 1]),
|
|
"FX carried a value without a calibration curve",
|
|
)
|
|
one_window.close()
|
|
one_window._box_worker = None
|
|
stuck_one.release()
|
|
app.processEvents()
|
|
|
|
section("session browsing")
|
|
import numpy as np
|
|
|
|
stuck = _StuckWorker()
|
|
window = StudioMainWindow(
|
|
prefs={
|
|
"language": "zh_CN",
|
|
"theme": "dark",
|
|
"sensor_type": "box",
|
|
"source_mode": "camera",
|
|
}
|
|
)
|
|
window.resize(1440, 880)
|
|
window.show()
|
|
window._ensure_workers = lambda: None
|
|
window._box_worker = stuck
|
|
window._finger_worker = stuck
|
|
window._start_run()
|
|
window._run_timer.stop()
|
|
window._set_phase("running")
|
|
for index in range(1500):
|
|
stuck.emit(_fake_packet(index))
|
|
app.processEvents()
|
|
|
|
plots = window.force_plots
|
|
check(plots.mode() == "follow", "the charts do not start in live mode")
|
|
check(
|
|
plots.stats_label.text() == "",
|
|
"the selection readout is not blank while following live",
|
|
)
|
|
|
|
first, last = window.session_store.extent()
|
|
check(last > first, "the session store has no extent after recording")
|
|
|
|
plots.show_all()
|
|
check(plots.mode() == "browse", "the overview did not enter browse mode")
|
|
check(
|
|
all(plot.getViewBox().state["mouseEnabled"][0] for plot in plots.plots),
|
|
"browsing does not enable panning",
|
|
)
|
|
window_texts = plots.stats_label.text()
|
|
check(window_texts != "", "no selection statistics in browse mode")
|
|
check("峰" in window_texts, f"the peak is missing from the readout: {window_texts!r}")
|
|
|
|
# Decimation must keep the spike while bounding the point count.
|
|
drawn = max(int(curve.getData()[0].size) for curve in plots.curves)
|
|
check(drawn <= 2 * plots._max_draw_points, f"overview drew {drawn} points")
|
|
spike = float(np.nanmax(plots.curves[0].getData()[1]))
|
|
check(spike > 90.0, f"the overview lost the spike: {spike}")
|
|
|
|
plots.set_window(10.0, 20.0)
|
|
check(plots.window() == (10.0, 20.0), f"set_window did not apply: {plots.window()}")
|
|
check(
|
|
plots.overview_curve.getData()[0].size > 0,
|
|
"the whole-session overview strip is empty",
|
|
)
|
|
|
|
# A user gesture enters browse; "back to latest" must actually leave it.
|
|
plots._syncing = False
|
|
plots.plots[0].setXRange(5.0, 8.0, padding=0.0)
|
|
app.processEvents()
|
|
check(plots.mode() == "browse", "a user pan did not enter browse mode")
|
|
plots.follow_latest()
|
|
check(plots.mode() == "follow", "back-to-latest did not restore live mode")
|
|
|
|
plots.set_cursor(3.5, emit=True)
|
|
check(
|
|
[round(cursor.value(), 3) for cursor in plots.cursors] == [3.5, 3.5, 3.5],
|
|
"the time cursor is not shared across the three channels",
|
|
)
|
|
|
|
# History browsing is a display pause: capture and recording keep going.
|
|
plots.show_all()
|
|
before = window.session_store.count
|
|
for index in range(1500, 1560):
|
|
stuck.emit(_fake_packet(index))
|
|
check(
|
|
window.session_store.count > before,
|
|
"browsing history stopped the record channel",
|
|
)
|
|
|
|
window.close()
|
|
app.processEvents()
|
|
stuck.release()
|
|
|
|
section("layout preset")
|
|
stuck = _StuckWorker()
|
|
window = StudioMainWindow(
|
|
prefs={
|
|
"language": "zh_CN",
|
|
"theme": "dark",
|
|
"sensor_type": "finger",
|
|
"source_mode": "camera",
|
|
"show_finger_3d": True,
|
|
}
|
|
)
|
|
window.resize(1600, 900)
|
|
window.show()
|
|
window._ensure_workers = lambda: None
|
|
window._box_worker = stuck
|
|
window._finger_worker = stuck
|
|
app.processEvents()
|
|
|
|
check(window._layout_preset == "default", "the layout preset did not load")
|
|
check(window.mesh3d_panel.isVisible(), "the 3D panel is hidden in the free layout")
|
|
|
|
window._on_layout_preset("2d")
|
|
app.processEvents()
|
|
check(window._layout_preset == "2d", "the 2D preset did not apply")
|
|
check(not window.mesh3d_panel.isVisible(), "the 2D preset keeps the 3D panel")
|
|
check(
|
|
window.arrow_panel.view_mode() == "depth",
|
|
"the 2D preset does not show the depth map",
|
|
)
|
|
check(not window.cal_panel.isVisible(), "the 2D preset keeps calibration expanded")
|
|
check(not window.act_finger_3d.isEnabled(), "the 3D toggle is live in the 2D preset")
|
|
check(window.act_layout_2d.isChecked(), "the 2D menu entry is not checked")
|
|
|
|
window._on_layout_preset("default")
|
|
app.processEvents()
|
|
check(window.mesh3d_panel.isVisible(), "the free layout did not restore the 3D panel")
|
|
check(window.act_finger_3d.isEnabled(), "the 3D toggle stayed disabled")
|
|
window._persist_prefs()
|
|
check(window.prefs.get("layout_preset") == "default", "the preset was not persisted")
|
|
|
|
window.close()
|
|
app.processEvents()
|
|
stuck.release()
|
|
|
|
section("replay")
|
|
replay_root = APP_ROOT / "outputs" / "_replay_check"
|
|
_shutil.rmtree(replay_root, ignore_errors=True)
|
|
replay_root.mkdir(parents=True, exist_ok=True)
|
|
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
|
|
|
|
recorder = ExperimentRecorder(
|
|
RecordConfig(video=True, fps=30.0, segment_seconds=30.0)
|
|
)
|
|
recording = recorder.start(
|
|
root=replay_root,
|
|
session_id=1,
|
|
metadata={
|
|
"device": {
|
|
"sensor_type": "box",
|
|
"config_ref": str(replay_root / "sensor.json"),
|
|
"config_sha256": "recorded",
|
|
},
|
|
"versions": {"sdk": "0.5.1"},
|
|
"depth": {"stored": False, "replay": "recompute"},
|
|
},
|
|
)
|
|
(replay_root / "sensor.json").write_text('{"camera": {}}\n', encoding="utf-8")
|
|
for index in range(40):
|
|
recorder.on_packet(_fake_packet(index))
|
|
recorder.stop()
|
|
|
|
from orisys_studio.ui.playback_dialog import PlaybackDialog
|
|
|
|
playback = PlaybackDialog(theme="dark", directory=recording)
|
|
playback.resize(1000, 620)
|
|
playback.show()
|
|
app.processEvents()
|
|
|
|
check(
|
|
playback.position() == 0.0,
|
|
f"replay did not start at the beginning: {playback.position()}",
|
|
)
|
|
check(
|
|
all(int(curve.getData()[0].size) == 40 for curve in playback.curves),
|
|
"replay did not draw every force sample",
|
|
)
|
|
|
|
# Long-history browsing: an interval read is bounded by the interval, and the
|
|
# whole-session envelope is a band rather than every row.
|
|
from orisys_app_shared.force.source import RecordingCurveSource
|
|
from orisys_app_shared.record.index import RecordingIndex
|
|
from orisys_app_shared.record.playback import RecordingReader as _RecordingReader
|
|
|
|
reader = _RecordingReader(recording, max_rows=10)
|
|
source = RecordingCurveSource(reader)
|
|
check(source.kind == "disk", "the replay source is not the recording")
|
|
index = reader.index()
|
|
check(index.rows == 40, f"the index found {index.rows} rows, not 40")
|
|
start, stop = index.row_range(0.5, 1.0)
|
|
check(
|
|
0 <= start < stop <= index.rows and (stop - start) <= 20,
|
|
f"an interval read spans {stop - start} rows",
|
|
)
|
|
interval = source.window(0.5, 1.0, unit="N")
|
|
check(0 < len(interval) <= 20, f"the interval read returned {len(interval)} rows")
|
|
envelope = source.envelope(buckets=8, unit="N")
|
|
check(0 < len(envelope) <= 8, f"the envelope has {len(envelope)} points")
|
|
check(
|
|
reader.info().total_rows == 40,
|
|
f"the recording reports {reader.info().total_rows} rows",
|
|
)
|
|
check(playback.slider.isEnabled(), "the replay position slider is disabled")
|
|
check(
|
|
t("playback.warn_depth_recomputed").split("{")[0].strip()[:4]
|
|
in playback.warning_label.text(),
|
|
f"the replay notes are missing: {playback.warning_label.text()!r}",
|
|
)
|
|
|
|
moved: list[float] = []
|
|
playback.cursor_moved.connect(moved.append)
|
|
playback.set_position(1.0)
|
|
app.processEvents()
|
|
check(
|
|
[round(line.value(), 2) for line in playback.cursor_lines] == [1.0, 1.0, 1.0],
|
|
"the replay cursor is not shared across the three charts",
|
|
)
|
|
check(moved == [1.0], f"the cursor signal did not fire once: {moved}")
|
|
check(
|
|
playback.video_label.pixmap() is not None
|
|
and not playback.video_label.pixmap().isNull(),
|
|
"no video frame was rendered at the cursor position",
|
|
)
|
|
|
|
# The depth recomputation must never run on the UI thread. A stub that blocks
|
|
# proves the call returns immediately instead of freezing the window.
|
|
import threading as _threading
|
|
import time as _time
|
|
|
|
class _Blocking:
|
|
released = _threading.Event()
|
|
|
|
def preflight(self) -> str:
|
|
return ""
|
|
|
|
def depth_at(self, _t):
|
|
_Blocking.released.wait(timeout=5.0)
|
|
return type("R", (), {"ok": False, "error": "stub", "depth": None})()
|
|
|
|
def close(self) -> None:
|
|
return None
|
|
|
|
playback._recomputer = _Blocking() # noqa: SLF001 - exercising the contract
|
|
started = _time.perf_counter()
|
|
playback._on_recompute_depth()
|
|
elapsed = _time.perf_counter() - started
|
|
check(elapsed < 0.5, f"the depth recompute blocked the UI for {elapsed:.2f}s")
|
|
check(
|
|
playback._depth_thread is not None and playback._depth_thread.is_alive(),
|
|
"the depth recompute did not run on a worker thread",
|
|
)
|
|
check(
|
|
playback._depth_thread.daemon,
|
|
"the depth worker is not a daemon, so a blocked SDK call would keep the "
|
|
"process alive at exit",
|
|
)
|
|
playback._on_recompute_depth() # a second request is refused, not queued
|
|
check(
|
|
playback.depth_label.text() == t("playback.depth_busy"),
|
|
f"a concurrent recompute was not refused: {playback.depth_label.text()!r}",
|
|
)
|
|
_Blocking.released.set()
|
|
for _ in range(40):
|
|
app.processEvents()
|
|
_time.sleep(0.05)
|
|
if not playback._depth_busy(): # noqa: SLF001
|
|
break
|
|
check(not playback._depth_busy(), "the depth worker never finished")
|
|
# Deliver the worker result deterministically instead of waiting for the
|
|
# 120 ms poll timer.
|
|
playback._poll_depth()
|
|
app.processEvents()
|
|
check(
|
|
t("playback.depth_failed").split("{")[0].strip()[:4]
|
|
in playback.depth_label.text(),
|
|
f"a failed recomputation was not reported: {playback.depth_label.text()!r}",
|
|
)
|
|
|
|
playback.close()
|
|
app.processEvents()
|
|
|
|
# The plan must state where to start, which reset it is anchored to and
|
|
# whether the state is really recoverable; and a request must belong to the
|
|
# recording it started in.
|
|
from orisys_app_shared.record.depth import (
|
|
STATE_APPROXIMATE,
|
|
STATE_PREROLL,
|
|
DepthRecomputer,
|
|
)
|
|
from orisys_app_shared.record.playback import RecordingReader
|
|
|
|
plan_reader = RecordingReader(recording)
|
|
planner = DepthRecomputer(plan_reader)
|
|
frames = plan_reader.info().frames
|
|
check(bool(frames), "the replay recording has no frame index")
|
|
plan = planner.plan_for(frames[10])
|
|
check(
|
|
plan.state == STATE_APPROXIMATE and plan.anchor == "segment_start",
|
|
f"a recording with no reset/pre-roll is not reported as approximate: {plan.state}",
|
|
)
|
|
check(
|
|
plan.steps and plan.steps[0].source == plan_reader.segment_path(frames[10].segment),
|
|
"the plan does not name the video to read",
|
|
)
|
|
check(
|
|
"state_approximate" in plan.warnings,
|
|
f"the approximate replay is not reported: {plan.warnings}",
|
|
)
|
|
# A pre-roll makes the start of a recording reproducible.
|
|
session_path = recording / "session.json"
|
|
session_doc = _json.loads(session_path.read_text(encoding="utf-8"))
|
|
prerecord = recording / "preroll_0001.mp4"
|
|
prerecord.write_bytes(b"")
|
|
session_doc["preroll_frames"] = 30
|
|
session_doc["preroll_segment"] = prerecord.name
|
|
session_path.write_text(_json.dumps(session_doc), encoding="utf-8")
|
|
planner.close()
|
|
planner = DepthRecomputer(RecordingReader(recording))
|
|
plan = planner.plan_for(planner.reader.info().frames[5])
|
|
check(
|
|
plan.state == STATE_PREROLL and plan.preroll == prerecord,
|
|
f"the pre-roll was not used for the start of the recording: {plan.state}",
|
|
)
|
|
check(plan.frames_to_read == 30 + 6, f"unexpected replay cost: {plan.frames_to_read}")
|
|
planner.close()
|
|
|
|
# A reset boundary is re-applied, and a request is bound to its recording.
|
|
_Blocking.released.clear()
|
|
second = PlaybackDialog(theme="dark", directory=recording)
|
|
second.show()
|
|
app.processEvents()
|
|
serial_before = second._record_serial
|
|
blocking = _Blocking()
|
|
second._recomputer = blocking
|
|
second._on_recompute_depth()
|
|
check(second._depth_pending_record == serial_before, "the request has no recording id")
|
|
check(
|
|
second._depth_pending_seq is not None,
|
|
"the request carries no sequence number",
|
|
)
|
|
# Opening another recording invalidates the running request...
|
|
second.open_recording(recording)
|
|
check(
|
|
second._record_serial == serial_before + 1,
|
|
"opening a recording did not invalidate the running request",
|
|
)
|
|
check(
|
|
second._recomputer is not blocking,
|
|
"the invalidated processor was not replaced",
|
|
)
|
|
_Blocking.released.set()
|
|
for _ in range(40):
|
|
app.processEvents()
|
|
_time.sleep(0.05)
|
|
if not second._depth_busy():
|
|
break
|
|
second._poll_depth()
|
|
app.processEvents()
|
|
pixmap = second.depth_label.pixmap()
|
|
check(
|
|
pixmap is None or pixmap.isNull(),
|
|
"an answer for a previous recording was drawn into the new one",
|
|
)
|
|
second.close()
|
|
app.processEvents()
|
|
|
|
_shutil.rmtree(replay_root, ignore_errors=True)
|
|
|
|
print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}")
|
|
for failure in FAILURES:
|
|
print(f" FAIL: {failure}")
|
|
return 1 if FAILURES else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|