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