Spaces:
Running
Running
| """Tier 1 β Backend unit tests for Marionette. | |
| Run without hardware, without daemon, in under 5 seconds. | |
| Tests the HTTP API layer, state machine, data validation, and persistence. | |
| """ | |
| import json | |
| from io import BytesIO | |
| from pathlib import Path | |
| import pytest | |
| from fastapi.testclient import TestClient | |
| from marionette.main import ( | |
| Marionette, | |
| _slugify, | |
| create_app, | |
| DEFAULT_DURATION, | |
| COUNTDOWN_SECONDS, | |
| MOTION_SAMPLE_RATE, | |
| DATASET_DATA_SUBDIR, | |
| ) | |
| # ββββββββ Utility function tests ββββββββββββββββββββββββββββββββββββββ | |
| class TestSlugify: | |
| def test_simple_lowercase(self): | |
| assert _slugify("Hello World") == "hello-world" | |
| def test_special_chars(self): | |
| assert _slugify("my@move#1!") == "my-move-1" | |
| def test_leading_trailing_hyphens(self): | |
| assert _slugify("---test---") == "test" | |
| def test_empty_string(self): | |
| assert _slugify("") == "take" | |
| def test_unicode(self): | |
| result = _slugify("cafΓ© rΓ©sumΓ©") | |
| assert result == "caf-r-sum" | |
| def test_already_slugified(self): | |
| assert _slugify("gentle-nod") == "gentle-nod" | |
| def test_numbers(self): | |
| assert _slugify("take 42") == "take-42" | |
| # ββββββββ State endpoint tests ββββββββββββββββββββββββββββββββββββββββ | |
| class TestStateEndpoint: | |
| def test_returns_200(self, client: TestClient): | |
| resp = client.get("/api/state") | |
| assert resp.status_code == 200 | |
| def test_initial_mode_is_idle(self, client: TestClient): | |
| data = client.get("/api/state").json() | |
| assert data["mode"] == "idle" | |
| def test_initial_message(self, client: TestClient): | |
| data = client.get("/api/state").json() | |
| assert data["message"] == "Ready to capture moves" | |
| def test_state_shape(self, client: TestClient): | |
| data = client.get("/api/state").json() | |
| required_keys = { | |
| "server_time", "mode", "message", "active_move", | |
| "phase_start_at", "phase_end_at", | |
| "countdown_ends_at", | |
| "recording_started_at", "recording_duration", "recording_stats", | |
| "pending_recording", "pending_playback", | |
| "moves", "config", "datasets", | |
| } | |
| assert required_keys.issubset(data.keys()) | |
| def test_server_time_present(self, client: TestClient): | |
| import time | |
| data = client.get("/api/state").json() | |
| assert isinstance(data["server_time"], float) | |
| # Should be close to current time (within 5 seconds) | |
| assert abs(data["server_time"] - time.time()) < 5.0 | |
| def test_idle_phase_timing_null(self, client: TestClient): | |
| data = client.get("/api/state").json() | |
| assert data["phase_start_at"] is None | |
| assert data["phase_end_at"] is None | |
| def test_config_shape(self, client: TestClient): | |
| config = client.get("/api/state").json()["config"] | |
| assert config["default_duration"] == DEFAULT_DURATION | |
| assert config["countdown_seconds"] == COUNTDOWN_SECONDS | |
| assert config["motion_sample_rate"] == MOTION_SAMPLE_RATE | |
| assert isinstance(config["audio_available"], bool) | |
| assert isinstance(config["features"], dict) | |
| def test_initial_moves_empty(self, client: TestClient): | |
| data = client.get("/api/state").json() | |
| assert data["moves"] == [] | |
| def test_initial_no_pending(self, client: TestClient): | |
| data = client.get("/api/state").json() | |
| assert data["pending_recording"] is None | |
| assert data["pending_playback"] is None | |
| class TestStartingUpMode: | |
| def test_starting_up_rejects_recording(self, client: TestClient, marionette: Marionette): | |
| """Commands are rejected while the robot is still starting up.""" | |
| marionette._set_state(mode="starting_up", message="Starting upβ¦", active_move=None) | |
| resp = client.post("/api/record", json={"duration": 3.0, "record_audio": False}) | |
| assert resp.status_code == 409 | |
| marionette._set_idle_state() | |
| def test_starting_up_rejects_dataset_changes(self, client: TestClient, marionette: Marionette): | |
| marionette._set_state(mode="starting_up", message="Starting upβ¦", active_move=None) | |
| resp = client.post("/api/datasets", json={"name": "test"}) | |
| assert resp.status_code == 409 | |
| marionette._set_idle_state() | |
| def test_starting_up_state_visible(self, client: TestClient, marionette: Marionette): | |
| marionette._set_state(mode="starting_up", message="Starting upβ¦", active_move=None) | |
| data = client.get("/api/state").json() | |
| assert data["mode"] == "starting_up" | |
| assert "starting" in data["message"].lower() | |
| marionette._set_idle_state() | |
| # ββββββββ Recording endpoint tests ββββββββββββββββββββββββββββββββββββ | |
| class TestRecordEndpoint: | |
| def test_accept_basic_recording(self, client: TestClient): | |
| resp = client.post("/api/record", json={ | |
| "duration": 3.0, | |
| "record_audio": False, | |
| }) | |
| assert resp.status_code == 200 | |
| data = resp.json() | |
| assert data["accepted"] is True | |
| assert "move_id" in data | |
| def test_mode_becomes_queued(self, client: TestClient): | |
| client.post("/api/record", json={"duration": 3.0, "record_audio": False}) | |
| state = client.get("/api/state").json() | |
| assert state["mode"] == "queued" | |
| def test_reject_when_busy(self, client: TestClient): | |
| # First recording is accepted | |
| resp1 = client.post("/api/record", json={"duration": 3.0, "record_audio": False}) | |
| assert resp1.status_code == 200 | |
| # Second recording is rejected (mode is now "queued") | |
| resp2 = client.post("/api/record", json={"duration": 3.0, "record_audio": False}) | |
| assert resp2.status_code == 409 | |
| def test_reject_invalid_duration_too_low(self, client: TestClient): | |
| resp = client.post("/api/record", json={"duration": 0.1, "record_audio": False}) | |
| assert resp.status_code == 422 # Pydantic validation | |
| def test_reject_invalid_duration_too_high(self, client: TestClient): | |
| resp = client.post("/api/record", json={"duration": 999.0, "record_audio": False}) | |
| assert resp.status_code == 422 | |
| def test_accept_duration_edge_cases(self, client: TestClient, marionette: Marionette): | |
| # Just above minimum | |
| resp = client.post("/api/record", json={"duration": 0.6, "record_audio": False}) | |
| assert resp.status_code == 200 | |
| # Reset for next test | |
| marionette._set_idle_state() | |
| marionette._pending_recording = None | |
| # At maximum | |
| resp = client.post("/api/record", json={"duration": 300.0, "record_audio": False}) | |
| assert resp.status_code == 200 | |
| def test_custom_label(self, client: TestClient): | |
| resp = client.post("/api/record", json={ | |
| "duration": 3.0, | |
| "record_audio": False, | |
| "label": "happy-dance", | |
| }) | |
| data = resp.json() | |
| assert data["label"] == "happy-dance" | |
| assert data["move_id"] == "happy-dance" | |
| def test_label_collision_appends_index( | |
| self, client: TestClient, marionette: Marionette, tmp_dataset_root: Path | |
| ): | |
| # Create a file that would collide | |
| data_dir = tmp_dataset_root / "local_dataset" / DATASET_DATA_SUBDIR | |
| data_dir.mkdir(parents=True, exist_ok=True) | |
| (data_dir / "happy-dance.json").write_text("{}") | |
| resp = client.post("/api/record", json={ | |
| "duration": 3.0, | |
| "record_audio": False, | |
| "label": "happy-dance", | |
| }) | |
| data = resp.json() | |
| assert data["move_id"] == "happy-dance-1" | |
| def test_preferred_duration_saved(self, client: TestClient, marionette: Marionette): | |
| client.post("/api/record", json={"duration": 7.5, "record_audio": False}) | |
| assert marionette._preferred_duration == 7.5 | |
| # ββββββββ Playback endpoint tests βββββββββββββββββββββββββββββββββββββ | |
| class TestPlayEndpoint: | |
| def test_reject_missing_move(self, client: TestClient): | |
| resp = client.post("/api/play", json={"move_id": "nonexistent"}) | |
| assert resp.status_code == 404 | |
| def test_accept_existing_move( | |
| self, client: TestClient, marionette: Marionette, sample_move_json: dict | |
| ): | |
| # Write a move file to the dataset | |
| data_dir = marionette._dataset_dir | |
| move_path = data_dir / "test-move.json" | |
| move_path.write_text(json.dumps(sample_move_json)) | |
| marionette._refresh_recordings() | |
| resp = client.post("/api/play", json={"move_id": "test-move"}) | |
| assert resp.status_code == 200 | |
| assert resp.json()["accepted"] is True | |
| def test_reject_play_when_busy( | |
| self, client: TestClient, marionette: Marionette, sample_move_json: dict | |
| ): | |
| data_dir = marionette._dataset_dir | |
| (data_dir / "test-move.json").write_text(json.dumps(sample_move_json)) | |
| marionette._refresh_recordings() | |
| # First play is accepted | |
| resp1 = client.post("/api/play", json={"move_id": "test-move"}) | |
| assert resp1.status_code == 200 | |
| # Second play is rejected (mode is queued) | |
| resp2 = client.post("/api/play", json={"move_id": "test-move"}) | |
| assert resp2.status_code == 409 | |
| # ββββββββ Stop endpoints ββββββββββββββββββββββββββββββββββββββββββββββ | |
| class TestStopEndpoints: | |
| def test_stop_playback_when_not_playing(self, client: TestClient): | |
| resp = client.post("/api/play/stop") | |
| assert resp.status_code == 200 | |
| assert resp.json()["stopped"] is False | |
| def test_stop_recording_when_not_recording(self, client: TestClient): | |
| resp = client.post("/api/record/stop") | |
| assert resp.status_code == 200 | |
| assert resp.json()["stopped"] is False | |
| def test_stop_recording_while_queued(self, client: TestClient, marionette: Marionette): | |
| """Stopping a queued recording should cancel it and return to idle.""" | |
| # Put the app in queued state by submitting a recording | |
| resp = client.post( | |
| "/api/record", | |
| json={"label": "test-queued", "duration": 5.0, "record_audio": False}, | |
| ) | |
| assert resp.status_code == 200 | |
| assert resp.json()["accepted"] is True | |
| # Verify we're in queued mode | |
| state = client.get("/api/state").json() | |
| assert state["mode"] == "queued" | |
| # Stop should work even in queued mode | |
| resp = client.post("/api/record/stop") | |
| assert resp.status_code == 200 | |
| assert resp.json()["stopped"] is True | |
| # Should be back to idle | |
| state = client.get("/api/state").json() | |
| assert state["mode"] == "idle" | |
| assert "cancelled" in state["message"].lower() | |
| # ββββββββ Move deletion tests βββββββββββββββββββββββββββββββββββββββββ | |
| class TestMoveDelete: | |
| def test_delete_existing_move( | |
| self, client: TestClient, marionette: Marionette, sample_move_json: dict | |
| ): | |
| data_dir = marionette._dataset_dir | |
| move_path = data_dir / "to-delete.json" | |
| move_path.write_text(json.dumps(sample_move_json)) | |
| marionette._refresh_recordings() | |
| resp = client.delete("/api/moves/to-delete") | |
| assert resp.status_code == 200 | |
| assert not move_path.exists() | |
| def test_delete_nonexistent_move(self, client: TestClient): | |
| resp = client.delete("/api/moves/nonexistent") | |
| assert resp.status_code == 404 | |
| def test_delete_removes_wav( | |
| self, client: TestClient, marionette: Marionette, sample_move_json: dict | |
| ): | |
| data_dir = marionette._dataset_dir | |
| (data_dir / "with-audio.json").write_text(json.dumps(sample_move_json)) | |
| (data_dir / "with-audio.wav").write_bytes(b"RIFF" + b"\x00" * 100) | |
| marionette._refresh_recordings() | |
| client.delete("/api/moves/with-audio") | |
| assert not (data_dir / "with-audio.json").exists() | |
| assert not (data_dir / "with-audio.wav").exists() | |
| def test_delete_updates_move_list( | |
| self, client: TestClient, marionette: Marionette, sample_move_json: dict | |
| ): | |
| data_dir = marionette._dataset_dir | |
| (data_dir / "test-move.json").write_text(json.dumps(sample_move_json)) | |
| marionette._refresh_recordings() | |
| state_before = client.get("/api/state").json() | |
| assert len(state_before["moves"]) == 1 | |
| client.delete("/api/moves/test-move") | |
| state_after = client.get("/api/state").json() | |
| assert len(state_after["moves"]) == 0 | |
| # ββββββββ Dataset management tests ββββββββββββββββββββββββββββββββββββ | |
| class TestDatasets: | |
| def test_initial_default_dataset(self, client: TestClient): | |
| data = client.get("/api/state").json() | |
| datasets = data["datasets"] | |
| assert datasets["active_id"] is not None | |
| assert len(datasets["entries"]) >= 1 | |
| def test_create_dataset(self, client: TestClient): | |
| resp = client.post("/api/datasets", json={"name": "My Dances"}) | |
| assert resp.status_code == 200 | |
| data = resp.json() | |
| assert data["status"] == "created" | |
| assert data["dataset"]["folder"] == "my-dances" | |
| def test_create_duplicate_dataset_rejected(self, client: TestClient): | |
| client.post("/api/datasets", json={"name": "dances"}) | |
| resp = client.post("/api/datasets", json={"name": "dances"}) | |
| assert resp.status_code == 409 | |
| def test_select_dataset(self, client: TestClient): | |
| # Create a second dataset | |
| resp = client.post("/api/datasets", json={"name": "second"}) | |
| dataset_id = resp.json()["dataset"]["id"] | |
| # Default is auto-selected after create, so select the original | |
| state = client.get("/api/state").json() | |
| original_id = [ | |
| e["id"] for e in state["datasets"]["entries"] | |
| if e["id"] != dataset_id | |
| ][0] | |
| resp = client.post("/api/datasets/select", json={"dataset_id": original_id}) | |
| assert resp.status_code == 200 | |
| def test_select_nonexistent_dataset(self, client: TestClient): | |
| resp = client.post("/api/datasets/select", json={"dataset_id": "fake"}) | |
| assert resp.status_code == 404 | |
| def test_dataset_root_change(self, client: TestClient, tmp_path: Path): | |
| new_root = tmp_path / "new_root" | |
| new_root.mkdir() | |
| resp = client.post("/api/datasets/root", json={"path": str(new_root)}) | |
| assert resp.status_code == 200 | |
| assert resp.json()["root_path"] == str(new_root) | |
| def test_default_dataset_origin_is_local(self, client: TestClient): | |
| state = client.get("/api/state").json() | |
| entries = state["datasets"]["entries"] | |
| assert all(e.get("origin") == "local" for e in entries) | |
| def test_created_dataset_origin_is_local(self, client: TestClient): | |
| resp = client.post("/api/datasets", json={"name": "my-local"}) | |
| assert resp.status_code == 200 | |
| assert resp.json()["dataset"]["origin"] == "local" | |
| def test_record_blocked_on_downloaded_dataset( | |
| self, client: TestClient, marionette: Marionette | |
| ): | |
| """Recording should be rejected when the active dataset is downloaded.""" | |
| # Create a dataset and tag it as downloaded | |
| entry = marionette._create_dataset_internal("dl-test", "Downloaded Test", origin="downloaded") | |
| marionette._select_dataset(entry.dataset_id) | |
| marionette._refresh_recordings() | |
| resp = client.post("/api/record", json={"duration": 3.0, "record_audio": False}) | |
| assert resp.status_code == 409 | |
| assert "downloaded" in resp.json()["detail"].lower() | |
| def test_record_allowed_on_local_dataset( | |
| self, client: TestClient, marionette: Marionette | |
| ): | |
| """Recording should be accepted when the active dataset is local.""" | |
| # Default dataset is local, just verify recording is accepted | |
| resp = client.post("/api/record", json={"duration": 3.0, "record_audio": False}) | |
| assert resp.status_code == 200 | |
| assert resp.json()["accepted"] is True | |
| def test_origin_persisted_in_registry( | |
| self, marionette: Marionette, tmp_registry: Path | |
| ): | |
| """Origin field should be saved and restored from registry.""" | |
| entry = marionette._create_dataset_internal("persisted-dl", "Persisted DL", origin="downloaded") | |
| marionette._save_dataset_registry() | |
| raw = json.loads(tmp_registry.read_text(encoding="utf-8")) | |
| ds_entry = next(d for d in raw["datasets"] if d["folder"] == "persisted-dl") | |
| assert ds_entry["origin"] == "downloaded" | |
| # ββββββββ Registry persistence tests ββββββββββββββββββββββββββββββββββ | |
| class TestRegistryPersistence: | |
| def test_registry_created_on_init(self, tmp_registry: Path, tmp_dataset_root: Path): | |
| create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root) | |
| assert tmp_registry.exists() | |
| data = json.loads(tmp_registry.read_text()) | |
| assert "active" in data | |
| assert "datasets" in data | |
| def test_registry_survives_restart(self, tmp_registry: Path, tmp_dataset_root: Path): | |
| # First instance creates a dataset | |
| app1, m1 = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root) | |
| client1 = TestClient(app1) | |
| client1.post("/api/datasets", json={"name": "persistent-ds"}) | |
| # Second instance should see it | |
| app2, m2 = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root) | |
| client2 = TestClient(app2) | |
| state = client2.get("/api/state").json() | |
| folders = [e["folder"] for e in state["datasets"]["entries"]] | |
| assert "persistent-ds" in folders | |
| def test_preferred_duration_persisted(self, tmp_registry: Path, tmp_dataset_root: Path): | |
| app1, m1 = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root) | |
| client1 = TestClient(app1) | |
| client1.post("/api/record", json={"duration": 8.5, "record_audio": False}) | |
| # Re-create and check | |
| _, m2 = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root) | |
| assert m2._preferred_duration == 8.5 | |
| # ββββββββ Moves list / refresh tests ββββββββββββββββββββββββββββββββββ | |
| class TestMovesRefresh: | |
| def test_moves_appear_after_file_creation( | |
| self, client: TestClient, marionette: Marionette, sample_move_json: dict | |
| ): | |
| data_dir = marionette._dataset_dir | |
| (data_dir / "my-move.json").write_text(json.dumps(sample_move_json)) | |
| marionette._refresh_recordings() | |
| state = client.get("/api/state").json() | |
| move_ids = [m["id"] for m in state["moves"]] | |
| assert "my-move" in move_ids | |
| def test_move_duration_computed_correctly( | |
| self, client: TestClient, marionette: Marionette, sample_move_json: dict | |
| ): | |
| data_dir = marionette._dataset_dir | |
| (data_dir / "timed.json").write_text(json.dumps(sample_move_json)) | |
| marionette._refresh_recordings() | |
| state = client.get("/api/state").json() | |
| move = next(m for m in state["moves"] if m["id"] == "timed") | |
| # 500 frames at 100Hz = 4.99s (last timestamp is 4.99) | |
| assert 4.5 < move["duration"] < 5.5 | |
| def test_move_has_audio_flag( | |
| self, client: TestClient, marionette: Marionette, sample_move_json: dict | |
| ): | |
| data_dir = marionette._dataset_dir | |
| (data_dir / "audio-move.json").write_text(json.dumps(sample_move_json)) | |
| (data_dir / "audio-move.wav").write_bytes(b"RIFF" + b"\x00" * 100) | |
| marionette._refresh_recordings() | |
| state = client.get("/api/state").json() | |
| move = next(m for m in state["moves"] if m["id"] == "audio-move") | |
| assert move["has_audio"] is True | |
| def test_move_without_audio( | |
| self, client: TestClient, marionette: Marionette, sample_move_json: dict | |
| ): | |
| data_dir = marionette._dataset_dir | |
| (data_dir / "silent-move.json").write_text(json.dumps(sample_move_json)) | |
| marionette._refresh_recordings() | |
| state = client.get("/api/state").json() | |
| move = next(m for m in state["moves"] if m["id"] == "silent-move") | |
| assert move["has_audio"] is False | |
| # ββββββββ Experiment / feature toggle tests βββββββββββββββββββββββββββ | |
| class TestExperiments: | |
| def test_denoise_feature_removed(self, client: TestClient): | |
| """Denoise feature was removed; the features dict should not contain it.""" | |
| data = client.get("/api/state").json() | |
| assert "denoise" not in data["config"]["features"] | |
| def test_toggle_motion_models(self, client: TestClient): | |
| resp = client.post("/api/experiments", json={"motion_models": True}) | |
| assert resp.status_code == 200 | |
| assert resp.json()["features"]["motion_models"] is True | |
| def test_update_duration(self, client: TestClient): | |
| resp = client.post("/api/experiments", json={"duration_seconds": 10.0}) | |
| assert resp.status_code == 200 | |
| assert resp.json()["preferred_duration"] == 10.0 | |
| def test_no_changes(self, client: TestClient): | |
| resp = client.post("/api/experiments", json={}) | |
| assert resp.json()["status"] == "unchanged" | |
| # ββββββββ Sensor data dummy endpoint ββββββββββββββββββββββββββββββββββ | |
| class TestHfAutoLogin: | |
| def test_state_includes_hf_username_key(self, client: TestClient): | |
| config = client.get("/api/state").json()["config"] | |
| assert "hf_username" in config | |
| # Value is None when not logged in, or a string when logged in | |
| assert config["hf_username"] is None or isinstance(config["hf_username"], str) | |
| def test_auto_detected_username_in_state(self, client: TestClient, marionette: Marionette): | |
| """When HF login is detected, username appears in state.""" | |
| marionette._hf_checked = False | |
| marionette._hf_username = None | |
| import marionette.main as mm | |
| original_whoami = mm.hf_whoami | |
| mm.hf_whoami = lambda: {"name": "testuser"} | |
| try: | |
| config = client.get("/api/state").json()["config"] | |
| assert config["hf_username"] == "testuser" | |
| finally: | |
| mm.hf_whoami = original_whoami | |
| def test_cached_after_first_check(self, marionette: Marionette): | |
| """HF login check is cached after first call.""" | |
| import marionette.main as mm | |
| call_count = 0 | |
| original_whoami = mm.hf_whoami | |
| def counting_whoami(): | |
| nonlocal call_count | |
| call_count += 1 | |
| return {"name": "cached-user"} | |
| mm.hf_whoami = counting_whoami | |
| marionette._hf_checked = False | |
| marionette._hf_username = None | |
| try: | |
| result1 = marionette._check_hf_login() | |
| result2 = marionette._check_hf_login() | |
| assert result1 == "cached-user" | |
| assert result2 == "cached-user" | |
| assert call_count == 1 | |
| finally: | |
| mm.hf_whoami = original_whoami | |
| def test_sync_without_username_uses_autodetected( | |
| self, client: TestClient, marionette: Marionette, tmp_path: Path | |
| ): | |
| """Sync endpoint uses auto-detected username when none provided.""" | |
| import marionette.main as mm | |
| original_whoami = mm.hf_whoami | |
| marionette._hf_checked = False | |
| marionette._hf_username = None | |
| mm.hf_whoami = lambda: {"name": "auto-user"} | |
| try: | |
| # No moves exist, so sync will fail with 400 (no moves found), | |
| # but we verify it gets past the username check | |
| resp = client.post("/api/datasets/sync", json={"move_ids": ["nonexistent"]}) | |
| # 404 = move not found (got past the username validation) | |
| assert resp.status_code == 404 | |
| finally: | |
| mm.hf_whoami = original_whoami | |
| def test_sync_without_username_and_no_login_returns_400( | |
| self, client: TestClient, marionette: Marionette | |
| ): | |
| """Sync without username and no HF login returns 400.""" | |
| import marionette.main as mm | |
| original_whoami = mm.hf_whoami | |
| marionette._hf_checked = False | |
| marionette._hf_username = None | |
| mm.hf_whoami = None | |
| try: | |
| resp = client.post("/api/datasets/sync", json={"move_ids": ["some-move"]}) | |
| assert resp.status_code == 400 | |
| assert "not logged in" in resp.json()["detail"].lower() | |
| finally: | |
| mm.hf_whoami = original_whoami | |
| class TestSensorData: | |
| def test_returns_empty(self, client: TestClient): | |
| resp = client.get("/sensor_data") | |
| assert resp.status_code == 200 | |
| assert resp.json() == {} | |
| # ββββββββ Upload audio tests βββββββββββββββββββββββββββββββββββββββββ | |
| class TestUploadAudio: | |
| def test_upload_wav_returns_upload_id(self, client: TestClient): | |
| from conftest import make_wav_bytes | |
| wav = make_wav_bytes(1.0) | |
| resp = client.post( | |
| "/api/upload-audio", | |
| files={"file": ("test.wav", BytesIO(wav), "audio/wav")}, | |
| ) | |
| assert resp.status_code == 200 | |
| data = resp.json() | |
| assert "upload_id" in data | |
| assert data["filename"] == "test.wav" | |
| def test_upload_wav_duration_extracted(self, client: TestClient): | |
| from conftest import make_wav_bytes | |
| wav = make_wav_bytes(2.0) | |
| resp = client.post( | |
| "/api/upload-audio", | |
| files={"file": ("two-sec.wav", BytesIO(wav), "audio/wav")}, | |
| ) | |
| assert resp.status_code == 200 | |
| duration = resp.json().get("duration") | |
| # Duration may be None if soundfile is unavailable, skip check in that case | |
| if duration is not None: | |
| assert abs(duration - 2.0) < 0.5 | |
| def test_upload_rejects_unsupported_format(self, client: TestClient): | |
| resp = client.post( | |
| "/api/upload-audio", | |
| files={"file": ("notes.txt", BytesIO(b"hello"), "text/plain")}, | |
| ) | |
| assert resp.status_code == 400 | |
| def test_upload_rejects_empty_filename(self, client: TestClient): | |
| resp = client.post( | |
| "/api/upload-audio", | |
| files={"file": ("", BytesIO(b"data"), "audio/wav")}, | |
| ) | |
| assert resp.status_code in (400, 422) | |
| def test_upload_mp3_accepted(self, client: TestClient): | |
| # A minimal fake MP3 β server accepts based on extension | |
| resp = client.post( | |
| "/api/upload-audio", | |
| files={"file": ("song.mp3", BytesIO(b"\xff\xfb\x90\x00" + b"\x00" * 100), "audio/mpeg")}, | |
| ) | |
| # Accepted (200) or 500 if soundfile can't parse β never 400 for extension | |
| assert resp.status_code in (200, 500) | |
| def test_uploaded_audio_id_usable_in_record(self, client: TestClient, marionette: Marionette): | |
| from conftest import make_wav_bytes | |
| wav = make_wav_bytes(1.0) | |
| upload_resp = client.post( | |
| "/api/upload-audio", | |
| files={"file": ("rec.wav", BytesIO(wav), "audio/wav")}, | |
| ) | |
| assert upload_resp.status_code == 200 | |
| upload_id = upload_resp.json()["upload_id"] | |
| resp = client.post("/api/record", json={ | |
| "duration": 3.0, | |
| "record_audio": False, | |
| "uploaded_audio_id": upload_id, | |
| }) | |
| assert resp.status_code == 200 | |
| assert resp.json()["accepted"] is True | |
| def test_record_with_invalid_upload_id(self, client: TestClient): | |
| resp = client.post("/api/record", json={ | |
| "duration": 3.0, | |
| "record_audio": False, | |
| "uploaded_audio_id": "nonexistent-uuid", | |
| }) | |
| assert resp.status_code == 400 | |
| # ββββββββ Motion model endpoint tests ββββββββββββββββββββββββββββββββ | |
| class TestMotionModelEndpoint: | |
| def test_motion_model_rejected_when_disabled(self, client: TestClient): | |
| resp = client.post("/api/motion-model", json={"name": "no_model"}) | |
| assert resp.status_code == 400 | |
| def test_enable_then_set_model(self, client: TestClient): | |
| client.post("/api/experiments", json={"motion_models": True}) | |
| resp = client.post("/api/motion-model", json={"name": "no_model"}) | |
| assert resp.status_code == 200 | |
| assert resp.json()["active"] == "no_model" | |
| def test_set_unknown_model_returns_404(self, client: TestClient): | |
| client.post("/api/experiments", json={"motion_models": True}) | |
| resp = client.post("/api/motion-model", json={"name": "totally_fake_model"}) | |
| assert resp.status_code == 404 | |
| def test_model_persisted_in_registry( | |
| self, tmp_registry: Path, tmp_dataset_root: Path | |
| ): | |
| app1, m1 = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root) | |
| c1 = TestClient(app1) | |
| c1.post("/api/experiments", json={"motion_models": True}) | |
| c1.post("/api/motion-model", json={"name": "no_model"}) | |
| _, m2 = create_app(registry_path=tmp_registry, dataset_root=tmp_dataset_root) | |
| assert m2._motion_model_registry.active == "no_model" | |
| # ββββββββ Community datasets tests βββββββββββββββββββββββββββββββββββ | |
| class TestCommunityDatasets: | |
| def test_community_returns_empty_list(self, client: TestClient, marionette: Marionette): | |
| """When HTTP fetch returns empty, endpoint returns an empty list.""" | |
| original = marionette._fetch_community_datasets_http | |
| marionette._fetch_community_datasets_http = lambda: [] | |
| try: | |
| resp = client.get("/api/datasets/community") | |
| assert resp.status_code == 200 | |
| # May be empty list (HfApi also not available in test env) | |
| assert isinstance(resp.json()["datasets"], list) | |
| finally: | |
| marionette._fetch_community_datasets_http = original | |
| def test_download_rejects_invalid_repo_id(self, client: TestClient): | |
| resp = client.post("/api/datasets/download", json={"repo_id": "no-slash"}) | |
| assert resp.status_code == 400 | |
| def test_download_rejects_duplicate_folder(self, client: TestClient, marionette: Marionette): | |
| # Create a dataset first | |
| client.post("/api/datasets", json={"name": "existing-ds"}) | |
| # Try to download with the same folder name | |
| resp = client.post("/api/datasets/download", json={ | |
| "repo_id": "someone/existing-ds", | |
| "name": "existing-ds", | |
| }) | |
| assert resp.status_code == 409 | |
| # ββββββββ Corrupt data tests ββββββββββββββββββββββββββββββββββββββββ | |
| class TestCorruptData: | |
| def test_malformed_json_skipped(self, marionette: Marionette): | |
| data_dir = marionette._dataset_dir | |
| (data_dir / "bad-file.json").write_text("{{{", encoding="utf-8") | |
| marionette._refresh_recordings() | |
| assert "bad-file" not in marionette._recordings | |
| def test_json_missing_time_key(self, marionette: Marionette): | |
| data_dir = marionette._dataset_dir | |
| (data_dir / "no-time.json").write_text( | |
| json.dumps({"description": "test"}), encoding="utf-8" | |
| ) | |
| marionette._refresh_recordings() | |
| # File is loaded but with duration 0 (empty timestamps) | |
| if "no-time" in marionette._recordings: | |
| assert marionette._recordings["no-time"].duration == 0.0 | |
| def test_json_empty_time_array(self, marionette: Marionette): | |
| data_dir = marionette._dataset_dir | |
| (data_dir / "empty-time.json").write_text( | |
| json.dumps({"time": [], "set_target_data": []}), encoding="utf-8" | |
| ) | |
| marionette._refresh_recordings() | |
| if "empty-time" in marionette._recordings: | |
| assert marionette._recordings["empty-time"].duration == 0.0 | |
| def test_corrupt_registry_recovers(self, tmp_path: Path): | |
| reg = tmp_path / "corrupt_reg.json" | |
| reg.write_text("NOT VALID JSON {{{", encoding="utf-8") | |
| ds_root = tmp_path / "ds" | |
| ds_root.mkdir() | |
| app, m = create_app(registry_path=reg, dataset_root=ds_root) | |
| # Should have recovered with defaults | |
| assert m._active_dataset_id is not None | |
| assert len(m._datasets) >= 1 | |
| # ββββββββ Concurrent state change tests βββββββββββββββββββββββββββββ | |
| class TestConcurrentStateChanges: | |
| def test_play_while_queued_rejected( | |
| self, client: TestClient, marionette: Marionette, sample_move_json: dict | |
| ): | |
| data_dir = marionette._dataset_dir | |
| (data_dir / "play-test.json").write_text(json.dumps(sample_move_json)) | |
| marionette._refresh_recordings() | |
| # Submit a recording to enter queued state | |
| client.post("/api/record", json={"duration": 3.0, "record_audio": False}) | |
| assert client.get("/api/state").json()["mode"] == "queued" | |
| resp = client.post("/api/play", json={"move_id": "play-test"}) | |
| assert resp.status_code == 409 | |
| def test_record_while_playing_rejected( | |
| self, client: TestClient, marionette: Marionette | |
| ): | |
| marionette._set_state(mode="playing", message="Playingβ¦", active_move="x") | |
| resp = client.post("/api/record", json={"duration": 3.0, "record_audio": False}) | |
| assert resp.status_code == 409 | |
| marionette._set_idle_state() | |
| def test_sync_while_busy_rejected( | |
| self, client: TestClient, marionette: Marionette | |
| ): | |
| marionette._set_state(mode="recording", message="Recordingβ¦", active_move=None) | |
| resp = client.post("/api/datasets/sync", json={"move_ids": ["x"]}) | |
| assert resp.status_code == 409 | |
| marionette._set_idle_state() | |
| def test_dataset_root_change_while_busy( | |
| self, client: TestClient, marionette: Marionette, tmp_path: Path | |
| ): | |
| marionette._set_state(mode="recording", message="Recordingβ¦", active_move=None) | |
| resp = client.post("/api/datasets/root", json={"path": str(tmp_path)}) | |
| assert resp.status_code == 409 | |
| marionette._set_idle_state() | |
| # ββββββββ Duration edge case tests ββββββββββββββββββββββββββββββββββ | |
| class TestDurationEdgeCases: | |
| def test_duration_just_above_minimum(self, client: TestClient): | |
| resp = client.post("/api/record", json={"duration": 0.51, "record_audio": False}) | |
| assert resp.status_code == 200 | |
| def test_duration_at_maximum(self, client: TestClient, marionette: Marionette): | |
| resp = client.post("/api/record", json={"duration": 300.0, "record_audio": False}) | |
| assert resp.status_code == 200 | |
| def test_duration_at_minimum_boundary_rejected(self, client: TestClient): | |
| """Pydantic field has gt=0.5, so exactly 0.5 should be rejected.""" | |
| resp = client.post("/api/record", json={"duration": 0.5, "record_audio": False}) | |
| assert resp.status_code == 422 | |
| # ββββββββ Sync dataset extended tests βββββββββββββββββββββββββββββββ | |
| class TestSyncDatasetExtended: | |
| def test_sync_empty_move_ids_rejected(self, client: TestClient): | |
| """Pydantic min_items=1 should reject empty move_ids.""" | |
| resp = client.post("/api/datasets/sync", json={"move_ids": []}) | |
| assert resp.status_code == 422 | |
| def test_sync_nonexistent_moves(self, client: TestClient, marionette: Marionette): | |
| import marionette.main as mm | |
| original_whoami = mm.hf_whoami | |
| marionette._hf_checked = False | |
| marionette._hf_username = None | |
| mm.hf_whoami = lambda: {"name": "testuser"} | |
| try: | |
| resp = client.post("/api/datasets/sync", json={ | |
| "move_ids": ["fake-move-id"], | |
| }) | |
| assert resp.status_code == 404 | |
| finally: | |
| mm.hf_whoami = original_whoami | |
| def test_sync_no_active_dataset(self, client: TestClient, marionette: Marionette): | |
| import marionette.main as mm | |
| original_whoami = mm.hf_whoami | |
| mm.hf_whoami = lambda: {"name": "testuser"} | |
| marionette._hf_checked = False | |
| marionette._hf_username = None | |
| # Save and clear active dataset | |
| old_id = marionette._active_dataset_id | |
| marionette._active_dataset_id = None | |
| # Clear recordings to avoid "move not found" before "no active dataset" | |
| marionette._recordings = {} | |
| try: | |
| resp = client.post("/api/datasets/sync", json={ | |
| "move_ids": ["any-move"], | |
| }) | |
| # Should be 404 for the move not found (since recordings is empty) | |
| assert resp.status_code == 404 | |
| finally: | |
| marionette._active_dataset_id = old_id | |
| mm.hf_whoami = original_whoami | |
| def test_record_on_downloaded_dataset_rejected( | |
| self, client: TestClient, marionette: Marionette | |
| ): | |
| entry = marionette._create_dataset_internal("dl-sync-test", "DL Sync Test", origin="downloaded") | |
| marionette._select_dataset(entry.dataset_id) | |
| marionette._refresh_recordings() | |
| resp = client.post("/api/record", json={"duration": 3.0, "record_audio": False}) | |
| assert resp.status_code == 409 | |
| assert "downloaded" in resp.json()["detail"].lower() | |