feat(tonedownload): Tone3000 NAM + IR browser, real anon key, tests
- Tonedownload.py: Tone3000 API client with search/download/cache/enrich - Server.py: 4 endpoints (search models, search IRs, install model, install IR) - Models.html + irs.html: Browse Tone3000 tab with search, trending, offline - Models.js + irs.js: Tone3000 search UI with install tracking via localStorage - Style.css: Card layout for search results with thumbnail, meta, install btn - Tests: 47 tests covering data classes, cache, API, search, download, enrichment - Anon key: Updated from placeholder to real Supabase anon key (verified: API works)
This commit is contained in:
@@ -0,0 +1,677 @@
|
||||
"""Tests for the Tone3000 downloader client.
|
||||
|
||||
Tests use mocked HTTP responses so no network access is needed.
|
||||
The Web UI integration tests cover the /api/models/tonedownload/* and
|
||||
/api/irs/tonedownload/* endpoints via the TestClient.
|
||||
|
||||
Run with:
|
||||
pytest tests/test_tonedownload.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.system.tonedownload import (
|
||||
Tone3000Client,
|
||||
ModelResult,
|
||||
IRResult,
|
||||
format_size,
|
||||
discover_anon_key,
|
||||
CACHE_TTL,
|
||||
)
|
||||
|
||||
# ── Test constants ─────────────────────────────────────────────────────
|
||||
|
||||
FAKE_ANON_KEY = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.test"
|
||||
|
||||
SAMPLE_MODEL_ROW = {
|
||||
"id": 1,
|
||||
"name": "Marshall JCM800",
|
||||
"model_url": "https://api.tone3000.com/storage/v1/object/public/models/test.nam",
|
||||
"size": 512000,
|
||||
"pack_name": "Marshall Pack",
|
||||
"user_id": "u1",
|
||||
"tone_id": 10,
|
||||
"architecture_version": "1",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
SAMPLE_TONE_ROW = {
|
||||
"id": 10,
|
||||
"title": "Marshall JCM800 Sound",
|
||||
"description": "Classic rock tone",
|
||||
"gear": "amp",
|
||||
"images": ["https://example.com/thumb.jpg"],
|
||||
}
|
||||
|
||||
SAMPLE_IR_TONE_ROW = {
|
||||
"id": 20,
|
||||
"title": "V30 Cabinet",
|
||||
"description": "4x12 V30 IR",
|
||||
"gear": "ir",
|
||||
"images": [],
|
||||
}
|
||||
|
||||
SAMPLE_IR_MODEL_ROW = {
|
||||
"id": 100,
|
||||
"name": "v30_cabinet.wav",
|
||||
"model_url": "https://api.tone3000.com/storage/v1/object/public/models/v30.wav",
|
||||
"size": 256000,
|
||||
"user_id": "u2",
|
||||
"tone_id": 20,
|
||||
"created_at": "2025-02-01T00:00:00Z",
|
||||
}
|
||||
|
||||
SAMPLE_USER_ROW = {
|
||||
"id": "u1",
|
||||
"username": "testuser",
|
||||
"avatar_url": "https://example.com/avatar.png",
|
||||
"display_name": "Test User",
|
||||
}
|
||||
|
||||
|
||||
# ── Fixtures ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session():
|
||||
"""Create a mock aiohttp.ClientSession that returns empty lists by default."""
|
||||
session = MagicMock()
|
||||
resp = MagicMock()
|
||||
resp.status = 200
|
||||
resp.json = AsyncMock(return_value=[])
|
||||
resp.text = AsyncMock(return_value="[]")
|
||||
resp.__aenter__ = AsyncMock(return_value=resp)
|
||||
resp.__aexit__ = AsyncMock(return_value=None)
|
||||
session.get = MagicMock(return_value=resp)
|
||||
session.closed = False
|
||||
session.close = AsyncMock()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_session):
|
||||
"""Tone3000Client with a mocked session and known anon key."""
|
||||
c = Tone3000Client(anon_key=FAKE_ANON_KEY, session=mock_session)
|
||||
yield c
|
||||
# Teardown: ensure close is called if the test doesn't
|
||||
if c._session and not c._session.closed:
|
||||
pass # tests manage session lifecycle
|
||||
|
||||
|
||||
# ── Data class tests ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestModelResult:
|
||||
def test_basic_attributes(self):
|
||||
m = ModelResult(id=1, name="test.nam", download_url="https://example.com/test.nam")
|
||||
assert m.id == 1
|
||||
assert m.filename == "test.nam"
|
||||
assert m.display_name == "test.nam"
|
||||
assert m.is_ir is False
|
||||
assert m.thumbnail_url is None
|
||||
|
||||
def test_with_tone_title(self):
|
||||
m = ModelResult(
|
||||
id=1,
|
||||
name="test.nam",
|
||||
download_url="https://example.com/test.nam",
|
||||
tone_title="My Tone",
|
||||
)
|
||||
assert m.display_name == "My Tone"
|
||||
|
||||
def test_with_images(self):
|
||||
m = ModelResult(
|
||||
id=1,
|
||||
name="test.nam",
|
||||
download_url="https://example.com/test.nam",
|
||||
tone_images=["https://example.com/thumb.jpg"],
|
||||
)
|
||||
assert m.thumbnail_url == "https://example.com/thumb.jpg"
|
||||
|
||||
def test_wav_is_ir(self):
|
||||
m = ModelResult(id=1, name="ir.wav", download_url="https://example.com/ir.wav")
|
||||
assert m.is_ir is True
|
||||
|
||||
def test_ir_flag_from_gear(self):
|
||||
m = ModelResult(id=1, name="test.wav", download_url="https://example.com/test.wav", tone_gear="ir")
|
||||
assert m.is_ir is True
|
||||
|
||||
|
||||
class TestIRResult:
|
||||
def test_basic_attributes(self):
|
||||
ir = IRResult(id=1, name="ir.wav", download_url="https://example.com/ir.wav")
|
||||
assert ir.is_ir is True
|
||||
|
||||
def test_sample_rate(self):
|
||||
ir = IRResult(id=1, name="ir.wav", download_url="https://example.com/ir.wav", sample_rate=48000)
|
||||
assert ir.sample_rate == 48000
|
||||
assert ir.is_ir is True
|
||||
|
||||
|
||||
# ── format_size tests ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFormatSize:
|
||||
def test_none(self):
|
||||
assert format_size(None) == "Unknown"
|
||||
|
||||
def test_bytes(self):
|
||||
assert format_size(500) == "0.5 KB"
|
||||
|
||||
def test_megabytes(self):
|
||||
assert format_size(2 * 1024 * 1024) == "2.0 MB"
|
||||
|
||||
def test_large_megabytes(self):
|
||||
assert format_size(15 * 1024 * 1024) == "15.0 MB"
|
||||
|
||||
def test_edge_kb(self):
|
||||
assert format_size(1023) == "1.0 KB"
|
||||
|
||||
def test_edge_mb(self):
|
||||
assert format_size(1024 * 1024) == "1.0 MB"
|
||||
|
||||
|
||||
# ── Client initialization ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestClientInit:
|
||||
def test_default_anon_key(self):
|
||||
"""Should use the hardcoded constant when no key provided."""
|
||||
c = Tone3000Client()
|
||||
assert c._anon_key is not None
|
||||
assert len(c._anon_key) > 50
|
||||
|
||||
def test_custom_anon_key(self):
|
||||
c = Tone3000Client(anon_key=FAKE_ANON_KEY)
|
||||
assert c._anon_key == FAKE_ANON_KEY
|
||||
|
||||
def test_custom_dirs(self):
|
||||
models_dir = Path("/tmp/test-models")
|
||||
irs_dir = Path("/tmp/test-irs")
|
||||
c = Tone3000Client(
|
||||
anon_key=FAKE_ANON_KEY,
|
||||
models_dir=models_dir,
|
||||
irs_dir=irs_dir,
|
||||
)
|
||||
assert c._models_dir == models_dir
|
||||
assert c._irs_dir == irs_dir
|
||||
|
||||
|
||||
# ── Session management ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSession:
|
||||
def test_get_session_creates_if_none(self, client):
|
||||
client._session = None
|
||||
assert client._session is None
|
||||
assert client._anon_key == FAKE_ANON_KEY
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_no_session(self, client):
|
||||
client._session = None
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_closed_session(self, client, mock_session):
|
||||
mock_session.closed = True
|
||||
await client.close()
|
||||
|
||||
|
||||
# ── Caching ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCaching:
|
||||
def test_cache_miss(self, client):
|
||||
assert client._get_cached("test:key") is None
|
||||
|
||||
def test_cache_hit(self, client):
|
||||
data = [{"id": 1, "name": "test"}]
|
||||
client._set_cache("test:key", data)
|
||||
result = client._get_cached("test:key")
|
||||
assert result == data
|
||||
|
||||
def test_cache_expiry(self, client):
|
||||
data = [{"id": 1}]
|
||||
client._set_cache("test:expire", data)
|
||||
client._cache["test:expire"] = (0.0, data)
|
||||
assert client._get_cached("test:expire") is None
|
||||
|
||||
|
||||
# ── API calls (mocked) ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSupabaseGet:
|
||||
@pytest.mark.asyncio
|
||||
async def test_success(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value=[{"id": 1}])
|
||||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||
mock_resp.__aexit__ = AsyncMock()
|
||||
client._session.get = MagicMock(return_value=mock_resp)
|
||||
|
||||
result = await client._supabase_get("models", {"limit": "1"})
|
||||
assert result == [{"id": 1}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_status(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 500
|
||||
mock_resp.text = AsyncMock(return_value="Internal error")
|
||||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||
mock_resp.__aexit__ = AsyncMock()
|
||||
client._session.get = MagicMock(return_value=mock_resp)
|
||||
|
||||
result = await client._supabase_get("models")
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_error(self, client):
|
||||
from aiohttp import ClientError
|
||||
|
||||
client._session.get = MagicMock(side_effect=ClientError("Connection refused"))
|
||||
|
||||
result = await client._supabase_get("models")
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_object_response(self, client):
|
||||
"""Should wrap a single object in a list."""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value={"id": 1})
|
||||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||
mock_resp.__aexit__ = AsyncMock()
|
||||
client._session.get = MagicMock(return_value=mock_resp)
|
||||
|
||||
result = await client._supabase_get("models", {"id": "eq.1"})
|
||||
assert result == [{"id": 1}]
|
||||
|
||||
|
||||
# ── Ping ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPing:
|
||||
@pytest.mark.asyncio
|
||||
async def test_online(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value=[{"id": 1}])
|
||||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||
mock_resp.__aexit__ = AsyncMock()
|
||||
client._session.get = MagicMock(return_value=mock_resp)
|
||||
|
||||
assert await client.ping() is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_offline(self, client):
|
||||
from aiohttp import ClientError
|
||||
|
||||
client._session.get = MagicMock(side_effect=ClientError("Timeout"))
|
||||
assert await client.ping() is False
|
||||
|
||||
|
||||
# ── Search ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSearchModels:
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_returns_results(self, client):
|
||||
async def mock_get(path, params=None):
|
||||
if path == "models" and params and "name" in params:
|
||||
return [SAMPLE_MODEL_ROW]
|
||||
if path == "users":
|
||||
return [SAMPLE_USER_ROW]
|
||||
if path == "tones":
|
||||
return [SAMPLE_TONE_ROW]
|
||||
return []
|
||||
|
||||
client._supabase_get = mock_get
|
||||
|
||||
results = await client.search_models("marshall", page=0)
|
||||
assert len(results) == 1
|
||||
assert results[0].name == "Marshall JCM800"
|
||||
assert results[0].display_name == "Marshall JCM800 Sound"
|
||||
assert results[0].author_username == "testuser"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_cached(self, client):
|
||||
call_count = 0
|
||||
|
||||
async def mock_get(path, params=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if path == "models":
|
||||
return [SAMPLE_MODEL_ROW]
|
||||
if path == "users":
|
||||
return [SAMPLE_USER_ROW]
|
||||
if path == "tones":
|
||||
return [SAMPLE_TONE_ROW]
|
||||
return []
|
||||
|
||||
client._supabase_get = mock_get
|
||||
|
||||
await client.search_models("marshall")
|
||||
first_count = call_count
|
||||
|
||||
await client.search_models("marshall")
|
||||
assert call_count == first_count
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_force_refresh(self, client):
|
||||
call_count = 0
|
||||
|
||||
async def mock_get(path, params=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if path == "models":
|
||||
return [SAMPLE_MODEL_ROW]
|
||||
if path == "users":
|
||||
return [SAMPLE_USER_ROW]
|
||||
if path == "tones":
|
||||
return [SAMPLE_TONE_ROW]
|
||||
return []
|
||||
|
||||
client._supabase_get = mock_get
|
||||
|
||||
await client.search_models("marshall")
|
||||
await client.search_models("marshall", force_refresh=True)
|
||||
assert call_count >= 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_query(self, client):
|
||||
results = await client.search_models("nonexistent_xyz123", page=0)
|
||||
assert isinstance(results, list)
|
||||
|
||||
|
||||
class TestSearchIRs:
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_returns_results(self, client):
|
||||
async def mock_get(path, params=None):
|
||||
if path == "tones" and "ir" in str(params.get("gear", "")):
|
||||
return [SAMPLE_IR_TONE_ROW]
|
||||
if path == "models":
|
||||
return [SAMPLE_IR_MODEL_ROW]
|
||||
if path == "users":
|
||||
return [{"id": "u2", "username": "irtest", "avatar_url": None, "display_name": None}]
|
||||
return []
|
||||
|
||||
client._supabase_get = mock_get
|
||||
|
||||
results = await client.search_irs("v30", page=0)
|
||||
assert len(results) >= 1
|
||||
assert results[0].is_ir is True
|
||||
assert results[0].tone_title == "V30 Cabinet"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_results(self, client):
|
||||
async def mock_get(path, params=None):
|
||||
if path == "tones":
|
||||
return []
|
||||
return []
|
||||
|
||||
client._supabase_get = mock_get
|
||||
|
||||
results = await client.search_irs("nonexistent_ir", page=0)
|
||||
assert results == []
|
||||
|
||||
|
||||
# ── Trending ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTrending:
|
||||
@pytest.mark.asyncio
|
||||
async def test_trending_models(self, client):
|
||||
async def mock_get(path, params=None):
|
||||
if path == "models":
|
||||
return [SAMPLE_MODEL_ROW]
|
||||
if path == "users":
|
||||
return [SAMPLE_USER_ROW]
|
||||
if path == "tones":
|
||||
return [SAMPLE_TONE_ROW]
|
||||
return []
|
||||
|
||||
client._supabase_get = mock_get
|
||||
results = await client.get_trending_models(limit=3)
|
||||
assert len(results) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trending_irs(self, client):
|
||||
async def mock_get(path, params=None):
|
||||
if path == "tones":
|
||||
return [SAMPLE_IR_TONE_ROW]
|
||||
if path == "models":
|
||||
return [SAMPLE_IR_MODEL_ROW]
|
||||
if path == "users":
|
||||
return [{"id": "u2", "username": "irtest", "avatar_url": None}]
|
||||
return []
|
||||
|
||||
client._supabase_get = mock_get
|
||||
results = await client.get_trending_irs(limit=3)
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
# ── Download ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDownload:
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_model_success(self, client, tmp_path):
|
||||
client._models_dir = tmp_path
|
||||
|
||||
async def _chunks():
|
||||
yield b"testdata"
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.headers = {"Content-Length": "9"}
|
||||
mock_resp.content = MagicMock()
|
||||
mock_resp.content.iter_chunked = MagicMock(side_effect=lambda cs: _chunks())
|
||||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||
mock_resp.__aexit__ = AsyncMock()
|
||||
client._session.get = MagicMock(return_value=mock_resp)
|
||||
|
||||
model = ModelResult(id=1, name="test_model", download_url="https://example.com/test.nam")
|
||||
path = await client.download_model(model)
|
||||
assert path is not None
|
||||
assert path.exists()
|
||||
assert path.suffix == ".nam"
|
||||
assert path.read_bytes() == b"testdata"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_model_no_url(self, client):
|
||||
model = ModelResult(id=1, name="test", download_url="")
|
||||
path = await client.download_model(model)
|
||||
assert path is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_model_http_error(self, client, tmp_path):
|
||||
client._models_dir = tmp_path
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 404
|
||||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||
mock_resp.__aexit__ = AsyncMock()
|
||||
client._session.get = MagicMock(return_value=mock_resp)
|
||||
|
||||
model = ModelResult(id=1, name="test", download_url="https://example.com/test.nam")
|
||||
path = await client.download_model(model)
|
||||
assert path is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_ir_success(self, client, tmp_path):
|
||||
client._irs_dir = tmp_path
|
||||
|
||||
async def _chunks():
|
||||
yield b"wavdata"
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.headers = {"Content-Length": "9"}
|
||||
mock_resp.content = MagicMock()
|
||||
mock_resp.content.iter_chunked = MagicMock(side_effect=lambda cs: _chunks())
|
||||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||
mock_resp.__aexit__ = AsyncMock()
|
||||
client._session.get = MagicMock(return_value=mock_resp)
|
||||
|
||||
ir = IRResult(id=1, name="cabinet", download_url="https://example.com/cabinet.wav")
|
||||
path = await client.download_ir(ir)
|
||||
assert path is not None
|
||||
assert path.exists()
|
||||
assert path.suffix == ".wav"
|
||||
assert path.read_bytes() == b"wavdata"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_progress(self, client, tmp_path):
|
||||
client._models_dir = tmp_path
|
||||
|
||||
async def _chunks():
|
||||
yield b"data1"
|
||||
yield b"data2"
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.headers = {"Content-Length": "12"}
|
||||
mock_resp.content = MagicMock()
|
||||
mock_resp.content.iter_chunked = MagicMock(side_effect=lambda cs: _chunks())
|
||||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||
mock_resp.__aexit__ = AsyncMock()
|
||||
client._session.get = MagicMock(return_value=mock_resp)
|
||||
|
||||
progress_values = []
|
||||
|
||||
def cb(ratio):
|
||||
progress_values.append(ratio)
|
||||
|
||||
model = ModelResult(id=1, name="test", download_url="https://example.com/test.nam")
|
||||
path = await client.download_model(model, progress_callback=cb)
|
||||
assert path is not None
|
||||
assert len(progress_values) > 0
|
||||
|
||||
|
||||
# ── User/Tone enrichment ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEnrichment:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_sync(self, client):
|
||||
"""User lookup works (sync test via mock)."""
|
||||
from src.system.tonedownload import Tone3000Client
|
||||
|
||||
c = Tone3000Client(anon_key=FAKE_ANON_KEY)
|
||||
result = await c._get_user("")
|
||||
assert result is None
|
||||
await c.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tone_sync(self, client):
|
||||
from src.system.tonedownload import Tone3000Client
|
||||
|
||||
c = Tone3000Client(anon_key=FAKE_ANON_KEY)
|
||||
result = await c._get_tone(9999)
|
||||
assert result is None
|
||||
await c.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_cached(self, client):
|
||||
async def mock_get(path, params=None):
|
||||
return [SAMPLE_USER_ROW]
|
||||
|
||||
client._supabase_get = mock_get
|
||||
user = await client._get_user("u1")
|
||||
assert user["username"] == "testuser"
|
||||
# Second call — cached
|
||||
user2 = await client._get_user("u1")
|
||||
assert user2["username"] == "testuser"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_empty_id(self, client):
|
||||
user = await client._get_user("")
|
||||
assert user is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tone_cached(self, client):
|
||||
async def mock_get(path, params=None):
|
||||
return [SAMPLE_TONE_ROW]
|
||||
|
||||
client._supabase_get = mock_get
|
||||
tone = await client._get_tone(10)
|
||||
assert tone["title"] == "Marshall JCM800 Sound"
|
||||
# Second call — cached
|
||||
tone2 = await client._get_tone(10)
|
||||
assert tone2["title"] == "Marshall JCM800 Sound"
|
||||
|
||||
|
||||
# ── Anon key discovery ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAnonKeyDiscovery:
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_timeout(self, client):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.__aenter__ = AsyncMock(side_effect=TimeoutError("Timed out"))
|
||||
mock_resp.__aexit__ = AsyncMock()
|
||||
client._session.get = MagicMock(return_value=mock_resp)
|
||||
|
||||
key = await discover_anon_key(client._session)
|
||||
assert key is None
|
||||
|
||||
|
||||
# ── Integration via real HTTP (optional) ───────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason="Real HTTP test — enable manually to verify against live Tone3000 API",
|
||||
)
|
||||
class TestRealHTTP:
|
||||
"""Tests that hit the real Tone3000 API."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping(self):
|
||||
c = Tone3000Client()
|
||||
ok = await c.ping()
|
||||
assert ok is True
|
||||
await c.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_models(self):
|
||||
c = Tone3000Client()
|
||||
results = await c.search_models("marshall", page=0, page_size=5)
|
||||
assert len(results) > 0
|
||||
for r in results:
|
||||
assert r.display_name
|
||||
assert r.download_url
|
||||
await c.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_irs(self):
|
||||
c = Tone3000Client()
|
||||
results = await c.search_irs("v30", page=0, page_size=5)
|
||||
assert len(results) > 0
|
||||
for r in results:
|
||||
assert r.is_ir is True
|
||||
await c.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trending_models(self):
|
||||
c = Tone3000Client()
|
||||
results = await c.get_trending_models(limit=5)
|
||||
assert len(results) > 0
|
||||
await c.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download(self, tmp_path):
|
||||
c = Tone3000Client(models_dir=tmp_path, irs_dir=tmp_path)
|
||||
results = await c.search_models("marshall", page=0, page_size=1)
|
||||
if results:
|
||||
path = await c.download_model(results[0])
|
||||
assert path is not None
|
||||
assert path.exists()
|
||||
assert path.stat().st_size > 1000
|
||||
await c.close()
|
||||
Reference in New Issue
Block a user