crowncode-backend / tests /test_timeline.py
Rthur2003's picture
feat: analysis jobs queue inference xai ve timeline modules ile unit tests eklendi
413c3b6
Raw History Blame Contribute Delete
8.32 kB
"""Whole-track scan: window plan, waveform peaks and the scan loop.
The classifier is a stub here; only the audio maths and the bookkeeping run.
"""
from __future__ import annotations
import dataclasses
from typing import List, Optional
import numpy as np
import pytest
from app.services import timeline
from app.services.feature_extractor import AudioFeatures
from app.services.inference_xai import FeatureContribution, WindowReading, XAIInferenceService
SR = 22050
class StubXai:
"""Scores a window by how loud it is, so each test can steer the result."""
threshold = 0.43
def __init__(self, probabilities: Optional[List[float]] = None) -> None:
self._queue = list(probabilities or [])
def predict_window(self, features, vocals, *, window_sec: float, top: int = 3) -> WindowReading:
p = self._queue.pop(0) if self._queue else 0.8
reason = FeatureContribution(
name="spectral_flatness_mean", label="Spektral düzlük", label_en="Spectral flatness",
category="spectral", value=0.1, z_score=-1.2, shap_value=0.4 if p >= self.threshold else -0.4,
direction="towards_ai" if p >= self.threshold else "towards_human", description="",
)
return WindowReading(probability=p, is_ai=p >= self.threshold, margin=0.5, reasons=[reason])
@pytest.fixture
def fast_features(monkeypatch):
"""Skip the librosa work: the scan loop is what these tests cover."""
monkeypatch.setattr(timeline, "extract_features_array", lambda seg, sr: object())
monkeypatch.setattr(timeline, "analyze_vocals_array", lambda seg, sr: None)
def tone(seconds: float, level: float = 0.5) -> np.ndarray:
t = np.arange(int(seconds * SR)) / SR
return (level * np.sin(2 * np.pi * 220 * t)).astype(np.float32)
# ── plan ─────────────────────────────────────────────────────────────
@pytest.mark.parametrize("duration", [3.0, 29.0, 44.9, 45.0, 75.0, 214.2, 359.9])
def test_windows_tile_the_whole_track(duration: float) -> None:
windows = timeline.plan_windows(duration)
assert windows[0][0] == 0
assert windows[-1][1] == pytest.approx(duration, abs=0.01)
for (_, end), (start, _) in zip(windows, windows[1:]):
assert end == pytest.approx(start, abs=0.01)
@pytest.mark.parametrize("duration", [45.0, 75.0, 214.2, 359.9])
def test_window_length_stays_near_thirty_seconds(duration: float) -> None:
for start, end in timeline.plan_windows(duration):
assert 20.0 <= end - start <= 45.0
def test_nothing_to_plan_for_empty_audio() -> None:
assert timeline.plan_windows(0) == []
# ── peaks ────────────────────────────────────────────────────────────
def test_peaks_are_normalised_and_bounded() -> None:
peaks = timeline.waveform_peaks(tone(10.0, 0.2), points=100)
assert len(peaks) == 100
assert max(peaks) == 1.0
assert min(peaks) >= 0.0
def test_peaks_of_silence_are_zero() -> None:
assert set(timeline.waveform_peaks(np.zeros(SR, dtype=np.float32), points=10)) == {0.0}
def test_peaks_of_a_very_short_clip_do_not_overrun() -> None:
assert len(timeline.waveform_peaks(np.ones(5, dtype=np.float32), points=600)) == 5
# ── scan ─────────────────────────────────────────────────────────────
def test_scan_scores_every_window_and_summarises(fast_features) -> None:
y = tone(90.0) # three windows of 30 s
out = timeline.scan_track(y, SR, StubXai([0.9, 0.1, 0.7]), total_sec=90.0)
assert [s["state"] for s in out["segments"]] == ["ok", "ok", "ok"]
assert [s["isAi"] for s in out["segments"]] == [True, False, True]
summary = out["summary"]
assert summary["scoredCount"] == 3
assert summary["flaggedCount"] == 2
assert summary["aiShare"] == pytest.approx(2 / 3, abs=0.01)
assert summary["peakIndex"] == 0
assert summary["maxProbability"] == 0.9
assert out["truncated"] is False
assert out["threshold"] == pytest.approx(0.43)
assert len(out["peaks"]) > 0
assert out["segments"][0]["reasons"][0]["direction"] == "towards_ai"
def test_scan_keeps_window_order_with_parallel_workers(fast_features, monkeypatch) -> None:
monkeypatch.setattr(timeline, "SCAN_WORKERS", 4)
out = timeline.scan_track(tone(300.0), SR, StubXai(), total_sec=300.0)
assert [s["index"] for s in out["segments"]] == list(range(10))
starts = [s["start"] for s in out["segments"]]
assert starts == sorted(starts)
def test_a_silent_window_is_not_scored(fast_features) -> None:
y = np.concatenate([tone(30.0), np.zeros(30 * SR, dtype=np.float32)])
out = timeline.scan_track(y, SR, StubXai([0.8]), total_sec=60.0)
assert [s["state"] for s in out["segments"]] == ["ok", "silent"]
assert out["summary"]["scoredCount"] == 1
assert out["summary"]["scoredSec"] == pytest.approx(30.0, abs=0.1)
def test_windows_past_the_budget_are_skipped(fast_features) -> None:
out = timeline.scan_track(tone(90.0), SR, StubXai(), total_sec=90.0, budget_sec=-1.0)
assert {s["state"] for s in out["segments"]} == {"skipped"}
assert out["summary"]["scoredCount"] == 0
assert out["summary"]["peakIndex"] is None
def test_cancelling_skips_the_remaining_windows(fast_features) -> None:
out = timeline.scan_track(tone(90.0), SR, StubXai(), total_sec=90.0, cancelled=lambda: True)
assert {s["state"] for s in out["segments"]} == {"skipped"}
def test_a_long_track_is_flagged_as_truncated(fast_features) -> None:
out = timeline.scan_track(tone(30.0), SR, StubXai(), total_sec=500.0)
assert out["truncated"] is True
assert out["totalSec"] == 500.0
assert out["durationSec"] == pytest.approx(30.0, abs=0.05)
def test_a_failing_window_does_not_sink_the_scan(fast_features, monkeypatch) -> None:
calls = {"n": 0}
def flaky(seg, sr):
calls["n"] += 1
if calls["n"] == 2:
raise RuntimeError("boom")
return object()
monkeypatch.setattr(timeline, "extract_features_array", flaky)
monkeypatch.setattr(timeline, "SCAN_WORKERS", 1)
out = timeline.scan_track(tone(90.0), SR, StubXai(), total_sec=90.0)
assert [s["state"] for s in out["segments"]] == ["ok", "failed", "ok"]
assert out["summary"]["scoredCount"] == 2
def test_progress_reports_every_window(fast_features) -> None:
seen: List[tuple] = []
timeline.scan_track(tone(90.0), SR, StubXai(), total_sec=90.0, on_progress=lambda d, n: seen.append((d, n)))
assert sorted(seen) == [(1, 3), (2, 3), (3, 3)]
def test_real_audio_features_run_on_a_short_window() -> None:
"""The librosa path, with only the classifier stubbed."""
rng = np.random.default_rng(3)
y = (tone(4.0, 0.3) + 0.02 * rng.standard_normal(4 * SR)).astype(np.float32)
out = timeline.scan_track(y, SR, StubXai([0.2]), total_sec=4.0)
assert out["segments"][0]["state"] == "ok"
assert out["segments"][0]["probability"] == 0.2
def test_window_beat_count_is_scaled_to_the_training_clip() -> None:
"""A 30 s window has about half the beats of a 60 s clip; the model expects 60 s."""
seen = {}
class Scaler:
def transform(self, x):
seen["x"] = x.copy()
return x
class Model:
def predict_proba(self, x):
return np.array([[0.4, 0.6]])
svc = XAIInferenceService.__new__(XAIInferenceService)
svc.available = True
svc.threshold = 0.43
svc.feature_cols = ["beat_count", "tempo_bpm"]
svc.feature_stats = {}
svc.shap_explainer = None
svc.scaler = Scaler()
svc.model = Model()
features = AudioFeatures(**{f.name: 0 for f in dataclasses.fields(AudioFeatures)})
features.beat_count = 25
features.tempo_bpm = 120.0
reading = svc.predict_window(features, None, window_sec=30.0)
assert seen["x"][0, 0] == 50.0
assert seen["x"][0, 1] == 120.0
assert reading is not None
assert reading.probability == pytest.approx(0.6)
assert reading.is_ai is True
assert reading.reasons == []