add test module
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import json
|
||||
from dataclasses import replace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from sentence_api.main import create_app
|
||||
from sentence_api.config import Settings
|
||||
from sentence_api.repository import VideoRepository
|
||||
from sentence_api.store import BoundaryStore
|
||||
|
||||
|
||||
@@ -26,7 +29,19 @@ def make_client(tmp_path):
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return TestClient(create_app(BoundaryStore(index_path)))
|
||||
settings = replace(
|
||||
Settings.from_env(),
|
||||
data_dir=tmp_path / "data",
|
||||
legacy_boundaries_file=index_path,
|
||||
)
|
||||
settings.ensure_directories()
|
||||
return TestClient(
|
||||
create_app(
|
||||
BoundaryStore(index_path),
|
||||
settings=settings,
|
||||
repository=VideoRepository(settings.database_path),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_lookup_returns_boundaries(tmp_path):
|
||||
|
||||
146
sentence_api/tests/test_assessment_api.py
Normal file
146
sentence_api/tests/test_assessment_api.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import io
|
||||
import json
|
||||
import wave
|
||||
from dataclasses import replace
|
||||
|
||||
import numpy as np
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from sentence_api.config import Settings
|
||||
from sentence_api.main import create_app
|
||||
from sentence_api.models import SentenceBoundaryDocument
|
||||
from sentence_api.repository import VideoRepository
|
||||
from sentence_api.store import BoundaryStore
|
||||
from sentence_api.transcription import Transcript
|
||||
|
||||
|
||||
VIDEO_HASH = "c" * 64
|
||||
|
||||
|
||||
class FakeTranscriber:
|
||||
available = True
|
||||
|
||||
def transcribe(self, audio_path, language=None):
|
||||
return Transcript(text="The meeting starts at nine.", segments=[])
|
||||
|
||||
|
||||
def make_wav() -> bytes:
|
||||
sample_rate = 16_000
|
||||
time = np.arange(sample_rate * 2, dtype=np.float64) / sample_rate
|
||||
samples = (np.sin(2 * np.pi * 220 * time) * 8000).astype("<i2")
|
||||
output = io.BytesIO()
|
||||
with wave.open(output, "wb") as wav_file:
|
||||
wav_file.setnchannels(1)
|
||||
wav_file.setsampwidth(2)
|
||||
wav_file.setframerate(sample_rate)
|
||||
wav_file.writeframes(samples.tobytes())
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def make_client(tmp_path, client_api_key=""):
|
||||
legacy_path = tmp_path / "legacy.json"
|
||||
legacy_path.write_text(json.dumps({"videos": {}}), encoding="utf-8")
|
||||
settings = replace(
|
||||
Settings.from_env(),
|
||||
data_dir=tmp_path / "data",
|
||||
legacy_boundaries_file=legacy_path,
|
||||
admin_api_key="test-admin-key",
|
||||
client_api_key=client_api_key,
|
||||
)
|
||||
settings.ensure_directories()
|
||||
repository = VideoRepository(settings.database_path)
|
||||
stored_filename = f"{VIDEO_HASH}.mp4"
|
||||
(settings.videos_dir / stored_filename).write_bytes(b"video-placeholder")
|
||||
repository.upsert_upload(
|
||||
video_hash=VIDEO_HASH,
|
||||
title="Lesson",
|
||||
filename="lesson.mp4",
|
||||
stored_filename=stored_filename,
|
||||
content_type="video/mp4",
|
||||
size_bytes=17,
|
||||
language="en",
|
||||
)
|
||||
repository.save_processing_result(
|
||||
SentenceBoundaryDocument(
|
||||
video_hash=VIDEO_HASH,
|
||||
duration_ms=2000,
|
||||
algorithm_version="test-v1",
|
||||
sentences=[
|
||||
{
|
||||
"index": 0,
|
||||
"start_ms": 0,
|
||||
"end_ms": 2000,
|
||||
"text": "The meeting starts at nine.",
|
||||
"language": "en",
|
||||
"reference_speech_duration_ms": 2000,
|
||||
}
|
||||
],
|
||||
),
|
||||
"The meeting starts at nine.",
|
||||
)
|
||||
app = create_app(
|
||||
BoundaryStore(legacy_path),
|
||||
settings=settings,
|
||||
repository=repository,
|
||||
transcriber=FakeTranscriber(),
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_assessment_returns_duration_and_content_breakdown(tmp_path):
|
||||
client = make_client(tmp_path)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1/videos/{VIDEO_HASH}/sentences/0/assessments",
|
||||
files={"audio": ("reading.wav", make_wav(), "audio/wav")},
|
||||
data={"language": "en"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload["overall_score"] == 100
|
||||
assert payload["passed"] is True
|
||||
assert 0.99 <= payload["duration_ratio"] <= 1.02
|
||||
assert payload["pronunciation_score"] is None
|
||||
assert payload["details"]["phoneme_scoring"] == "not_enabled"
|
||||
|
||||
|
||||
def test_admin_routes_require_key(tmp_path):
|
||||
client = make_client(tmp_path)
|
||||
|
||||
response = client.delete(f"/api/v1/admin/videos/{VIDEO_HASH}")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_catalog_and_range_streaming(tmp_path):
|
||||
client = make_client(tmp_path)
|
||||
|
||||
catalog = client.get("/api/v1/videos")
|
||||
stream = client.get(
|
||||
f"/api/v1/videos/{VIDEO_HASH}/content",
|
||||
headers={"Range": "bytes=0-4"},
|
||||
)
|
||||
|
||||
assert catalog.status_code == 200
|
||||
assert catalog.json()["videos"][0]["stream_url"].endswith(f"/{VIDEO_HASH}/content")
|
||||
assert stream.status_code == 206
|
||||
assert stream.content == b"video"
|
||||
|
||||
|
||||
def test_assessment_client_key_is_enforced_when_configured(tmp_path):
|
||||
client = make_client(tmp_path, client_api_key="tablet-key")
|
||||
endpoint = f"/api/v1/videos/{VIDEO_HASH}/sentences/0/assessments"
|
||||
|
||||
unauthorized = client.post(
|
||||
endpoint,
|
||||
files={"audio": ("reading.wav", make_wav(), "audio/wav")},
|
||||
)
|
||||
authorized = client.post(
|
||||
endpoint,
|
||||
headers={"X-Client-Key": "tablet-key"},
|
||||
files={"audio": ("reading.wav", make_wav(), "audio/wav")},
|
||||
)
|
||||
|
||||
assert unauthorized.status_code == 401
|
||||
assert authorized.status_code == 200
|
||||
56
sentence_api/tests/test_scoring.py
Normal file
56
sentence_api/tests/test_scoring.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import numpy as np
|
||||
|
||||
from sentence_api.audio_metrics import AudioMetrics, analyze_samples
|
||||
from sentence_api.scoring import align_text, duration_similarity_score, score_attempt, tokenize
|
||||
|
||||
|
||||
def test_tokenize_supports_mixed_chinese_and_english():
|
||||
assert tokenize("你好,World! Don't stop.") == ["你", "好", "world", "don't", "stop"]
|
||||
|
||||
|
||||
def test_identical_reading_with_matching_duration_scores_100():
|
||||
result = score_attempt(
|
||||
reference_text="The meeting starts at nine.",
|
||||
recognized_text="The meeting starts at nine",
|
||||
reference_speech_duration_ms=2000,
|
||||
student_metrics=AudioMetrics(
|
||||
recording_duration_ms=2400,
|
||||
speech_duration_ms=2000,
|
||||
internal_silence_ms=0,
|
||||
internal_pause_ratio=0.0,
|
||||
),
|
||||
)
|
||||
|
||||
assert result.overall_score == 100
|
||||
assert result.duration_score == 100
|
||||
assert result.missing_tokens == []
|
||||
|
||||
|
||||
def test_alignment_reports_missing_extra_and_substituted_tokens():
|
||||
result = align_text(
|
||||
"The meeting starts at nine",
|
||||
"The lesson start at nine today",
|
||||
)
|
||||
|
||||
assert result.content_score < 70
|
||||
assert result.extra_tokens == ["today"]
|
||||
assert ("meeting", "lesson") in result.substitutions
|
||||
assert ("starts", "start") in result.substitutions
|
||||
|
||||
|
||||
def test_duration_score_allows_students_to_read_more_slowly():
|
||||
assert duration_similarity_score(0.8) == 100
|
||||
assert duration_similarity_score(1.3) == 100
|
||||
assert duration_similarity_score(1.5) == 60
|
||||
assert duration_similarity_score(1.8) == 20
|
||||
|
||||
|
||||
def test_vad_excludes_leading_and_trailing_silence():
|
||||
sample_rate = 16_000
|
||||
silence = np.zeros(sample_rate // 2, dtype=np.float32)
|
||||
time = np.arange(sample_rate, dtype=np.float32) / sample_rate
|
||||
speech = (0.25 * np.sin(2 * np.pi * 220 * time)).astype(np.float32)
|
||||
metrics = analyze_samples(np.concatenate([silence, speech, silence]), sample_rate)
|
||||
|
||||
assert metrics.recording_duration_ms == 2000
|
||||
assert 900 <= metrics.speech_duration_ms <= 1050
|
||||
7
sentence_api/tests/test_store.py
Normal file
7
sentence_api/tests/test_store.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from sentence_api.store import BoundaryStore
|
||||
|
||||
|
||||
def test_missing_legacy_index_starts_empty(tmp_path):
|
||||
store = BoundaryStore(tmp_path / "not-created-yet.json")
|
||||
|
||||
assert store.count() == 0
|
||||
Reference in New Issue
Block a user