RemiFabre commited on
Commit
4a04219
Β·
1 Parent(s): ba39644

Add test infrastructure: 50 unit tests + 13 Playwright E2E tests

Browse files

Tier 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 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() == {}