cd D:\orisys_ws\orisys-app
git add apps/studio/config/locales/zh_CN.json `
apps/studio/config/locales/en_US.json `
apps/studio/tests/test_locales.py `
apps/studio/tools/ui_check.py `
apps/studio/tests/test_ui_smoke.py `
apps/studio/src/orisys_studio/app_window.py `
apps/studio/src/orisys_studio/ui/cal_panel.py `
apps/studio/src/orisys_studio/ui/playback_dialog.py `
apps/studio/src/orisys_studio/ui/force_plots.py `
apps/studio/docs/record-channel.md `
shared/src/orisys_app_shared/force/calibration_recorder.py `
shared/src/orisys_app_shared/record/__init__.py `
shared/src/orisys_app_shared/record/playback.py `
shared/src/orisys_app_shared/record/depth.py `
shared/tests/test_record_channel.py `
shared/tests/test_playback.py
This commit is contained in:
@@ -61,6 +61,66 @@ 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
|
||||
@@ -429,6 +489,13 @@ def main() -> int:
|
||||
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"):
|
||||
@@ -1047,6 +1114,128 @@ def main() -> int:
|
||||
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",
|
||||
)
|
||||
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()
|
||||
_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}")
|
||||
|
||||
Reference in New Issue
Block a user