Spaces:
Running
Running
Download tests/test_api.py from pollen-robotics/marionette: direct link, hf CLI and curl.
- Browser
- Download file 18.1 kB
-
https://huggingface.co/spaces/pollen-robotics/marionette/resolve/35d5e6ceee0901a63d7e37e387c7499fcbcf4f36/tests/test_api.py
- Command line
-
hf download hf://spaces/pollen-robotics/marionette@35d5e6ceee0901a63d7e37e387c7499fcbcf4f36/tests/test_api.py
-
curl -L -o test_api.py https://huggingface.co/spaces/pollen-robotics/marionette/resolve/35d5e6ceee0901a63d7e37e387c7499fcbcf4f36/tests/test_api.py
18.1 kB
| """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 | |
| # ββββββββ 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 | |
| # ββββββββ 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) | |
| # ββββββββ 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 TestSensorData: | |
| def test_returns_empty(self, client: TestClient): | |
| resp = client.get("/sensor_data") | |
| assert resp.status_code == 200 | |
| assert resp.json() == {} | |