Spaces:
Running
Running
RemiFabre commited on
Commit Β·
4a04219
1
Parent(s): ba39644
Add test infrastructure: 50 unit tests + 13 Playwright E2E tests
Browse filesTier 1 (tests/test_api.py): 50 backend unit tests using FastAPI TestClient
- Slugify, state endpoint, record/play/stop/delete endpoints
- Dataset CRUD, registry persistence, moves refresh, experiments
- Runs in <1s without hardware
Tier 3 (tests/e2e/test_ui.py): 13 Playwright browser tests
- Page load, idle state display, form validation, recording submission
- Dataset UI, cross-browser (Chromium + Firefox verified)
- Runs in ~16s with a real uvicorn server on port 18042
Run all: pytest tests/ --browser chromium
Run unit only: pytest tests/test_api.py
- tests/conftest.py +67 -0
- tests/e2e/conftest.py +62 -0
- tests/e2e/test_ui.py +132 -0
- tests/test_api.py +438 -0
tests/conftest.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared fixtures for Marionette tests."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from fastapi.testclient import TestClient
|
| 8 |
+
|
| 9 |
+
from marionette.main import Marionette, create_app
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture()
|
| 13 |
+
def tmp_registry(tmp_path: Path) -> Path:
|
| 14 |
+
"""Return a path for a temporary dataset registry file."""
|
| 15 |
+
return tmp_path / "dataset_registry.json"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@pytest.fixture()
|
| 19 |
+
def tmp_dataset_root(tmp_path: Path) -> Path:
|
| 20 |
+
"""Return a temporary directory for datasets."""
|
| 21 |
+
root = tmp_path / "datasets"
|
| 22 |
+
root.mkdir()
|
| 23 |
+
return root
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@pytest.fixture()
|
| 27 |
+
def marionette_app(tmp_registry: Path, tmp_dataset_root: Path) -> tuple[TestClient, Marionette]:
|
| 28 |
+
"""Create a Marionette app with TestClient, using isolated temp paths."""
|
| 29 |
+
app, m = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root)
|
| 30 |
+
client = TestClient(app)
|
| 31 |
+
return client, m
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.fixture()
|
| 35 |
+
def client(marionette_app: tuple[TestClient, Marionette]) -> TestClient:
|
| 36 |
+
"""Convenience fixture: just the TestClient."""
|
| 37 |
+
return marionette_app[0]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@pytest.fixture()
|
| 41 |
+
def marionette(marionette_app: tuple[TestClient, Marionette]) -> Marionette:
|
| 42 |
+
"""Convenience fixture: just the Marionette instance."""
|
| 43 |
+
return marionette_app[1]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@pytest.fixture()
|
| 47 |
+
def sample_move_json() -> dict:
|
| 48 |
+
"""A minimal valid move JSON structure matching RecordedMove expectations."""
|
| 49 |
+
timestamps = [i * 0.01 for i in range(500)] # 5 seconds at 100Hz
|
| 50 |
+
frames = []
|
| 51 |
+
for _ in timestamps:
|
| 52 |
+
frames.append({
|
| 53 |
+
"head": [
|
| 54 |
+
[1.0, 0.0, 0.0, 0.0],
|
| 55 |
+
[0.0, 1.0, 0.0, 0.0],
|
| 56 |
+
[0.0, 0.0, 1.0, 0.0],
|
| 57 |
+
[0.0, 0.0, 0.0, 1.0],
|
| 58 |
+
],
|
| 59 |
+
"antennas": [0.0, 0.0],
|
| 60 |
+
"body_yaw": 0.0,
|
| 61 |
+
"check_collision": False,
|
| 62 |
+
})
|
| 63 |
+
return {
|
| 64 |
+
"description": "test move",
|
| 65 |
+
"time": timestamps,
|
| 66 |
+
"set_target_data": frames,
|
| 67 |
+
}
|
tests/e2e/conftest.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fixtures for Playwright E2E tests.
|
| 2 |
+
|
| 3 |
+
Starts a real Marionette server (with temp paths) and provides the base URL.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import threading
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
import uvicorn
|
| 11 |
+
|
| 12 |
+
from marionette.main import create_app
|
| 13 |
+
|
| 14 |
+
# Use a high port to avoid conflicts
|
| 15 |
+
TEST_PORT = 18042
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class _ServerThread:
|
| 19 |
+
"""Runs a uvicorn server in a background thread."""
|
| 20 |
+
|
| 21 |
+
def __init__(self, app, port: int):
|
| 22 |
+
self.config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
|
| 23 |
+
self.server = uvicorn.Server(self.config)
|
| 24 |
+
self.thread = threading.Thread(target=self.server.run, daemon=True)
|
| 25 |
+
|
| 26 |
+
def start(self):
|
| 27 |
+
self.thread.start()
|
| 28 |
+
# Wait for the server to be ready
|
| 29 |
+
for _ in range(50):
|
| 30 |
+
if self.server.started:
|
| 31 |
+
break
|
| 32 |
+
time.sleep(0.1)
|
| 33 |
+
|
| 34 |
+
def stop(self):
|
| 35 |
+
self.server.should_exit = True
|
| 36 |
+
self.thread.join(timeout=5)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@pytest.fixture(scope="session")
|
| 40 |
+
def _test_server(tmp_path_factory):
|
| 41 |
+
"""Start a Marionette server for the entire test session."""
|
| 42 |
+
tmp = tmp_path_factory.mktemp("e2e")
|
| 43 |
+
app, marionette = create_app(
|
| 44 |
+
registry_path=tmp / "registry.json",
|
| 45 |
+
dataset_root=tmp / "datasets",
|
| 46 |
+
)
|
| 47 |
+
srv = _ServerThread(app, TEST_PORT)
|
| 48 |
+
srv.start()
|
| 49 |
+
yield f"http://127.0.0.1:{TEST_PORT}", marionette
|
| 50 |
+
srv.stop()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@pytest.fixture(scope="session")
|
| 54 |
+
def base_url(_test_server):
|
| 55 |
+
"""Return the base URL of the test server (session-scoped for pytest-playwright)."""
|
| 56 |
+
return _test_server[0]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@pytest.fixture(scope="session")
|
| 60 |
+
def test_marionette(_test_server):
|
| 61 |
+
"""Return the Marionette instance backing the test server."""
|
| 62 |
+
return _test_server[1]
|
tests/e2e/test_ui.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tier 3 β Playwright E2E browser tests for Marionette.
|
| 2 |
+
|
| 3 |
+
Run without hardware, without daemon. Tests the frontend UI in real browsers.
|
| 4 |
+
Uses a real Marionette server with temp paths (no robot connection needed for
|
| 5 |
+
these tests β they only test the web UI behavior).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
import pytest
|
| 10 |
+
from playwright.sync_api import Page, expect
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# ββββββββ Page load tests βββββββββββββββββββββββββββββββββββββββββββββ
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class TestPageLoad:
|
| 17 |
+
def test_page_loads_successfully(self, page: Page, base_url: str):
|
| 18 |
+
page.goto(base_url)
|
| 19 |
+
expect(page).to_have_title(re.compile("Marionette", re.IGNORECASE))
|
| 20 |
+
|
| 21 |
+
def test_main_sections_visible(self, page: Page, base_url: str):
|
| 22 |
+
page.goto(base_url)
|
| 23 |
+
# Record section
|
| 24 |
+
expect(page.locator("#record-form")).to_be_visible()
|
| 25 |
+
# Moves section
|
| 26 |
+
expect(page.locator("#moves-list")).to_be_visible()
|
| 27 |
+
# Record button
|
| 28 |
+
expect(page.locator("#record-btn")).to_be_visible()
|
| 29 |
+
|
| 30 |
+
def test_mode_pill_shows_idle(self, page: Page, base_url: str):
|
| 31 |
+
page.goto(base_url)
|
| 32 |
+
pill = page.locator("#mode-pill")
|
| 33 |
+
expect(pill).to_be_visible()
|
| 34 |
+
# Wait for first poll to update the pill
|
| 35 |
+
page.wait_for_timeout(2000)
|
| 36 |
+
expect(pill).to_contain_text(re.compile("idle", re.IGNORECASE))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ββββββββ Idle state display ββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class TestIdleState:
|
| 43 |
+
def test_record_button_enabled(self, page: Page, base_url: str):
|
| 44 |
+
page.goto(base_url)
|
| 45 |
+
page.wait_for_timeout(2000)
|
| 46 |
+
btn = page.locator("#record-btn")
|
| 47 |
+
expect(btn).to_be_enabled()
|
| 48 |
+
|
| 49 |
+
def test_stop_buttons_hidden(self, page: Page, base_url: str):
|
| 50 |
+
page.goto(base_url)
|
| 51 |
+
page.wait_for_timeout(2000)
|
| 52 |
+
expect(page.locator("#stop-recording-btn")).to_be_hidden()
|
| 53 |
+
expect(page.locator("#stop-playback-btn")).to_be_hidden()
|
| 54 |
+
|
| 55 |
+
def test_progress_bar_empty(self, page: Page, base_url: str):
|
| 56 |
+
page.goto(base_url)
|
| 57 |
+
page.wait_for_timeout(2000)
|
| 58 |
+
fill = page.locator("#progress-fill")
|
| 59 |
+
width = fill.evaluate("el => el.style.width")
|
| 60 |
+
assert width == "0%" or width == ""
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ββββββββ Form validation tests βββββββββββββββββββββββββββββββββββββββ
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class TestFormValidation:
|
| 67 |
+
def test_duration_field_exists(self, page: Page, base_url: str):
|
| 68 |
+
page.goto(base_url)
|
| 69 |
+
duration = page.locator("#duration")
|
| 70 |
+
expect(duration).to_be_visible()
|
| 71 |
+
# Check it has a default value
|
| 72 |
+
value = duration.input_value()
|
| 73 |
+
assert float(value) > 0
|
| 74 |
+
|
| 75 |
+
def test_duration_accepts_decimal(self, page: Page, base_url: str):
|
| 76 |
+
page.goto(base_url)
|
| 77 |
+
duration = page.locator("#duration")
|
| 78 |
+
duration.fill("3.7")
|
| 79 |
+
assert duration.input_value() == "3.7"
|
| 80 |
+
|
| 81 |
+
def test_audio_source_radios_exist(self, page: Page, base_url: str):
|
| 82 |
+
page.goto(base_url)
|
| 83 |
+
expect(page.locator("#audio-source-mic")).to_be_visible()
|
| 84 |
+
expect(page.locator("#audio-source-none")).to_be_visible()
|
| 85 |
+
|
| 86 |
+
def test_label_field_exists(self, page: Page, base_url: str):
|
| 87 |
+
page.goto(base_url)
|
| 88 |
+
expect(page.locator("#label")).to_be_visible()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ββββββββ Recording submission tests ββββββββββββββββββββββββββββββββββ
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class TestRecordingSubmission:
|
| 95 |
+
def test_submit_recording_changes_mode(self, page: Page, base_url: str, test_marionette):
|
| 96 |
+
page.goto(base_url)
|
| 97 |
+
page.wait_for_timeout(2000)
|
| 98 |
+
|
| 99 |
+
# Set audio source to "none" to avoid audio backend requirement
|
| 100 |
+
page.locator("#audio-source-none").check()
|
| 101 |
+
page.locator("#duration").fill("2")
|
| 102 |
+
page.locator("#label").fill("e2e-test")
|
| 103 |
+
|
| 104 |
+
# Submit
|
| 105 |
+
page.locator("#record-btn").click()
|
| 106 |
+
|
| 107 |
+
# Wait for state to update
|
| 108 |
+
page.wait_for_timeout(2000)
|
| 109 |
+
|
| 110 |
+
# Mode pill should show something other than "idle"
|
| 111 |
+
pill_text = page.locator("#mode-pill").text_content()
|
| 112 |
+
assert pill_text is not None
|
| 113 |
+
# Mode should be queued (since no robot is running to process it)
|
| 114 |
+
assert "queued" in pill_text.lower() or "countdown" in pill_text.lower() or "recording" in pill_text.lower()
|
| 115 |
+
|
| 116 |
+
# Reset for other tests
|
| 117 |
+
test_marionette._set_idle_state()
|
| 118 |
+
test_marionette._pending_recording = None
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ββββββββ Dataset UI tests ββββββββββββββββββββββββββββββββββββββββββββ
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class TestDatasetUI:
|
| 125 |
+
def test_dataset_selector_visible(self, page: Page, base_url: str):
|
| 126 |
+
page.goto(base_url)
|
| 127 |
+
page.wait_for_timeout(2000)
|
| 128 |
+
expect(page.locator("#dataset-select")).to_be_visible()
|
| 129 |
+
|
| 130 |
+
def test_new_dataset_button_exists(self, page: Page, base_url: str):
|
| 131 |
+
page.goto(base_url)
|
| 132 |
+
expect(page.locator("#new-dataset-btn")).to_be_visible()
|
tests/test_api.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tier 1 β Backend unit tests for Marionette.
|
| 2 |
+
|
| 3 |
+
Run without hardware, without daemon, in under 5 seconds.
|
| 4 |
+
Tests the HTTP API layer, state machine, data validation, and persistence.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
from io import BytesIO
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import pytest
|
| 12 |
+
from fastapi.testclient import TestClient
|
| 13 |
+
|
| 14 |
+
from marionette.main import (
|
| 15 |
+
Marionette,
|
| 16 |
+
_slugify,
|
| 17 |
+
create_app,
|
| 18 |
+
DEFAULT_DURATION,
|
| 19 |
+
COUNTDOWN_SECONDS,
|
| 20 |
+
MOTION_SAMPLE_RATE,
|
| 21 |
+
DATASET_DATA_SUBDIR,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ββββββββ Utility function tests ββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class TestSlugify:
|
| 29 |
+
def test_simple_lowercase(self):
|
| 30 |
+
assert _slugify("Hello World") == "hello-world"
|
| 31 |
+
|
| 32 |
+
def test_special_chars(self):
|
| 33 |
+
assert _slugify("my@move#1!") == "my-move-1"
|
| 34 |
+
|
| 35 |
+
def test_leading_trailing_hyphens(self):
|
| 36 |
+
assert _slugify("---test---") == "test"
|
| 37 |
+
|
| 38 |
+
def test_empty_string(self):
|
| 39 |
+
assert _slugify("") == "take"
|
| 40 |
+
|
| 41 |
+
def test_unicode(self):
|
| 42 |
+
result = _slugify("cafΓ© rΓ©sumΓ©")
|
| 43 |
+
assert result == "caf-r-sum"
|
| 44 |
+
|
| 45 |
+
def test_already_slugified(self):
|
| 46 |
+
assert _slugify("gentle-nod") == "gentle-nod"
|
| 47 |
+
|
| 48 |
+
def test_numbers(self):
|
| 49 |
+
assert _slugify("take 42") == "take-42"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ββββββββ State endpoint tests ββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class TestStateEndpoint:
|
| 56 |
+
def test_returns_200(self, client: TestClient):
|
| 57 |
+
resp = client.get("/api/state")
|
| 58 |
+
assert resp.status_code == 200
|
| 59 |
+
|
| 60 |
+
def test_initial_mode_is_idle(self, client: TestClient):
|
| 61 |
+
data = client.get("/api/state").json()
|
| 62 |
+
assert data["mode"] == "idle"
|
| 63 |
+
|
| 64 |
+
def test_initial_message(self, client: TestClient):
|
| 65 |
+
data = client.get("/api/state").json()
|
| 66 |
+
assert data["message"] == "Ready to capture moves"
|
| 67 |
+
|
| 68 |
+
def test_state_shape(self, client: TestClient):
|
| 69 |
+
data = client.get("/api/state").json()
|
| 70 |
+
required_keys = {
|
| 71 |
+
"mode", "message", "active_move", "countdown_ends_at",
|
| 72 |
+
"recording_started_at", "recording_duration", "recording_stats",
|
| 73 |
+
"pending_recording", "pending_playback", "pending_denoise",
|
| 74 |
+
"moves", "config", "datasets",
|
| 75 |
+
}
|
| 76 |
+
assert required_keys.issubset(data.keys())
|
| 77 |
+
|
| 78 |
+
def test_config_shape(self, client: TestClient):
|
| 79 |
+
config = client.get("/api/state").json()["config"]
|
| 80 |
+
assert config["default_duration"] == DEFAULT_DURATION
|
| 81 |
+
assert config["countdown_seconds"] == COUNTDOWN_SECONDS
|
| 82 |
+
assert config["motion_sample_rate"] == MOTION_SAMPLE_RATE
|
| 83 |
+
assert isinstance(config["audio_available"], bool)
|
| 84 |
+
assert isinstance(config["features"], dict)
|
| 85 |
+
|
| 86 |
+
def test_initial_moves_empty(self, client: TestClient):
|
| 87 |
+
data = client.get("/api/state").json()
|
| 88 |
+
assert data["moves"] == []
|
| 89 |
+
|
| 90 |
+
def test_initial_no_pending(self, client: TestClient):
|
| 91 |
+
data = client.get("/api/state").json()
|
| 92 |
+
assert data["pending_recording"] is None
|
| 93 |
+
assert data["pending_playback"] is None
|
| 94 |
+
assert data["pending_denoise"] is None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ββββββββ Recording endpoint tests ββββββββββββββββββββββββββββββββββββ
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class TestRecordEndpoint:
|
| 101 |
+
def test_accept_basic_recording(self, client: TestClient):
|
| 102 |
+
resp = client.post("/api/record", json={
|
| 103 |
+
"duration": 3.0,
|
| 104 |
+
"record_audio": False,
|
| 105 |
+
})
|
| 106 |
+
assert resp.status_code == 200
|
| 107 |
+
data = resp.json()
|
| 108 |
+
assert data["accepted"] is True
|
| 109 |
+
assert "move_id" in data
|
| 110 |
+
|
| 111 |
+
def test_mode_becomes_queued(self, client: TestClient):
|
| 112 |
+
client.post("/api/record", json={"duration": 3.0, "record_audio": False})
|
| 113 |
+
state = client.get("/api/state").json()
|
| 114 |
+
assert state["mode"] == "queued"
|
| 115 |
+
|
| 116 |
+
def test_reject_when_busy(self, client: TestClient):
|
| 117 |
+
# First recording is accepted
|
| 118 |
+
resp1 = client.post("/api/record", json={"duration": 3.0, "record_audio": False})
|
| 119 |
+
assert resp1.status_code == 200
|
| 120 |
+
# Second recording is rejected (mode is now "queued")
|
| 121 |
+
resp2 = client.post("/api/record", json={"duration": 3.0, "record_audio": False})
|
| 122 |
+
assert resp2.status_code == 409
|
| 123 |
+
|
| 124 |
+
def test_reject_invalid_duration_too_low(self, client: TestClient):
|
| 125 |
+
resp = client.post("/api/record", json={"duration": 0.1, "record_audio": False})
|
| 126 |
+
assert resp.status_code == 422 # Pydantic validation
|
| 127 |
+
|
| 128 |
+
def test_reject_invalid_duration_too_high(self, client: TestClient):
|
| 129 |
+
resp = client.post("/api/record", json={"duration": 999.0, "record_audio": False})
|
| 130 |
+
assert resp.status_code == 422
|
| 131 |
+
|
| 132 |
+
def test_accept_duration_edge_cases(self, client: TestClient, marionette: Marionette):
|
| 133 |
+
# Just above minimum
|
| 134 |
+
resp = client.post("/api/record", json={"duration": 0.6, "record_audio": False})
|
| 135 |
+
assert resp.status_code == 200
|
| 136 |
+
# Reset for next test
|
| 137 |
+
marionette._set_idle_state()
|
| 138 |
+
marionette._pending_recording = None
|
| 139 |
+
|
| 140 |
+
# At maximum
|
| 141 |
+
resp = client.post("/api/record", json={"duration": 300.0, "record_audio": False})
|
| 142 |
+
assert resp.status_code == 200
|
| 143 |
+
|
| 144 |
+
def test_custom_label(self, client: TestClient):
|
| 145 |
+
resp = client.post("/api/record", json={
|
| 146 |
+
"duration": 3.0,
|
| 147 |
+
"record_audio": False,
|
| 148 |
+
"label": "happy-dance",
|
| 149 |
+
})
|
| 150 |
+
data = resp.json()
|
| 151 |
+
assert data["label"] == "happy-dance"
|
| 152 |
+
assert data["move_id"] == "happy-dance"
|
| 153 |
+
|
| 154 |
+
def test_label_collision_appends_index(
|
| 155 |
+
self, client: TestClient, marionette: Marionette, tmp_dataset_root: Path
|
| 156 |
+
):
|
| 157 |
+
# Create a file that would collide
|
| 158 |
+
data_dir = tmp_dataset_root / "local_dataset" / DATASET_DATA_SUBDIR
|
| 159 |
+
data_dir.mkdir(parents=True, exist_ok=True)
|
| 160 |
+
(data_dir / "happy-dance.json").write_text("{}")
|
| 161 |
+
|
| 162 |
+
resp = client.post("/api/record", json={
|
| 163 |
+
"duration": 3.0,
|
| 164 |
+
"record_audio": False,
|
| 165 |
+
"label": "happy-dance",
|
| 166 |
+
})
|
| 167 |
+
data = resp.json()
|
| 168 |
+
assert data["move_id"] == "happy-dance-1"
|
| 169 |
+
|
| 170 |
+
def test_preferred_duration_saved(self, client: TestClient, marionette: Marionette):
|
| 171 |
+
client.post("/api/record", json={"duration": 7.5, "record_audio": False})
|
| 172 |
+
assert marionette._preferred_duration == 7.5
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# ββββββββ Playback endpoint tests βββββββββββββββββββββββββββββββββββββ
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
class TestPlayEndpoint:
|
| 179 |
+
def test_reject_missing_move(self, client: TestClient):
|
| 180 |
+
resp = client.post("/api/play", json={"move_id": "nonexistent"})
|
| 181 |
+
assert resp.status_code == 404
|
| 182 |
+
|
| 183 |
+
def test_accept_existing_move(
|
| 184 |
+
self, client: TestClient, marionette: Marionette, sample_move_json: dict
|
| 185 |
+
):
|
| 186 |
+
# Write a move file to the dataset
|
| 187 |
+
data_dir = marionette._dataset_dir
|
| 188 |
+
move_path = data_dir / "test-move.json"
|
| 189 |
+
move_path.write_text(json.dumps(sample_move_json))
|
| 190 |
+
marionette._refresh_recordings()
|
| 191 |
+
|
| 192 |
+
resp = client.post("/api/play", json={"move_id": "test-move"})
|
| 193 |
+
assert resp.status_code == 200
|
| 194 |
+
assert resp.json()["accepted"] is True
|
| 195 |
+
|
| 196 |
+
def test_reject_play_when_busy(
|
| 197 |
+
self, client: TestClient, marionette: Marionette, sample_move_json: dict
|
| 198 |
+
):
|
| 199 |
+
data_dir = marionette._dataset_dir
|
| 200 |
+
(data_dir / "test-move.json").write_text(json.dumps(sample_move_json))
|
| 201 |
+
marionette._refresh_recordings()
|
| 202 |
+
|
| 203 |
+
# First play is accepted
|
| 204 |
+
resp1 = client.post("/api/play", json={"move_id": "test-move"})
|
| 205 |
+
assert resp1.status_code == 200
|
| 206 |
+
# Second play is rejected (mode is queued)
|
| 207 |
+
resp2 = client.post("/api/play", json={"move_id": "test-move"})
|
| 208 |
+
assert resp2.status_code == 409
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ββββββββ Stop endpoints ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
class TestStopEndpoints:
|
| 215 |
+
def test_stop_playback_when_not_playing(self, client: TestClient):
|
| 216 |
+
resp = client.post("/api/play/stop")
|
| 217 |
+
assert resp.status_code == 200
|
| 218 |
+
assert resp.json()["stopped"] is False
|
| 219 |
+
|
| 220 |
+
def test_stop_recording_when_not_recording(self, client: TestClient):
|
| 221 |
+
resp = client.post("/api/record/stop")
|
| 222 |
+
assert resp.status_code == 200
|
| 223 |
+
assert resp.json()["stopped"] is False
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ββββββββ Move deletion tests βββββββββββββββββββββββββββββββββββββββββ
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
class TestMoveDelete:
|
| 230 |
+
def test_delete_existing_move(
|
| 231 |
+
self, client: TestClient, marionette: Marionette, sample_move_json: dict
|
| 232 |
+
):
|
| 233 |
+
data_dir = marionette._dataset_dir
|
| 234 |
+
move_path = data_dir / "to-delete.json"
|
| 235 |
+
move_path.write_text(json.dumps(sample_move_json))
|
| 236 |
+
marionette._refresh_recordings()
|
| 237 |
+
|
| 238 |
+
resp = client.delete("/api/moves/to-delete")
|
| 239 |
+
assert resp.status_code == 200
|
| 240 |
+
assert not move_path.exists()
|
| 241 |
+
|
| 242 |
+
def test_delete_nonexistent_move(self, client: TestClient):
|
| 243 |
+
resp = client.delete("/api/moves/nonexistent")
|
| 244 |
+
assert resp.status_code == 404
|
| 245 |
+
|
| 246 |
+
def test_delete_removes_wav(
|
| 247 |
+
self, client: TestClient, marionette: Marionette, sample_move_json: dict
|
| 248 |
+
):
|
| 249 |
+
data_dir = marionette._dataset_dir
|
| 250 |
+
(data_dir / "with-audio.json").write_text(json.dumps(sample_move_json))
|
| 251 |
+
(data_dir / "with-audio.wav").write_bytes(b"RIFF" + b"\x00" * 100)
|
| 252 |
+
marionette._refresh_recordings()
|
| 253 |
+
|
| 254 |
+
client.delete("/api/moves/with-audio")
|
| 255 |
+
assert not (data_dir / "with-audio.json").exists()
|
| 256 |
+
assert not (data_dir / "with-audio.wav").exists()
|
| 257 |
+
|
| 258 |
+
def test_delete_updates_move_list(
|
| 259 |
+
self, client: TestClient, marionette: Marionette, sample_move_json: dict
|
| 260 |
+
):
|
| 261 |
+
data_dir = marionette._dataset_dir
|
| 262 |
+
(data_dir / "test-move.json").write_text(json.dumps(sample_move_json))
|
| 263 |
+
marionette._refresh_recordings()
|
| 264 |
+
|
| 265 |
+
state_before = client.get("/api/state").json()
|
| 266 |
+
assert len(state_before["moves"]) == 1
|
| 267 |
+
|
| 268 |
+
client.delete("/api/moves/test-move")
|
| 269 |
+
state_after = client.get("/api/state").json()
|
| 270 |
+
assert len(state_after["moves"]) == 0
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# ββββββββ Dataset management tests ββββββββββββββββββββββββββββββββββββ
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
class TestDatasets:
|
| 277 |
+
def test_initial_default_dataset(self, client: TestClient):
|
| 278 |
+
data = client.get("/api/state").json()
|
| 279 |
+
datasets = data["datasets"]
|
| 280 |
+
assert datasets["active_id"] is not None
|
| 281 |
+
assert len(datasets["entries"]) >= 1
|
| 282 |
+
|
| 283 |
+
def test_create_dataset(self, client: TestClient):
|
| 284 |
+
resp = client.post("/api/datasets", json={"name": "My Dances"})
|
| 285 |
+
assert resp.status_code == 200
|
| 286 |
+
data = resp.json()
|
| 287 |
+
assert data["status"] == "created"
|
| 288 |
+
assert data["dataset"]["folder"] == "my-dances"
|
| 289 |
+
|
| 290 |
+
def test_create_duplicate_dataset_rejected(self, client: TestClient):
|
| 291 |
+
client.post("/api/datasets", json={"name": "dances"})
|
| 292 |
+
resp = client.post("/api/datasets", json={"name": "dances"})
|
| 293 |
+
assert resp.status_code == 409
|
| 294 |
+
|
| 295 |
+
def test_select_dataset(self, client: TestClient):
|
| 296 |
+
# Create a second dataset
|
| 297 |
+
resp = client.post("/api/datasets", json={"name": "second"})
|
| 298 |
+
dataset_id = resp.json()["dataset"]["id"]
|
| 299 |
+
|
| 300 |
+
# Default is auto-selected after create, so select the original
|
| 301 |
+
state = client.get("/api/state").json()
|
| 302 |
+
original_id = [
|
| 303 |
+
e["id"] for e in state["datasets"]["entries"]
|
| 304 |
+
if e["id"] != dataset_id
|
| 305 |
+
][0]
|
| 306 |
+
|
| 307 |
+
resp = client.post("/api/datasets/select", json={"dataset_id": original_id})
|
| 308 |
+
assert resp.status_code == 200
|
| 309 |
+
|
| 310 |
+
def test_select_nonexistent_dataset(self, client: TestClient):
|
| 311 |
+
resp = client.post("/api/datasets/select", json={"dataset_id": "fake"})
|
| 312 |
+
assert resp.status_code == 404
|
| 313 |
+
|
| 314 |
+
def test_dataset_root_change(self, client: TestClient, tmp_path: Path):
|
| 315 |
+
new_root = tmp_path / "new_root"
|
| 316 |
+
new_root.mkdir()
|
| 317 |
+
resp = client.post("/api/datasets/root", json={"path": str(new_root)})
|
| 318 |
+
assert resp.status_code == 200
|
| 319 |
+
assert resp.json()["root_path"] == str(new_root)
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
# ββββββββ Registry persistence tests ββββββββββββββββββββββββββββββββββ
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
class TestRegistryPersistence:
|
| 326 |
+
def test_registry_created_on_init(self, tmp_registry: Path, tmp_dataset_root: Path):
|
| 327 |
+
create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root)
|
| 328 |
+
assert tmp_registry.exists()
|
| 329 |
+
data = json.loads(tmp_registry.read_text())
|
| 330 |
+
assert "active" in data
|
| 331 |
+
assert "datasets" in data
|
| 332 |
+
|
| 333 |
+
def test_registry_survives_restart(self, tmp_registry: Path, tmp_dataset_root: Path):
|
| 334 |
+
# First instance creates a dataset
|
| 335 |
+
app1, m1 = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root)
|
| 336 |
+
client1 = TestClient(app1)
|
| 337 |
+
client1.post("/api/datasets", json={"name": "persistent-ds"})
|
| 338 |
+
|
| 339 |
+
# Second instance should see it
|
| 340 |
+
app2, m2 = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root)
|
| 341 |
+
client2 = TestClient(app2)
|
| 342 |
+
state = client2.get("/api/state").json()
|
| 343 |
+
folders = [e["folder"] for e in state["datasets"]["entries"]]
|
| 344 |
+
assert "persistent-ds" in folders
|
| 345 |
+
|
| 346 |
+
def test_preferred_duration_persisted(self, tmp_registry: Path, tmp_dataset_root: Path):
|
| 347 |
+
app1, m1 = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root)
|
| 348 |
+
client1 = TestClient(app1)
|
| 349 |
+
client1.post("/api/record", json={"duration": 8.5, "record_audio": False})
|
| 350 |
+
|
| 351 |
+
# Re-create and check
|
| 352 |
+
_, m2 = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root)
|
| 353 |
+
assert m2._preferred_duration == 8.5
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
# ββββββββ Moves list / refresh tests ββββββββββββββββββββββββββββββββββ
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
class TestMovesRefresh:
|
| 360 |
+
def test_moves_appear_after_file_creation(
|
| 361 |
+
self, client: TestClient, marionette: Marionette, sample_move_json: dict
|
| 362 |
+
):
|
| 363 |
+
data_dir = marionette._dataset_dir
|
| 364 |
+
(data_dir / "my-move.json").write_text(json.dumps(sample_move_json))
|
| 365 |
+
marionette._refresh_recordings()
|
| 366 |
+
|
| 367 |
+
state = client.get("/api/state").json()
|
| 368 |
+
move_ids = [m["id"] for m in state["moves"]]
|
| 369 |
+
assert "my-move" in move_ids
|
| 370 |
+
|
| 371 |
+
def test_move_duration_computed_correctly(
|
| 372 |
+
self, client: TestClient, marionette: Marionette, sample_move_json: dict
|
| 373 |
+
):
|
| 374 |
+
data_dir = marionette._dataset_dir
|
| 375 |
+
(data_dir / "timed.json").write_text(json.dumps(sample_move_json))
|
| 376 |
+
marionette._refresh_recordings()
|
| 377 |
+
|
| 378 |
+
state = client.get("/api/state").json()
|
| 379 |
+
move = next(m for m in state["moves"] if m["id"] == "timed")
|
| 380 |
+
# 500 frames at 100Hz = 4.99s (last timestamp is 4.99)
|
| 381 |
+
assert 4.5 < move["duration"] < 5.5
|
| 382 |
+
|
| 383 |
+
def test_move_has_audio_flag(
|
| 384 |
+
self, client: TestClient, marionette: Marionette, sample_move_json: dict
|
| 385 |
+
):
|
| 386 |
+
data_dir = marionette._dataset_dir
|
| 387 |
+
(data_dir / "audio-move.json").write_text(json.dumps(sample_move_json))
|
| 388 |
+
(data_dir / "audio-move.wav").write_bytes(b"RIFF" + b"\x00" * 100)
|
| 389 |
+
marionette._refresh_recordings()
|
| 390 |
+
|
| 391 |
+
state = client.get("/api/state").json()
|
| 392 |
+
move = next(m for m in state["moves"] if m["id"] == "audio-move")
|
| 393 |
+
assert move["has_audio"] is True
|
| 394 |
+
|
| 395 |
+
def test_move_without_audio(
|
| 396 |
+
self, client: TestClient, marionette: Marionette, sample_move_json: dict
|
| 397 |
+
):
|
| 398 |
+
data_dir = marionette._dataset_dir
|
| 399 |
+
(data_dir / "silent-move.json").write_text(json.dumps(sample_move_json))
|
| 400 |
+
marionette._refresh_recordings()
|
| 401 |
+
|
| 402 |
+
state = client.get("/api/state").json()
|
| 403 |
+
move = next(m for m in state["moves"] if m["id"] == "silent-move")
|
| 404 |
+
assert move["has_audio"] is False
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
# ββββββββ Experiment / feature toggle tests βββββββββββββββββββββββββββ
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
class TestExperiments:
|
| 411 |
+
def test_toggle_denoise(self, client: TestClient):
|
| 412 |
+
resp = client.post("/api/experiments", json={"denoise": True})
|
| 413 |
+
assert resp.status_code == 200
|
| 414 |
+
assert resp.json()["features"]["denoise"] is True
|
| 415 |
+
|
| 416 |
+
def test_toggle_motion_models(self, client: TestClient):
|
| 417 |
+
resp = client.post("/api/experiments", json={"motion_models": True})
|
| 418 |
+
assert resp.status_code == 200
|
| 419 |
+
assert resp.json()["features"]["motion_models"] is True
|
| 420 |
+
|
| 421 |
+
def test_update_duration(self, client: TestClient):
|
| 422 |
+
resp = client.post("/api/experiments", json={"duration_seconds": 10.0})
|
| 423 |
+
assert resp.status_code == 200
|
| 424 |
+
assert resp.json()["preferred_duration"] == 10.0
|
| 425 |
+
|
| 426 |
+
def test_no_changes(self, client: TestClient):
|
| 427 |
+
resp = client.post("/api/experiments", json={})
|
| 428 |
+
assert resp.json()["status"] == "unchanged"
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
# ββββββββ Sensor data dummy endpoint ββββββββββββββββββββββββββββββββββ
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
class TestSensorData:
|
| 435 |
+
def test_returns_empty(self, client: TestClient):
|
| 436 |
+
resp = client.get("/sensor_data")
|
| 437 |
+
assert resp.status_code == 200
|
| 438 |
+
assert resp.json() == {}
|