from io import BytesIO import re import numpy as np import pytest import soundfile as sf from fastapi.testclient import TestClient from app.main import app client = TestClient(app) @pytest.fixture(autouse=True) def disable_llm_for_api_tests(monkeypatch): monkeypatch.delenv("DOGVOICE_LLM_API_KEY", raising=False) def test_health(): response = client.get("/health") assert response.status_code == 200 assert response.json()["ok"] is True assert response.json()["llm"]["configured"] is False def test_analyze_generated_wav_and_feedback(): wav = _make_wav() response = client.post( "/analyze-dog-audio", files={"file": ("bark.wav", wav, "audio/wav")}, data={"context_note": "门外有人经过,狗狗叫了几声。"}, ) assert response.status_code == 200 data = response.json() assert data["ok"] is True assert data["result_id"] assert data["scene"] == "audio_only" assert data["arousal"] in {"Low", "Medium", "High"} assert data["arousal_label"] in {"低", "中", "高"} assert data["valence"] in {"Negative", "Neutral", "Positive"} assert data["valence_label"] in {"偏负向", "中性", "偏正向"} assert data["confidence_label"] in {"低", "中", "高"} assert data["model_backend_label"] in {"狗叫情绪识别模型", "备用声学特征分析"} assert data["primary_intent"]["label"] assert data["primary_intent"]["likelihood"] in {"较高", "中等", "较低"} assert len(data["intent_candidates"]) == 3 assert data["sound_profile"]["signals"] assert data["care"]["urgency_label"] assert data["care"]["next_steps"] assert data["rule_summary"] assert data["user_context_note"] == "门外有人经过,狗狗叫了几声。" assert data["professional_note"] assert data["science_basis"] assert data["behavior_knowledge"] _assert_clean_visible_text(data["professional_note"]) _assert_clean_visible_text(" ".join(f"{item['label']} {item['text']}" for item in data["science_basis"])) assert data["llm_interpretation"]["status"] == "disabled" assert "High" not in data["summary"] assert "Medium" not in data["summary"] assert "Low" not in data["summary"] assert "Positive" not in data["summary"] assert "Neutral" not in data["summary"] assert "Negative" not in data["summary"] assert data["timeline"] assert data["timeline"][0]["arousal_label"] assert data["timeline"][0]["valence_label"] feedback = client.post( "/feedback", data={ "result_id": data["result_id"], "rating": "partial", "actual_reason": "door_or_outside_noise", "note": "测试反馈", }, ) assert feedback.status_code == 200 assert feedback.json()["ok"] is True followup = client.post( "/follow-up", data={ "result_id": data["result_id"], "message": "它最近独处时也会叫,这个需要担心吗?", }, ) assert followup.status_code == 200 followup_data = followup.json() assert followup_data["ok"] is True assert followup_data["reply"] assert followup_data["professional_note"] assert followup_data["science_basis"] assert followup_data["behavior_knowledge"] _assert_clean_visible_text(followup_data["professional_note"]) _assert_clean_visible_text(" ".join(f"{item['label']} {item['text']}" for item in followup_data["science_basis"])) def test_rejects_short_wav(): wav = _make_wav(duration=0.5) response = client.post( "/analyze-dog-audio", files={"file": ("short.wav", wav, "audio/wav")}, ) assert response.status_code == 400 assert response.json()["detail"]["ok"] is False def test_legacy_scene_field_is_ignored(): wav = _make_wav() response = client.post( "/analyze-dog-audio", files={"file": ("bark.wav", wav, "audio/wav")}, data={"scene": "food"}, ) assert response.status_code == 200 assert response.json()["scene"] == "audio_only" def _make_wav(duration: float = 3.0, sample_rate: int = 22_050) -> BytesIO: t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False) carrier = np.sin(2 * np.pi * 520 * t) envelope = (np.sin(2 * np.pi * 3 * t) > 0).astype(float) * 0.45 + 0.05 signal = (carrier * envelope).astype(np.float32) buffer = BytesIO() sf.write(buffer, signal, sample_rate, format="WAV") buffer.seek(0) return buffer def _assert_clean_visible_text(text: str) -> None: for term in ("Dogbug", "Dogbark", "Dock", "提示词", "资料库检索结果", "专业依据摘要", "规则底稿", "声音需要结合场景解读", "当时场景", "选择场景"): assert term not in text assert not re.search(r"[A-Za-z]", text)