22cc59c402
What - record: one packet->row conversion (record/sample.py). A failed compute chain records no force value at all, a newton value is written only for an axis that really has a calibration curve, and mono_t (not the wall clock) orders history with a per-row calibration hash. - record lifecycle: explicit starting/recording/stopping/stopped/failed states owned by the writer thread; stop() reports completed=False on timeout and does not write ended_at; consecutive write failures stop acceptance immediately. - record capacity: byte budget for the video queue (512 MiB default); a full queue sheds the *frame* and parks the tiny data row, so a video overflow no longer deletes CSV rows. Lost rows leave a dropped=1 index row and are counted as dropped_gaps / unrecorded_gaps. - calibration identity: sha256 content hash instead of a path, minted from the calibration the sensor actually loaded (reported before the first frame) and confirmed by the acquisition thread; events are timestamped when they happen. - calibration diagnostics: the fit summary, residual and validation error now come from the model that export/apply would use (piecewise_linear); validation points are held out of the build; repeated reference loads are merged with an n-weighted mean and pooled std. - calibration record: every point keeps its full three-axis sample series (frame id, time, raw, newtons, valid) plus diagnostics, written to an independent calibration_session_*.json that can be reopened for inspection; save record / export coefficients / apply to device are three separate actions. - depth replay: the cursor's frame is bound to the depth result by request sequence and frame id, so a moved cursor never shows another frame's map; the SDK is stepped from its current position instead of re-reading from the start; the live chain (box deformation+contact, finger unroll+tactile) is replayed with the exact resolved config path; algorithm state is rebuilt from a reset marker or from the recorded pre-roll, and says so when it cannot be. Why The review (D:/orisys_ws/_review_phase12/review.md) showed the app writing raw counts into the newton columns, recording a stale value after a failed compute, losing CSV rows when the video queue filled, reporting success while a writer was still running, diagnosing a model nobody applies, applying a calibration before the device confirmed it, dropping the samples behind a calibration point, showing a stale depth map under a moved cursor and recomputing depth of the wrong frame. Verification - D:/orisys_ws/_review_phase12/reproduce.py: all ten acceptance lines flipped (log saved as _review_phase12/reproduce_after.log). Including 25/25 rows kept with 8 video frames shed, frames [2]->[3], depth 8.0/8.0, fit 2/2 points. - apps/studio/tools/ui_check.py: 249 checks, 0 failures (twice). - shared suites: 91 tests, 0 failures (new: test_record_semantics, test_record_lifecycle, test_calibration_model, test_calibration_record, test_depth_replay). - apps/studio/tests/test_ui_smoke.py: 23/30; the 7 remaining are harness artifacts of the sandbox (4 tmp_path PermissionError, 3 parametrized tests the shim cannot drive), unchanged from before this change. - compileall clean; locale parity zh/en + t() key coverage: 0 failures. Not in this change - P2#10 disk-based browsing of a long recording and range CSV export (next round, per your choice). - Stability-triggered calibration sampling, replay fixed colour scale, PyOpenGL three-dimensional view. - Hardware verification: no camera attached, so the SDK's real processing chain and the pre-roll's effect on a live tracker were exercised with fakes only.
620 lines
22 KiB
Python
620 lines
22 KiB
Python
"""Offscreen UI smoke tests: construction, layout adaptation, theme tokens."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
import pytest
|
|
|
|
import ui_check
|
|
from tests.conftest import visible_descendants
|
|
|
|
RESOLUTIONS = ((1280, 720), (1600, 900), (1920, 1080))
|
|
|
|
|
|
def _show(window, size):
|
|
window.resize(*size)
|
|
window.show()
|
|
from PyQt5.QtWidgets import QApplication
|
|
|
|
QApplication.processEvents()
|
|
return window
|
|
|
|
|
|
@pytest.mark.parametrize(("theme", "sensor_type"), [
|
|
("dark", "box"),
|
|
("light", "box"),
|
|
("dark", "finger"),
|
|
("light", "finger"),
|
|
])
|
|
def test_window_builds_in_every_theme(make_window, theme, sensor_type):
|
|
window = _show(make_window(theme=theme, sensor_type=sensor_type), (1400, 880))
|
|
assert window.windowTitle()
|
|
assert window.content_splitter.count() == 4
|
|
|
|
|
|
@pytest.mark.parametrize("size", RESOLUTIONS)
|
|
def test_panels_fit_within_window(make_window, size):
|
|
window = _show(
|
|
make_window(sensor_type="finger", show_cal_panel=True),
|
|
size,
|
|
)
|
|
sizes = window.content_splitter.sizes()
|
|
panels = [
|
|
window.mesh3d_panel,
|
|
window.arrow_panel,
|
|
window.force_plots,
|
|
window.cal_panel,
|
|
]
|
|
for panel, width in zip(panels, sizes):
|
|
assert panel.isVisible()
|
|
assert width >= panel.minimumWidth(), (
|
|
f"{panel.objectName() or type(panel).__name__} too narrow: {width}"
|
|
)
|
|
assert window.force_plots.width() >= window.force_plots.minimumWidth()
|
|
assert window.arrow_panel.width() >= window.arrow_panel.minimumWidth()
|
|
|
|
|
|
@pytest.mark.parametrize("size", RESOLUTIONS)
|
|
def test_children_stay_inside_their_parent(make_window, size):
|
|
window = _show(make_window(sensor_type="finger", show_cal_panel=True), size)
|
|
oversize = []
|
|
for child in visible_descendants(window):
|
|
parent = child.parentWidget()
|
|
if parent is None or not parent.isVisible():
|
|
continue
|
|
geometry = child.geometry()
|
|
if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1:
|
|
oversize.append(
|
|
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
|
|
f" > parent {parent.width()}x{parent.height()}"
|
|
)
|
|
assert not oversize, oversize
|
|
|
|
|
|
def test_theme_tokens_have_identical_keys():
|
|
from orisys_app_shared.theme import build_qss, load_colors
|
|
|
|
dark = set(load_colors("dark"))
|
|
light = set(load_colors("light"))
|
|
assert dark == light, {"dark_only": sorted(dark - light), "light_only": sorted(light - dark)}
|
|
assert len(build_qss("dark")) > 1000
|
|
assert len(build_qss("light")) > 1000
|
|
|
|
|
|
def test_arrow_canvas_differs_from_accent():
|
|
"""The pure-color arrow canvas must not collide with the accent pen color."""
|
|
from orisys_app_shared.theme import load_colors
|
|
|
|
for theme in ("dark", "light"):
|
|
colors = load_colors(theme)
|
|
assert colors["arrow_canvas"] != colors["accent"], theme
|
|
|
|
|
|
def test_palette_rules_hold():
|
|
"""WCAG contrast and colour-separation rules from ``tools/ui_check.py``."""
|
|
import ui_check
|
|
|
|
assert ui_check.audit_palette() == []
|
|
|
|
|
|
def test_flow_colormap_follows_the_theme_ramp():
|
|
from orisys_app_shared.acquisition.arrows import (
|
|
_lut_from_ramp,
|
|
flow_colormap,
|
|
hex_to_bgr,
|
|
)
|
|
from orisys_app_shared.theme import load_colors
|
|
|
|
for theme in ("dark", "light"):
|
|
stops = [s.strip() for s in load_colors(theme)["flow_ramp"].split(",")]
|
|
lut = flow_colormap(theme)
|
|
assert lut.shape == (256, 1, 3)
|
|
assert tuple(int(v) for v in lut[0, 0]) == hex_to_bgr(stops[0])
|
|
assert tuple(int(v) for v in lut[-1, 0]) == hex_to_bgr(stops[-1])
|
|
|
|
# Malformed ramps must never raise; they fall back to the built-in ramp.
|
|
fallback = _lut_from_ramp("not,a,ramp")
|
|
assert fallback.shape == (256, 1, 3)
|
|
|
|
|
|
def test_brand_bar_renders_and_localizes(make_window):
|
|
from orisys_app_shared.i18n import t
|
|
|
|
window = _show(make_window(language="zh_CN"), (1440, 880))
|
|
bar = window.brand_bar
|
|
assert bar.height() >= 28
|
|
assert bar.name.text()
|
|
assert bar.product.text() == t("app.title")
|
|
|
|
zh_tagline = bar.tagline.text()
|
|
window._set_language("en_US")
|
|
assert bar.tagline.text() != zh_tagline
|
|
assert bar.name.text() == "ORISYS"
|
|
|
|
logo = bar.logo.pixmap()
|
|
if logo is not None and not logo.isNull():
|
|
image = logo.toImage()
|
|
alphas = [
|
|
image.pixelColor(x, y).alpha()
|
|
for y in range(image.height())
|
|
for x in range(image.width())
|
|
]
|
|
assert any(a < 32 for a in alphas)
|
|
assert any(a >= 200 for a in alphas)
|
|
|
|
|
|
def test_brand_bar_background_comes_from_tokens(make_window):
|
|
from orisys_app_shared.theme import load_colors
|
|
|
|
window = _show(make_window(), (1440, 880))
|
|
for theme in ("dark", "light"):
|
|
window._set_theme(theme)
|
|
colors = load_colors(theme)
|
|
assert colors["brand_bar_bg"] != colors["window_bg"], theme
|
|
assert window.brand_bar._theme == theme
|
|
|
|
|
|
def test_disabled_buttons_use_muted_text_not_hover_background():
|
|
from orisys_app_shared.theme import build_qss, load_colors
|
|
|
|
for theme in ("dark", "light"):
|
|
colors = load_colors(theme)
|
|
qss = build_qss(theme)
|
|
assert f"color: {colors['text_muted']}" in qss, theme
|
|
# The old rule painted disabled buttons with the brighter hover color.
|
|
assert f"background-color: {colors['button_hover']};\n border-color: {colors['panel_border']}" not in qss
|
|
|
|
|
|
def test_splitter_sizes_round_trip_through_prefs(make_window):
|
|
window = _show(make_window(), (1440, 880))
|
|
window.content_splitter.setSizes([300, 420, 520, 0])
|
|
window._persist_prefs()
|
|
stored = window.prefs["splitter_sizes"]
|
|
assert len(stored) == 4
|
|
assert stored[2] > stored[0]
|
|
|
|
|
|
def test_status_chip_tracks_phase(make_window):
|
|
window = _show(make_window(), (1400, 880))
|
|
assert window._session_phase == "idle"
|
|
window._set_phase("running")
|
|
assert window.status_chip.text()
|
|
assert "status_running" not in window.status_chip.text()
|
|
window._set_phase("nonexistent-phase")
|
|
assert window._session_phase == "idle"
|
|
|
|
|
|
def test_run_tooltip_explains_disabled_reason(make_window):
|
|
window = _show(make_window(source_mode="video", video_path=""), (1400, 880))
|
|
assert not window.toolbar.btn_run.isEnabled()
|
|
assert window.toolbar.btn_run.toolTip()
|
|
|
|
window.toolbar.set_video_path("C:/tmp/demo.mp4")
|
|
window._update_run_enabled()
|
|
assert window.toolbar.btn_run.isEnabled()
|
|
|
|
|
|
def test_toolbar_run_button_is_labelled(make_window):
|
|
window = _show(make_window(), (1400, 880))
|
|
label = window.toolbar.btn_run.text()
|
|
assert label.strip()
|
|
assert label != "â–¶"
|
|
window.toolbar.set_run_checked(True)
|
|
assert window.toolbar.btn_run.text() != label
|
|
|
|
|
|
def test_stop_cancels_a_hung_connect(make_window):
|
|
"""Stop must stay live and take effect instantly while connecting."""
|
|
import time
|
|
|
|
import ui_check
|
|
from orisys_app_shared.i18n import t
|
|
|
|
window = _show(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
|
|
stuck = ui_check._StuckWorker()
|
|
window._ensure_workers = lambda: None
|
|
window._box_worker = stuck
|
|
window._finger_worker = stuck
|
|
|
|
window.toolbar.btn_run.setChecked(True)
|
|
assert window._session_phase == "connecting"
|
|
assert window.toolbar.btn_run.isEnabled(), "Stop is disabled while connecting"
|
|
assert window.toolbar.btn_run.text() == t("toolbar.cancel_label")
|
|
assert window.act_stop.isEnabled()
|
|
|
|
started = time.perf_counter()
|
|
window.toolbar.btn_run.setChecked(False)
|
|
elapsed = time.perf_counter() - started
|
|
assert elapsed < 0.5, f"Stop blocked the GUI for {elapsed:.2f}s"
|
|
assert not window._running
|
|
assert stuck.thread_still_owned, "Stop abandoned the still-running worker thread"
|
|
|
|
|
|
def test_escape_cancels_a_hung_connect(make_window):
|
|
import time
|
|
|
|
import ui_check
|
|
|
|
window = _show(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
|
|
stuck = ui_check._StuckWorker()
|
|
window._ensure_workers = lambda: None
|
|
window._box_worker = stuck
|
|
window._finger_worker = stuck
|
|
|
|
window.toolbar.btn_run.setChecked(True)
|
|
assert window.act_stop.shortcut().toString() == "Esc"
|
|
started = time.perf_counter()
|
|
window._on_stop_shortcut()
|
|
assert time.perf_counter() - started < 0.5
|
|
assert not window._running
|
|
|
|
|
|
def test_theme_switch_updates_panels(make_window):
|
|
window = _show(make_window(), (1400, 880))
|
|
window._set_theme("light")
|
|
assert window._theme == "light"
|
|
window._set_theme("dark")
|
|
assert window._theme == "dark"
|
|
|
|
|
|
def test_cal_panel_records_and_lists_samples(make_window):
|
|
window = _show(make_window(), (1400, 880))
|
|
cal = window.cal_panel
|
|
cal.set_running(True)
|
|
assert cal.btn_record.isEnabled()
|
|
assert cal.btn_record.text()
|
|
assert cal.table.columnCount() == 6, "the diagnostic columns changed"
|
|
|
|
_add_calibration_point(window, load=1.0, raw=100.2)
|
|
_add_calibration_point(window, load=2.0, raw=400.0) # off-trend
|
|
_add_calibration_point(window, load=3.0, raw=300.6)
|
|
assert cal.table.rowCount() == 3
|
|
assert not cal.empty_hint.isVisible()
|
|
|
|
headers = [
|
|
cal.table.horizontalHeaderItem(i).text() for i in range(cal.table.columnCount())
|
|
]
|
|
from orisys_app_shared.i18n import t
|
|
|
|
assert headers == [
|
|
t("cal.col_weight"),
|
|
t("cal.col_fn"),
|
|
t("cal.col_fx"),
|
|
t("cal.col_fy"),
|
|
t("cal.col_stability"),
|
|
t("cal.col_state"),
|
|
]
|
|
|
|
|
|
def _add_calibration_point(window, *, load: float, raw: float, samples: int = 40) -> None:
|
|
"""Drive one point through the recorder and into the panel's table."""
|
|
from orisys_app_shared.force.session import SessionSample
|
|
|
|
window.cal_recorder.start(channel="fn", reference_load_n=load, duration_s=1.0)
|
|
for index in range(samples):
|
|
window.cal_recorder.feed(
|
|
SessionSample(
|
|
frame_id=index,
|
|
t_rel_s=index / 100.0,
|
|
fn_raw=raw,
|
|
fx_raw=0.4,
|
|
fy_raw=-0.2,
|
|
valid=True,
|
|
)
|
|
)
|
|
window.cal_panel.on_record_finished(window.cal_recorder.finish_now())
|
|
|
|
|
|
def test_calibration_diagnostics_drive_the_fit(make_window):
|
|
"""The diagnostics must describe the model that would be exported.
|
|
|
|
The export builds a piecewise-linear curve, which passes through every point
|
|
it is built from — so the training residual is zero by construction and an
|
|
off-trend point can only show up as a *validation* error. A private
|
|
least-squares residual would have described a model nobody applies.
|
|
"""
|
|
window = _show(make_window(), (1400, 880))
|
|
window.cal_panel.set_running(True)
|
|
_add_calibration_point(window, load=1.0, raw=100.2)
|
|
_add_calibration_point(window, load=2.0, raw=400.0)
|
|
_add_calibration_point(window, load=3.0, raw=300.6)
|
|
_add_calibration_point(window, load=5.0, raw=500.9)
|
|
|
|
recorder = window.cal_recorder
|
|
fit = recorder.fit("fn")
|
|
assert fit.usable and fit.n_points == 4
|
|
assert fit.method == "piecewise_linear", fit.method
|
|
assert fit.breakpoints == 4, fit.breakpoints
|
|
assert fit.max_residual_n <= 1e-9, "the exported model misses its own points"
|
|
assert window.cal_panel.fit_label.text()
|
|
|
|
# Hold the off-trend point (load 2.0 at raw 400) out: the exported model is
|
|
# then built without it and the misfit is measurable.
|
|
window.cal_panel.table.setCurrentCell(1, 0)
|
|
window.cal_panel._on_toggle_validation()
|
|
held_out = recorder.fit("fn")
|
|
assert held_out.validation_points == 1
|
|
assert held_out.validation_error_n > 0.05, held_out.validation_error_n
|
|
assert recorder.points[1].is_validation
|
|
assert np.isnan(recorder.points[1].residual_n), "validation point entered the fit"
|
|
|
|
window.cal_panel._on_toggle_validation()
|
|
window.cal_panel._on_toggle_excluded()
|
|
assert recorder.points[1].excluded
|
|
# An excluded point leaves the export store but stays on record.
|
|
assert len(window.cal_panel._store.channel("fn")) == 3
|
|
|
|
# The exported model no longer passes through the excluded point.
|
|
from orisys_app_shared.force.calibration_io import (
|
|
build_export_model,
|
|
predict_channel,
|
|
)
|
|
|
|
store = window.cal_panel._store
|
|
model = build_export_model({key: store.channel(key) for key in ("fn", "fx", "fy")})
|
|
assert abs(predict_channel(model, "fn", 400.0) - 2.0) > 0.05
|
|
|
|
|
|
def test_calibration_point_dialog_shows_three_axes(make_window):
|
|
window = _show(make_window(), (1400, 880))
|
|
_add_calibration_point(window, load=1.0, raw=100.2)
|
|
point = window.cal_recorder.points[0]
|
|
assert set(point.trace) == {"fn", "fx", "fy"}
|
|
assert point.trace_dt_s > 0
|
|
|
|
from orisys_studio.ui.cal_point_dialog import CalibrationPointDialog
|
|
|
|
dialog = CalibrationPointDialog(point, theme="dark")
|
|
assert dialog.windowTitle()
|
|
assert len(dialog.plot_widget.ci.items) == 3, "not one trace per axis"
|
|
dialog.close()
|
|
|
|
|
|
def test_force_plots_render_demo_data(make_window):
|
|
window = _show(make_window(), (1440, 880))
|
|
for i in range(60):
|
|
window.force_plots.push(i / 30.0, float(i), -1.0, 1.0, unit="N")
|
|
from PyQt5.QtWidgets import QApplication
|
|
|
|
QApplication.processEvents()
|
|
assert window.force_plots.history.sample_count > 0
|
|
assert all(label.text() for label in window.force_plots.value_labels)
|
|
|
|
|
|
# --------------------------------------------------------------- record channel
|
|
|
|
|
|
def _running_window(make_window):
|
|
"""A window in the ``running`` phase whose worker never opens a device.
|
|
|
|
The Qt timer is stopped deliberately: these tests drive the display path by
|
|
calling ``_on_run_tick`` themselves, and a concurrent 16 ms timer would race
|
|
the assertions (and outlive the window on teardown).
|
|
"""
|
|
window = _show(make_window(sensor_type="box", source_mode="camera"), (1440, 880))
|
|
stuck = ui_check._StuckWorker()
|
|
window._ensure_workers = lambda: None
|
|
window._box_worker = stuck
|
|
window._finger_worker = stuck
|
|
window._start_run()
|
|
window._run_timer.stop()
|
|
window._set_phase("running")
|
|
return window, stuck
|
|
|
|
|
|
def _finish(window, stuck):
|
|
window._stop_run(reason="user_stop")
|
|
stuck.release()
|
|
|
|
|
|
def test_record_channel_does_not_depend_on_the_ui_timer(make_window):
|
|
window, stuck = _running_window(make_window)
|
|
for index in range(90):
|
|
stuck.emit(ui_check._fake_packet(index))
|
|
|
|
assert window.session_store.count == 90, "samples were lost without a UI tick"
|
|
_finish(window, stuck)
|
|
|
|
|
|
def test_invalid_samples_are_excluded_not_zeroed(make_window):
|
|
import math
|
|
|
|
window, stuck = _running_window(make_window)
|
|
for index in range(9):
|
|
stuck.emit(ui_check._fake_packet(index))
|
|
stuck.emit(ui_check._fake_packet(99, valid=False, reason="read failed"))
|
|
|
|
slice_ = window.session_store.data()
|
|
assert slice_.valid.tolist()[-1] is False
|
|
assert math.isnan(slice_.raw[-1][0]), "a failed readout became a number"
|
|
assert sum(1 for _ in window.session_store.iter_valid("raw")) == 9
|
|
_finish(window, stuck)
|
|
|
|
|
|
def test_calibration_sampling_survives_a_hidden_panel(make_window):
|
|
window, stuck = _running_window(make_window)
|
|
window._show_cal = False
|
|
window._apply_panel_visibility()
|
|
assert not window.cal_panel.isVisible()
|
|
|
|
window.cal_recorder.start(channel="fn", reference_load_n=2.0, duration_s=1.0)
|
|
for index in range(40):
|
|
stuck.emit(ui_check._fake_packet(index))
|
|
point = window.cal_recorder.finish_now()
|
|
|
|
assert point is not None
|
|
assert point.n_valid == 40, "hiding the panel stopped the sampling"
|
|
assert point.status == "ok"
|
|
_finish(window, stuck)
|
|
|
|
|
|
def test_record_button_stays_a_cancel_affordance(make_window):
|
|
from orisys_app_shared.i18n import t
|
|
|
|
window, stuck = _running_window(make_window)
|
|
window.cal_panel.set_running(True)
|
|
window.cal_panel._on_record_clicked()
|
|
|
|
assert window.cal_panel._recording
|
|
assert window.cal_panel.btn_record.isEnabled(), "the recording cannot be stopped"
|
|
assert window.cal_panel.btn_record.text() == t("cal.recording_stop")
|
|
|
|
window.cal_panel._on_record_clicked()
|
|
assert not window.cal_panel._recording
|
|
_finish(window, stuck)
|
|
|
|
|
|
def test_unit_switch_rebuilds_from_the_session_store(make_window):
|
|
import math
|
|
|
|
window, stuck = _running_window(make_window)
|
|
window.toolbar.units_combo.setCurrentIndex(1) # raw
|
|
window.force_plots.reset()
|
|
for index in range(60):
|
|
stuck.emit(ui_check._fake_packet(index))
|
|
window._on_run_tick()
|
|
|
|
state = window.force_plots.history.state()
|
|
assert float(state.values[:, 0].max()) > 5.0, "raw values were not plotted"
|
|
|
|
window.toolbar.units_combo.setCurrentIndex(0) # newton
|
|
assert window.force_plots._unit == "N"
|
|
newtons = window.force_plots.history.state().values[:, 0]
|
|
finite = [v for v in newtons if math.isfinite(v)]
|
|
assert finite and max(finite) < 5.0, "the buffer still held raw values in N mode"
|
|
_finish(window, stuck)
|
|
|
|
|
|
def test_history_keeps_negative_fn(make_window):
|
|
window, stuck = _running_window(make_window)
|
|
window.force_plots.set_fixed_newton(True)
|
|
for index in range(20):
|
|
window.force_plots.push(index / 30.0, -2.5, 0.0, 0.0, unit="N")
|
|
window.force_plots.push(1.0, -3.0, 0.0, 0.0, unit="N")
|
|
|
|
state = window.force_plots.history.state()
|
|
assert float(state.values[:, 0].min()) < 0.0, (
|
|
"negative FN was clamped before entering the buffer"
|
|
)
|
|
_finish(window, stuck)
|
|
|
|
|
|
def test_depth_view_reports_missing_data_and_fixes_its_scale(make_window):
|
|
import numpy as np
|
|
|
|
window, stuck = _running_window(make_window)
|
|
window.arrow_panel.set_view_mode("depth")
|
|
|
|
window._last_rendered_view_mode = None
|
|
stuck.emit(ui_check._fake_packet(0, depth=None))
|
|
window._on_run_tick()
|
|
from orisys_app_shared.i18n import t
|
|
|
|
assert window.arrow_panel.image_label.text() == t("panels.depth_unavailable"), (
|
|
"a missing depth map silently fell back to the processed frame"
|
|
)
|
|
|
|
window._last_rendered_view_mode = None
|
|
stuck.emit(ui_check._fake_packet(1, depth=np.linspace(0.0, 1.0, 64).reshape(8, 8)))
|
|
window._on_run_tick()
|
|
latched = window.arrow_panel.depth_scale_label.text()
|
|
assert latched
|
|
|
|
window._last_rendered_view_mode = None
|
|
stuck.emit(ui_check._fake_packet(2, depth=np.linspace(0.0, 9.0, 64).reshape(8, 8)))
|
|
window._on_run_tick()
|
|
assert window.arrow_panel.depth_scale_label.text() == latched, (
|
|
"the depth colour scale re-fitted on every frame"
|
|
)
|
|
_finish(window, stuck)
|
|
|
|
|
|
# ------------------------------------------------------------- experiment record
|
|
|
|
#: Recording scratch goes under the app's gitignored ``outputs`` tree rather than
|
|
#: the system temp directory, so the tests also run under a file sandbox that
|
|
#: only permits writes inside the workspace.
|
|
RECORD_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_record"
|
|
|
|
|
|
@pytest.fixture
|
|
def record_root():
|
|
import shutil
|
|
|
|
shutil.rmtree(RECORD_SCRATCH, ignore_errors=True)
|
|
RECORD_SCRATCH.mkdir(parents=True, exist_ok=True)
|
|
yield RECORD_SCRATCH
|
|
shutil.rmtree(RECORD_SCRATCH, ignore_errors=True)
|
|
|
|
|
|
def test_recording_writes_the_documented_experiment_layout(make_window, record_root):
|
|
import csv
|
|
import json
|
|
|
|
import orisys_studio.app_window as app_module
|
|
from orisys_app_shared.i18n import t
|
|
|
|
window, stuck = _running_window(make_window)
|
|
app_module.recordings_dir = lambda: record_root
|
|
assert window.toolbar.btn_record.isEnabled()
|
|
assert window.toolbar.btn_record.text() == t("record.start_label")
|
|
|
|
window.toolbar.btn_record.setChecked(True)
|
|
assert window.recorder.is_recording, "the recording did not start"
|
|
assert window.toolbar.btn_record.text() == t("record.stop_label")
|
|
assert not window.toolbar.record_mode_combo.isEnabled()
|
|
|
|
for index in range(20):
|
|
stuck.emit(ui_check._fake_packet(index))
|
|
window._on_run_tick()
|
|
window.toolbar.btn_record.setChecked(False)
|
|
|
|
report = window._record_last_report
|
|
assert report is not None and report.ok, getattr(report, "problems", None)
|
|
names = sorted(path.name for path in report.directory.iterdir())
|
|
assert names == ["events.csv", "forces.csv", "frames.csv", "raw_0001.mp4", "session.json"]
|
|
|
|
with (report.directory / "forces.csv").open(encoding="utf-8") as handle:
|
|
forces = list(csv.DictReader(handle))
|
|
with (report.directory / "frames.csv").open(encoding="utf-8") as handle:
|
|
frames = list(csv.DictReader(handle))
|
|
assert len(forces) == len(frames) == 20
|
|
assert [f["frame_id"] for f in frames] == [r["frame_id"] for r in forces]
|
|
assert [f["t_rel_s"] for f in frames] == [r["t_rel_s"] for r in forces]
|
|
|
|
meta = json.loads((report.directory / "session.json").read_text(encoding="utf-8"))
|
|
assert meta["ended_at"]
|
|
assert meta["format_version"]
|
|
assert meta["stats"]["frames"] == 20
|
|
_finish(window, stuck)
|
|
|
|
|
|
def test_recording_leaves_unavailable_values_empty(make_window, record_root):
|
|
import csv
|
|
|
|
import orisys_studio.app_window as app_module
|
|
|
|
window, stuck = _running_window(make_window)
|
|
app_module.recordings_dir = lambda: record_root
|
|
window.toolbar.btn_record.setChecked(True)
|
|
stuck.emit(ui_check._fake_packet(0))
|
|
window._on_run_tick()
|
|
stuck.emit(ui_check._fake_packet(1, valid=False, reason="read failed"))
|
|
window._on_run_tick()
|
|
window.toolbar.btn_record.setChecked(False)
|
|
|
|
with (window._record_last_report.directory / "forces.csv").open(encoding="utf-8") as handle:
|
|
rows = list(csv.DictReader(handle))
|
|
failed = rows[-1]
|
|
assert failed["valid"] == "0"
|
|
assert failed["FN_raw"] == "" and failed["FN_N"] == "", (
|
|
"a failed readout was persisted as a number"
|
|
)
|
|
_finish(window, stuck)
|
|
|
|
|
|
|