Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
0
ai_platform/modules/extractors/tests/__init__.py
Normal file
0
ai_platform/modules/extractors/tests/__init__.py
Normal file
45
ai_platform/modules/extractors/tests/conftest.py
Normal file
45
ai_platform/modules/extractors/tests/conftest.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Shared test fixtures: synthetic media generated in-memory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jpeg_bytes() -> bytes:
|
||||
"""A small valid JPEG with no EXIF."""
|
||||
|
||||
img = Image.fromarray(
|
||||
(np.random.default_rng(0).random((64, 64, 3)) * 255).astype("uint8")
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=85)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def png_bytes() -> bytes:
|
||||
"""A small valid PNG."""
|
||||
|
||||
img = Image.new("RGB", (48, 48), (120, 30, 200))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jpeg_with_software(tmp_path):
|
||||
"""Write a JPEG carrying a Photoshop Software EXIF tag; return its path."""
|
||||
|
||||
from PIL import Image as PImage
|
||||
|
||||
img = PImage.new("RGB", (32, 32), (10, 20, 30))
|
||||
exif = img.getexif()
|
||||
exif[0x0131] = "Adobe Photoshop 25.0" # Software tag
|
||||
path = tmp_path / "edited.jpg"
|
||||
img.save(path, format="JPEG", exif=exif)
|
||||
return str(path)
|
||||
51
ai_platform/modules/extractors/tests/test_api.py
Normal file
51
ai_platform/modules/extractors/tests/test_api.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""End-to-end API tests via FastAPI TestClient."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from extractors.app import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def _png() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (40, 40), (200, 10, 10)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_health():
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_ready_reports_tooling():
|
||||
r = client.get("/ready")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "ffprobe" in body and "ffmpeg" in body
|
||||
|
||||
|
||||
def test_metadata_image_runs_image_extractors():
|
||||
r = client.post(
|
||||
"/v1/metadata",
|
||||
files={"file": ("photo.png", _png(), "image/png")},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["media_type"] == "image"
|
||||
assert set(body["analyses"]) >= {"integrity", "exif", "ela"}
|
||||
assert len(body["sha256"]) == 64
|
||||
assert body["execution_time_ms"] >= 0
|
||||
|
||||
|
||||
def test_metadata_rejects_empty():
|
||||
r = client.post(
|
||||
"/v1/metadata", files={"file": ("empty.png", b"", "image/png")}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
80
ai_platform/modules/extractors/tests/test_detect.py
Normal file
80
ai_platform/modules/extractors/tests/test_detect.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Object-detection tests with a fake YOLO predictor (no torch/weights)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from extractors.app import app
|
||||
from extractors.features import detect
|
||||
|
||||
|
||||
class _Box:
|
||||
def __init__(self, cls_id, conf, xyxy):
|
||||
self.cls = [cls_id]
|
||||
self.conf = [conf]
|
||||
self.xyxy = [xyxy]
|
||||
|
||||
|
||||
class _Result:
|
||||
names = {0: "person", 2: "car"}
|
||||
|
||||
def __init__(self, boxes):
|
||||
self.boxes = boxes
|
||||
|
||||
|
||||
class FakeYOLO:
|
||||
def __init__(self, boxes):
|
||||
self._boxes = boxes
|
||||
|
||||
def predict(self, img, **kwargs):
|
||||
return [_Result(self._boxes)]
|
||||
|
||||
|
||||
def _png() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (64, 64), (0, 0, 0)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_summarize_counts():
|
||||
dets = [
|
||||
{"label": "person", "confidence": 0.9, "box": [0, 0, 10, 10]},
|
||||
{"label": "person", "confidence": 0.8, "box": [5, 5, 20, 20]},
|
||||
{"label": "car", "confidence": 0.7, "box": [0, 0, 30, 30]},
|
||||
]
|
||||
res = detect._summarize(dets)
|
||||
assert res.results["count"] == 3
|
||||
assert res.results["by_label"] == {"person": 2, "car": 1}
|
||||
assert "person×2" in res.evidence[0]
|
||||
|
||||
|
||||
def test_analyze_parses_and_sorts():
|
||||
fake = FakeYOLO([_Box(2, 0.6, [1, 1, 9, 9]), _Box(0, 0.95, [0, 0, 8, 8])])
|
||||
res = detect.analyze(_png(), model=fake)
|
||||
assert res.ok is True
|
||||
assert res.results["count"] == 2
|
||||
# sorted by confidence desc → person (0.95) first
|
||||
assert res.results["detections"][0]["label"] == "person"
|
||||
assert res.results["detections"][0]["confidence"] == 0.95
|
||||
|
||||
|
||||
def test_analyze_empty_image():
|
||||
res = detect.analyze(b"", model=FakeYOLO([]))
|
||||
assert res.ok is False
|
||||
|
||||
|
||||
def test_detect_endpoint_503_without_ml_extra():
|
||||
client = TestClient(app)
|
||||
r = client.post("/v1/detect", files={"file": ("x.png", _png(), "image/png")})
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
def test_detect_endpoint_with_monkeypatched_model(monkeypatch):
|
||||
monkeypatch.setattr(detect, "get_model", lambda: FakeYOLO([_Box(0, 0.9, [0, 0, 5, 5])]))
|
||||
client = TestClient(app)
|
||||
r = client.post("/v1/detect", files={"file": ("x.png", _png(), "image/png")})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["results"]["by_label"] == {"person": 1}
|
||||
22
ai_platform/modules/extractors/tests/test_ela.py
Normal file
22
ai_platform/modules/extractors/tests/test_ela.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""ELA extractor tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from extractors.features import ela
|
||||
|
||||
|
||||
def test_ela_returns_stats(tmp_path, jpeg_bytes):
|
||||
path = tmp_path / "img.jpg"
|
||||
path.write_bytes(jpeg_bytes)
|
||||
res = ela.extract(str(path))
|
||||
assert res.ok is True
|
||||
for key in ("mean_error", "max_error", "p99_error", "hot_pixel_fraction"):
|
||||
assert key in res.results
|
||||
assert 0.0 <= res.results["hot_pixel_fraction"] <= 1.0
|
||||
|
||||
|
||||
def test_ela_on_bad_input(tmp_path):
|
||||
path = tmp_path / "x.bin"
|
||||
path.write_bytes(b"\x00\x01\x02")
|
||||
res = ela.extract(str(path))
|
||||
assert res.ok is False
|
||||
33
ai_platform/modules/extractors/tests/test_exif.py
Normal file
33
ai_platform/modules/extractors/tests/test_exif.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""EXIF extractor tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from extractors.features import exif
|
||||
|
||||
|
||||
def test_no_exif_is_reported(tmp_path, png_bytes):
|
||||
path = tmp_path / "plain.png"
|
||||
path.write_bytes(png_bytes)
|
||||
res = exif.extract(str(path))
|
||||
assert res.ok is True
|
||||
assert any("No EXIF" in e for e in res.evidence)
|
||||
assert not res.anomalies
|
||||
|
||||
|
||||
def test_editing_software_flagged_as_anomaly(jpeg_with_software):
|
||||
res = exif.extract(jpeg_with_software)
|
||||
assert res.ok is True
|
||||
assert res.results.get("software", "").lower().startswith("adobe photoshop")
|
||||
assert any("Editing/generation software" in a for a in res.anomalies)
|
||||
|
||||
|
||||
def test_non_image_returns_error(tmp_path):
|
||||
path = tmp_path / "notimage.bin"
|
||||
path.write_bytes(b"not an image at all")
|
||||
res = exif.extract(str(path))
|
||||
assert res.ok is False
|
||||
assert res.error
|
||||
32
ai_platform/modules/extractors/tests/test_integrity.py
Normal file
32
ai_platform/modules/extractors/tests/test_integrity.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""Integrity & signature extractor tests (no external deps)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from extractors.features import integrity
|
||||
|
||||
|
||||
def test_identifies_jpeg_and_hashes(jpeg_bytes):
|
||||
res = integrity.analyze_bytes(jpeg_bytes)
|
||||
assert res.ok is True
|
||||
assert res.results["container"] == "JPEG"
|
||||
assert len(res.results["sha256"]) == 64
|
||||
assert res.results["size_bytes"] == len(jpeg_bytes)
|
||||
assert not res.anomalies
|
||||
|
||||
|
||||
def test_identifies_png(png_bytes):
|
||||
res = integrity.analyze_bytes(png_bytes)
|
||||
assert res.results["container"] == "PNG"
|
||||
|
||||
|
||||
def test_detects_trailing_data_after_jpeg_eoi(jpeg_bytes):
|
||||
tampered = jpeg_bytes + b"\x00" * 64 # appended payload after FFD9
|
||||
res = integrity.analyze_bytes(tampered)
|
||||
assert res.results.get("trailing_bytes_after_eoi") == 64
|
||||
assert any("appended after JPEG" in a for a in res.anomalies)
|
||||
|
||||
|
||||
def test_detects_mp4_brand():
|
||||
data = b"\x00\x00\x00\x18ftypmp42" + b"\x00" * 32
|
||||
res = integrity.analyze_bytes(data)
|
||||
assert "ISO-BMFF/MP4" in res.results["container"]
|
||||
133
ai_platform/modules/extractors/tests/test_llm_features.py
Normal file
133
ai_platform/modules/extractors/tests/test_llm_features.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""Tests for LLM-delegated features (sentiment, OCR) with a fake gateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from extractors.app import app
|
||||
from extractors.deps import get_llm_client
|
||||
from extractors.features import ocr, sentiment
|
||||
from extractors.features._jsonparse import extract_json_object
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
"""Stand-in for LLMClient that returns a canned completion."""
|
||||
|
||||
configured = True
|
||||
|
||||
def __init__(self, reply: str):
|
||||
self._reply = reply
|
||||
self.calls: list[list[dict]] = []
|
||||
|
||||
async def chat(self, messages, *, max_tokens=1024, temperature=0.0):
|
||||
self.calls.append(messages)
|
||||
return self._reply
|
||||
|
||||
|
||||
# --- JSON parsing --------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_json_plain():
|
||||
assert extract_json_object('{"a": 1}') == {"a": 1}
|
||||
|
||||
|
||||
def test_extract_json_fenced_with_prose():
|
||||
txt = 'Sigur!\n```json\n{"label": "negativ", "score": -0.7}\n```\nGata.'
|
||||
assert extract_json_object(txt)["label"] == "negativ"
|
||||
|
||||
|
||||
def test_extract_json_missing_raises():
|
||||
with pytest.raises(ValueError):
|
||||
extract_json_object("no json here")
|
||||
|
||||
|
||||
# --- Sentiment (unit) ----------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentiment_parses_model_json():
|
||||
fake = FakeLLM('{"label":"negativ","score":-0.8,"confidence":0.9,'
|
||||
'"rationale":"Ton agresiv."}')
|
||||
res = await sentiment.analyze("Ești un mincompetent total.", fake)
|
||||
assert res.ok is True
|
||||
assert res.results["label"] == "negativ"
|
||||
assert res.results["score"] == -0.8
|
||||
assert res.confidence == 0.9
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentiment_clamps_and_defaults():
|
||||
fake = FakeLLM('{"label":"ceva-invalid","score":5,"confidence":2}')
|
||||
res = await sentiment.analyze("text", fake)
|
||||
assert res.results["label"] == "neutru" # invalid label → neutru
|
||||
assert res.results["score"] == 1.0 # clamped to 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sentiment_empty_text():
|
||||
res = await sentiment.analyze(" ", FakeLLM("{}"))
|
||||
assert res.ok is False
|
||||
|
||||
|
||||
# --- OCR (unit) ----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_parses_text():
|
||||
fake = FakeLLM('{"text":"BREAKING: fake news","language":"en","has_text":true}')
|
||||
res = await ocr.analyze(b"\xff\xd8\xff_fake_jpeg", fake)
|
||||
assert res.ok is True
|
||||
assert res.results["text"] == "BREAKING: fake news"
|
||||
assert res.results["char_count"] == 19
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_accepts_plain_text_fallback():
|
||||
fake = FakeLLM("Doar text simplu, fără JSON")
|
||||
res = await ocr.analyze(b"img", fake)
|
||||
assert res.ok is True
|
||||
assert "text simplu" in res.results["text"]
|
||||
|
||||
|
||||
# --- API level -----------------------------------------------------------
|
||||
|
||||
|
||||
def _png() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (32, 32), (0, 0, 0)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_sentiment_endpoint():
|
||||
fake = FakeLLM('{"label":"pozitiv","score":0.6,"confidence":0.8}')
|
||||
app.dependency_overrides[get_llm_client] = lambda: fake
|
||||
try:
|
||||
client = TestClient(app)
|
||||
r = client.post("/v1/sentiment", json={"text": "Ce zi frumoasă!"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["results"]["label"] == "pozitiv"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_ocr_endpoint():
|
||||
fake = FakeLLM('{"text":"STOP","language":"en","has_text":true}')
|
||||
app.dependency_overrides[get_llm_client] = lambda: fake
|
||||
try:
|
||||
client = TestClient(app)
|
||||
r = client.post("/v1/ocr", files={"file": ("x.png", _png(), "image/png")})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["results"]["text"] == "STOP"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_sentiment_endpoint_503_without_gateway():
|
||||
# Default client has no gateway URL configured → 503.
|
||||
client = TestClient(app)
|
||||
r = client.post("/v1/sentiment", json={"text": "test"})
|
||||
assert r.status_code == 503
|
||||
58
ai_platform/modules/extractors/tests/test_ner.py
Normal file
58
ai_platform/modules/extractors/tests/test_ner.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""NER tests with a fake GLiNER predictor (no torch/model download)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from extractors.app import app
|
||||
from extractors.features import ner
|
||||
|
||||
|
||||
class FakePredictor:
|
||||
"""Mimics GLiNER.predict_entities with canned spans."""
|
||||
|
||||
def __init__(self, ents):
|
||||
self._ents = ents
|
||||
|
||||
def predict_entities(self, text, labels, threshold=0.5):
|
||||
return self._ents
|
||||
|
||||
|
||||
_SAMPLE = [
|
||||
{"text": "Klaus Iohannis", "label": "persoană", "start": 0, "end": 14, "score": 0.97},
|
||||
{"text": "Guvernul României", "label": "instituție publică", "start": 20,
|
||||
"end": 37, "score": 0.91},
|
||||
]
|
||||
|
||||
|
||||
def test_ner_parses_and_sorts():
|
||||
res = ner.analyze("Klaus Iohannis și Guvernul României.", model=FakePredictor(_SAMPLE))
|
||||
assert res.ok is True
|
||||
assert res.results["count"] == 2
|
||||
assert res.results["entities"][0]["text"] == "Klaus Iohannis"
|
||||
assert res.results["by_label"]["persoană"] == 1
|
||||
assert "persoană×1" in res.evidence[0]
|
||||
|
||||
|
||||
def test_ner_empty_entities():
|
||||
res = ner.analyze("text neutru", model=FakePredictor([]))
|
||||
assert res.ok is True
|
||||
assert res.results["count"] == 0
|
||||
assert "No entities" in res.evidence[0]
|
||||
|
||||
|
||||
def test_ner_endpoint_503_without_ml_extra():
|
||||
# gliner/torch are not installed in the fast (dev) environment.
|
||||
client = TestClient(app)
|
||||
r = client.post("/v1/ner", json={"text": "Klaus Iohannis"})
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
def test_ner_endpoint_with_monkeypatched_model(monkeypatch):
|
||||
monkeypatch.setattr(ner, "get_model", lambda: FakePredictor(_SAMPLE))
|
||||
client = TestClient(app)
|
||||
r = client.post("/v1/ner", json={"text": "Klaus Iohannis și Guvernul României."})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["results"]["count"] == 2
|
||||
assert body["results"]["entities"][1]["label"] == "instituție publică"
|
||||
34
ai_platform/modules/extractors/tests/test_spectrogram.py
Normal file
34
ai_platform/modules/extractors/tests/test_spectrogram.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Spectrogram DSP tests (synthetic signals, no ffmpeg)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from extractors.features import spectrogram
|
||||
|
||||
|
||||
def test_compute_features_on_tone():
|
||||
sr = 16_000
|
||||
t = np.linspace(0, 1.0, sr, endpoint=False)
|
||||
pcm = 0.5 * np.sin(2 * np.pi * 440 * t).astype(np.float32)
|
||||
feats = spectrogram.compute_features(pcm, sr)
|
||||
assert feats["sample_rate"] == sr
|
||||
assert abs(feats["duration_s"] - 1.0) < 0.01
|
||||
assert feats["spectral_centroid_hz_mean"] > 0
|
||||
assert feats["discontinuities_s"] == []
|
||||
|
||||
|
||||
def test_detects_energy_discontinuity():
|
||||
sr = 16_000
|
||||
quiet = np.full(sr // 2, 0.001, dtype=np.float32)
|
||||
loud = (0.6 * np.sin(2 * np.pi * 300 * np.linspace(0, 0.5, sr // 2))).astype(
|
||||
np.float32
|
||||
)
|
||||
pcm = np.concatenate([quiet, loud])
|
||||
feats = spectrogram.compute_features(pcm, sr)
|
||||
assert len(feats["discontinuities_s"]) >= 1
|
||||
|
||||
|
||||
def test_empty_signal():
|
||||
feats = spectrogram.compute_features(np.array([], dtype=np.float32), 16_000)
|
||||
assert feats["frames"] == 0
|
||||
59
ai_platform/modules/extractors/tests/test_video_meta.py
Normal file
59
ai_platform/modules/extractors/tests/test_video_meta.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""ffprobe parser tests (no binary needed — fixture-driven)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from extractors.features import video_meta
|
||||
|
||||
_FIXTURE = {
|
||||
"format": {
|
||||
"format_name": "mov,mp4,m4a,3gp,3g2,mj2",
|
||||
"duration": "12.500",
|
||||
"bit_rate": "2500000",
|
||||
"tags": {"encoder": "Lavf58.76.100"},
|
||||
},
|
||||
"streams": [
|
||||
{
|
||||
"codec_type": "video",
|
||||
"codec_name": "h264",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"avg_frame_rate": "30000/1001",
|
||||
"pix_fmt": "yuv420p",
|
||||
},
|
||||
{
|
||||
"codec_type": "audio",
|
||||
"codec_name": "aac",
|
||||
"sample_rate": "48000",
|
||||
"channels": 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_parse_fps_fractional():
|
||||
assert video_meta._parse_fps("30000/1001") == 29.97
|
||||
assert video_meta._parse_fps("25/1") == 25.0
|
||||
assert video_meta._parse_fps("0/0") is None
|
||||
assert video_meta._parse_fps(None) is None
|
||||
|
||||
|
||||
def test_parse_ffprobe_full():
|
||||
res = video_meta.parse_ffprobe(_FIXTURE)
|
||||
assert res.ok is True
|
||||
assert res.results["video"]["codec"] == "h264"
|
||||
assert res.results["video"]["width"] == 1920
|
||||
assert res.results["video"]["fps"] == 29.97
|
||||
assert res.results["bitrate_bps"] == 2500000
|
||||
assert res.results["has_audio"] is True
|
||||
assert res.results["audio"]["codec"] == "aac"
|
||||
# Lavf encoder tag should be flagged as a re-encoding tool.
|
||||
assert any("Lavf" in a for a in res.anomalies)
|
||||
|
||||
|
||||
def test_parse_ffprobe_audio_only():
|
||||
res = video_meta.parse_ffprobe(
|
||||
{"format": {"format_name": "wav"}, "streams": [{"codec_type": "audio"}]}
|
||||
)
|
||||
assert res.ok is True
|
||||
assert res.results["has_audio"] is True
|
||||
assert "video" not in res.results
|
||||
Loading…
Add table
Add a link
Reference in a new issue