diff --git a/apps/server/services/shot_annotations_service.py b/apps/server/services/shot_annotations_service.py index 5d01a341..851c3fa9 100644 --- a/apps/server/services/shot_annotations_service.py +++ b/apps/server/services/shot_annotations_service.py @@ -61,7 +61,7 @@ def _save_annotations(data: dict) -> None: """Save annotations to disk and update cache.""" global _annotations_cache _ensure_file() - atomic_write_json(str(ANNOTATIONS_FILE), data) + atomic_write_json(ANNOTATIONS_FILE, data) _annotations_cache = data diff --git a/apps/server/test_main.py b/apps/server/test_main.py index 64bb8ee5..f69c39b3 100644 --- a/apps/server/test_main.py +++ b/apps/server/test_main.py @@ -12225,3 +12225,142 @@ def test_prepare_recipe_empty_slug_returns_422(self, client): ) assert response.status_code == 422 + +# ============================================================================ +# Shot Annotation Endpoint Tests +# ============================================================================ + + +class TestShotAnnotationEndpoints: + """Tests for GET/PATCH /api/shots/{date}/{filename}/annotation.""" + + @pytest.fixture(autouse=True) + def isolate_annotations(self, tmp_path, monkeypatch): + """Redirect annotations storage to a temp dir and clear the cache.""" + import services.shot_annotations_service as svc + annotations_file = tmp_path / "shot_annotations.json" + monkeypatch.setattr(svc, "ANNOTATIONS_FILE", annotations_file) + svc.invalidate_cache() + yield + svc.invalidate_cache() + + def test_get_annotation_missing_returns_null(self, client): + """GET annotation for a shot with no saved annotation returns null.""" + response = client.get("/api/shots/2024-01-15/shot_001.json/annotation") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["annotation"] is None + + def test_create_annotation(self, client): + """PATCH creates a new annotation and returns it.""" + response = client.patch( + "/api/shots/2024-01-15/shot_001.json/annotation", + json={"annotation": "Great shot, nice crema."}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["annotation"] == "Great shot, nice crema." + assert data["updated_at"] is not None + + def test_update_annotation(self, client): + """PATCH updates an existing annotation.""" + client.patch( + "/api/shots/2024-01-15/shot_001.json/annotation", + json={"annotation": "First note."}, + ) + response = client.patch( + "/api/shots/2024-01-15/shot_001.json/annotation", + json={"annotation": "Updated note."}, + ) + assert response.status_code == 200 + data = response.json() + assert data["annotation"] == "Updated note." + + def test_get_annotation_after_create(self, client): + """GET returns the annotation after it has been created.""" + client.patch( + "/api/shots/2024-01-15/shot_001.json/annotation", + json={"annotation": "Tasty."}, + ) + response = client.get("/api/shots/2024-01-15/shot_001.json/annotation") + assert response.status_code == 200 + assert response.json()["annotation"] == "Tasty." + + def test_clear_annotation_via_empty_string(self, client): + """PATCH with empty string clears the annotation (returns null).""" + client.patch( + "/api/shots/2024-01-15/shot_001.json/annotation", + json={"annotation": "Remove me."}, + ) + response = client.patch( + "/api/shots/2024-01-15/shot_001.json/annotation", + json={"annotation": ""}, + ) + assert response.status_code == 200 + assert response.json()["annotation"] is None + # GET should also return null after clearing + get_resp = client.get("/api/shots/2024-01-15/shot_001.json/annotation") + assert get_resp.json()["annotation"] is None + + def test_clear_annotation_via_whitespace_only(self, client): + """PATCH with whitespace-only string clears the annotation.""" + client.patch( + "/api/shots/2024-01-15/shot_001.json/annotation", + json={"annotation": "Remove me."}, + ) + response = client.patch( + "/api/shots/2024-01-15/shot_001.json/annotation", + json={"annotation": " "}, + ) + assert response.status_code == 200 + assert response.json()["annotation"] is None + + def test_patch_missing_annotation_field_defaults_to_clear(self, client): + """PATCH body with no annotation key defaults to clearing.""" + client.patch( + "/api/shots/2024-01-15/shot_001.json/annotation", + json={"annotation": "Something."}, + ) + response = client.patch( + "/api/shots/2024-01-15/shot_001.json/annotation", + json={}, + ) + assert response.status_code == 200 + assert response.json()["annotation"] is None + + def test_patch_invalid_json_body_returns_error(self, client): + """PATCH with invalid JSON body returns a 4xx error.""" + response = client.patch( + "/api/shots/2024-01-15/shot_001.json/annotation", + content=b"not json at all", + headers={"Content-Type": "application/json"}, + ) + assert response.status_code in (400, 422, 500) + + def test_annotations_are_isolated_per_shot(self, client): + """Annotations for different shots do not bleed into each other.""" + client.patch( + "/api/shots/2024-01-15/shot_001.json/annotation", + json={"annotation": "Shot one."}, + ) + client.patch( + "/api/shots/2024-01-15/shot_002.json/annotation", + json={"annotation": "Shot two."}, + ) + r1 = client.get("/api/shots/2024-01-15/shot_001.json/annotation") + r2 = client.get("/api/shots/2024-01-15/shot_002.json/annotation") + assert r1.json()["annotation"] == "Shot one." + assert r2.json()["annotation"] == "Shot two." + + def test_annotation_markdown_content_preserved(self, client): + """Annotation text with markdown is stored and returned verbatim.""" + md = "## Notes\n\n- Bold flavour\n- **Nice** crema" + client.patch( + "/api/shots/2024-01-15/shot_001.json/annotation", + json={"annotation": md}, + ) + response = client.get("/api/shots/2024-01-15/shot_001.json/annotation") + assert response.json()["annotation"] == md + diff --git a/apps/web/src/components/ControlCenter.tsx b/apps/web/src/components/ControlCenter.tsx index 56f396e4..a3e87de0 100644 --- a/apps/web/src/components/ControlCenter.tsx +++ b/apps/web/src/components/ControlCenter.tsx @@ -113,16 +113,14 @@ export function ControlCenter({ machineState, onOpenLiveView }: ControlCenterPro !machineState.active_profile.startsWith('MeticAI ')) ? machineState.active_profile : null - // Reset dependent state when active profile changes (adjusting state during render) - const [prevProfile, setPrevProfile] = useState(activeProfile) - if (prevProfile !== activeProfile) { - setPrevProfile(activeProfile) + // Reset dependent state when active profile is cleared + useEffect(() => { if (!activeProfile) { setProfileImgUrl(null) setProfileImgError(false) setProfileAuthor(null) } - } + }, [activeProfile]) useEffect(() => { let cancelled = false diff --git a/apps/web/src/components/ShotAnnotation.tsx b/apps/web/src/components/ShotAnnotation.tsx index 1a575ae0..6b4b36b8 100644 --- a/apps/web/src/components/ShotAnnotation.tsx +++ b/apps/web/src/components/ShotAnnotation.tsx @@ -25,8 +25,9 @@ export function ShotAnnotation({ date, filename, className = '' }: ShotAnnotatio const fetchAnnotation = async () => { setIsLoading(true) try { + const serverUrl = await getServerUrl() const response = await fetch( - `${getServerUrl()}/api/shots/${encodeURIComponent(date)}/${encodeURIComponent(filename)}/annotation` + `${serverUrl}/api/shots/${encodeURIComponent(date)}/${encodeURIComponent(filename)}/annotation` ) if (response.ok) { const data = await response.json() @@ -49,8 +50,9 @@ export function ShotAnnotation({ date, filename, className = '' }: ShotAnnotatio const handleSave = useCallback(async () => { setIsSaving(true) try { + const serverUrl = await getServerUrl() const response = await fetch( - `${getServerUrl()}/api/shots/${encodeURIComponent(date)}/${encodeURIComponent(filename)}/annotation`, + `${serverUrl}/api/shots/${encodeURIComponent(date)}/${encodeURIComponent(filename)}/annotation`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' },