Refactor settings store to rename proModel to proThinking and update related logic; enhance CSS for energy efficiency and reduced motion preferences; improve i18n translations for better clarity and consistency; modify proBlock utility functions for clearer instruction handling; streamline Vite configuration by removing unnecessary Univer.js dependencies.
This commit is contained in:
+263
-30
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -16,47 +17,279 @@ except ModuleNotFoundError:
|
||||
pytest.skip("llm module dependencies are not available", allow_module_level=True)
|
||||
|
||||
|
||||
def test_call_ollama_messages_roles_with_system(monkeypatch):
|
||||
def test_extract_message_openai_format():
|
||||
resp = {"choices": [{"message": {"content": "hello world", "thinking": "reasoning"}}]}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == "hello world"
|
||||
assert thinking == "reasoning"
|
||||
|
||||
|
||||
def test_extract_message_openai_reasoning_content():
|
||||
resp = {"choices": [{"message": {"content": "answer", "reasoning_content": "deep thought"}}]}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == "answer"
|
||||
assert thinking == "deep thought"
|
||||
|
||||
|
||||
def test_extract_message_empty_choices():
|
||||
resp = {"choices": []}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_extract_message_no_choices_key():
|
||||
resp = {}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_extract_message_none_content():
|
||||
resp = {"choices": [{"message": {"content": None, "thinking": None}}]}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_extract_delta_text():
|
||||
chunk = {"choices": [{"delta": {"content": "hello"}}]}
|
||||
assert llm._extract_delta_text(chunk) == "hello"
|
||||
|
||||
|
||||
def test_extract_delta_text_empty():
|
||||
chunk = {"choices": [{"delta": {}}]}
|
||||
assert llm._extract_delta_text(chunk) == ""
|
||||
|
||||
|
||||
def test_extract_delta_thinking():
|
||||
chunk = {"choices": [{"delta": {"thinking": "reasoning step"}}]}
|
||||
assert llm._extract_delta_thinking(chunk) == "reasoning step"
|
||||
|
||||
|
||||
def test_extract_delta_reasoning_content():
|
||||
chunk = {"choices": [{"delta": {"reasoning_content": "deep thought"}}]}
|
||||
assert llm._extract_delta_thinking(chunk) == "deep thought"
|
||||
|
||||
|
||||
def test_resolve_model_name_explicit():
|
||||
assert llm._resolve_model_name("custom-model") == "custom-model"
|
||||
|
||||
|
||||
def test_resolve_model_name_default():
|
||||
assert llm._resolve_model_name() == llm.LLM_MODEL
|
||||
|
||||
|
||||
def test_resolve_model_name_pro():
|
||||
assert llm._resolve_model_name(use_pro_model=True) == llm.PRO_LLM_MODEL
|
||||
|
||||
|
||||
def test_resolve_system_prompt():
|
||||
assert llm._resolve_system_prompt(" system prompt ") == "system prompt"
|
||||
assert llm._resolve_system_prompt("") == ""
|
||||
assert llm._resolve_system_prompt(None) == ""
|
||||
|
||||
|
||||
def test_build_chat_payload_with_system():
|
||||
payload = llm._build_chat_payload(
|
||||
"user prompt", system_prompt="sys prompt", temperature=0.5, model="test-model"
|
||||
)
|
||||
assert payload["model"] == "test-model"
|
||||
assert len(payload["messages"]) == 2
|
||||
assert payload["messages"][0]["role"] == "system"
|
||||
assert payload["messages"][1]["role"] == "user"
|
||||
assert payload["stream"] is False
|
||||
|
||||
|
||||
def test_build_chat_payload_no_system():
|
||||
payload = llm._build_chat_payload("user prompt", system_prompt=None)
|
||||
assert len(payload["messages"]) == 1
|
||||
assert payload["stream"] is False
|
||||
|
||||
|
||||
def test_build_chat_payload_with_thinking():
|
||||
payload = llm._build_chat_payload("prompt", thinking="low")
|
||||
assert "options" in payload
|
||||
assert payload["options"]["think"] == "low"
|
||||
|
||||
|
||||
def test_build_chat_stream_payload():
|
||||
payload = llm._build_chat_stream_payload("prompt", system_prompt="sys")
|
||||
assert payload["stream"] is True
|
||||
assert len(payload["messages"]) == 2
|
||||
|
||||
|
||||
def test_build_chat_stream_payload_with_thinking():
|
||||
payload = llm._build_chat_stream_payload("prompt", thinking="high")
|
||||
assert "options" in payload
|
||||
assert payload["options"]["think"] == "high"
|
||||
|
||||
|
||||
def test_call_ollama_non_streaming(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_generate(**kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return {"response": "ok"}
|
||||
async def fake_post(url, json=None):
|
||||
captured["url"] = url
|
||||
captured["json"] = json
|
||||
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
class FakeResp:
|
||||
def raise_for_status(self): pass
|
||||
def json(self): return {"choices": [{"message": {"content": "done"}}]}
|
||||
|
||||
return FakeResp()
|
||||
|
||||
async def fake_client(*args, **kwargs):
|
||||
class Ctx:
|
||||
async def __aenter__(self2): return self2
|
||||
async def __aexit__(*a): pass
|
||||
post = fake_post
|
||||
return Ctx()
|
||||
|
||||
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
|
||||
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
|
||||
|
||||
result = asyncio.run(
|
||||
llm.call_ollama(
|
||||
"user prompt body",
|
||||
system_prompt="system prompt body",
|
||||
tag="test",
|
||||
temperature=0.1,
|
||||
)
|
||||
llm.call_ollama("test prompt", system_prompt="sys", tag="t1")
|
||||
)
|
||||
|
||||
assert result["content"] == "ok"
|
||||
assert captured["kwargs"]["prompt"] == "system prompt body\n\nuser prompt body"
|
||||
assert captured["kwargs"]["raw"] is True
|
||||
assert result["content"] == "done"
|
||||
assert captured["url"] == "/chat/completions"
|
||||
assert captured["json"]["stream"] is False
|
||||
|
||||
|
||||
def test_call_ollama_messages_roles_without_system(monkeypatch):
|
||||
def test_stream_ollama_text_deltas(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_generate(**kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return {"response": "ok"}
|
||||
def make_lines():
|
||||
lines_iter = iter([
|
||||
'data: {"choices": [{"delta": {"content": "hel"}}]}',
|
||||
'data: {"choices": [{"delta": {"content": "lo"}}]}',
|
||||
"data: [DONE]",
|
||||
])
|
||||
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
class LineIterator:
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(lines_iter)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration()
|
||||
|
||||
result = asyncio.run(
|
||||
llm.call_ollama(
|
||||
"user prompt only",
|
||||
system_prompt="",
|
||||
tag="test-no-system",
|
||||
temperature=0.1,
|
||||
)
|
||||
)
|
||||
class Response:
|
||||
def __init__(self2): self2._lines = LineIterator()
|
||||
|
||||
assert result["content"] == "ok"
|
||||
assert captured["kwargs"]["prompt"] == "user prompt only"
|
||||
assert captured["kwargs"]["raw"] is True
|
||||
async def raise_for_status(self2): pass
|
||||
async def aiter_lines(self2): return self2._lines
|
||||
|
||||
class StreamCtx:
|
||||
async def __aenter__(self2): return Response()
|
||||
async def __aexit__(*a): pass
|
||||
|
||||
class Client:
|
||||
stream = lambda self2, *args, **kw: StreamCtx()
|
||||
|
||||
return Client()
|
||||
|
||||
async def fake_client(*args, **kwargs):
|
||||
captured["called"] = True
|
||||
return make_lines()
|
||||
|
||||
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
|
||||
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
|
||||
|
||||
results = []
|
||||
async def collect():
|
||||
async for delta in llm.stream_ollama("prompt", tag="t1"):
|
||||
results.append(delta)
|
||||
|
||||
asyncio.run(collect())
|
||||
assert captured.get("called") is True
|
||||
assert results == ["hel", "lo"]
|
||||
|
||||
|
||||
def test_stream_ollama_events_thinking_and_content(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def make_lines():
|
||||
lines_iter = iter([
|
||||
'data: {"choices": [{"delta": {"thinking": "reasoning"}}]}',
|
||||
'data: {"choices": [{"delta": {"content": "answer"}}]}',
|
||||
"data: [DONE]",
|
||||
])
|
||||
|
||||
class LineIterator:
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(lines_iter)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration()
|
||||
|
||||
class Response:
|
||||
def __init__(self2): self2._lines = LineIterator()
|
||||
|
||||
async def raise_for_status(self2): pass
|
||||
async def aiter_lines(self2): return self2._lines
|
||||
|
||||
class StreamCtx:
|
||||
async def __aenter__(self2): return Response()
|
||||
async def __aexit__(*a): pass
|
||||
|
||||
class Client:
|
||||
stream = lambda self2, *args, **kw: StreamCtx()
|
||||
|
||||
return Client()
|
||||
|
||||
async def fake_client(*args, **kwargs):
|
||||
captured["called"] = True
|
||||
return make_lines()
|
||||
|
||||
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
|
||||
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
|
||||
|
||||
results = []
|
||||
async def collect():
|
||||
async for event_type, payload in llm.stream_ollama_events("prompt", tag="t1"):
|
||||
results.append((event_type, payload))
|
||||
|
||||
asyncio.run(collect())
|
||||
assert captured.get("called") is True
|
||||
# First event should be thinking, then content
|
||||
assert results[0] == ("thinking", "")
|
||||
assert results[1][0] == "content"
|
||||
|
||||
|
||||
def test_call_vlm_ocr(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_post(url, json=None):
|
||||
captured["url"] = url
|
||||
captured["json"] = json
|
||||
|
||||
class FakeResp:
|
||||
def raise_for_status(self): pass
|
||||
def json(self): return {"choices": [{"message": {"content": "ocr text"}}]}
|
||||
|
||||
return FakeResp()
|
||||
|
||||
async def fake_client(*args, **kwargs):
|
||||
class Ctx:
|
||||
async def __aenter__(self2): return self2
|
||||
async def __aexit__(*a): pass
|
||||
post = fake_post
|
||||
return Ctx()
|
||||
|
||||
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
|
||||
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
|
||||
|
||||
result = asyncio.run(llm.call_vlm_ocr(b"fake image bytes"))
|
||||
assert result == "ocr text"
|
||||
|
||||
# Verify the payload uses OpenAI vision format (image_url)
|
||||
assert captured["url"] == "/chat/completions"
|
||||
messages = captured["json"]["messages"]
|
||||
assert len(messages) == 1
|
||||
content_parts = messages[0]["content"]
|
||||
# Should have text part and image_url part
|
||||
assert any(p.get("type") == "text" for p in content_parts)
|
||||
image_part = [p for p in content_parts if p.get("type") == "image_url"]
|
||||
assert len(image_part) == 1
|
||||
assert image_part[0]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
+148
-178
@@ -2,6 +2,7 @@ import asyncio
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -15,66 +16,27 @@ except ModuleNotFoundError:
|
||||
pytest.skip("llm module dependencies are not available", allow_module_level=True)
|
||||
|
||||
|
||||
def test_extract_message_with_object_message_content_and_thinking():
|
||||
class Msg:
|
||||
def __init__(self, content, thinking):
|
||||
self.content = content
|
||||
self.thinking = thinking
|
||||
|
||||
class Resp:
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
resp = Resp(Msg("hello world", "thinking about it"))
|
||||
def test_extract_message_with_content_and_thinking():
|
||||
resp = {"choices": [{"message": {"content": "hello world", "thinking": "reasoning"}}]}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == "hello world"
|
||||
assert thinking == "thinking about it"
|
||||
assert thinking == "reasoning"
|
||||
|
||||
|
||||
def test_extract_message_with_object_message_empty_content():
|
||||
class Msg:
|
||||
def __init__(self, content, thinking):
|
||||
self.content = content
|
||||
self.thinking = thinking
|
||||
|
||||
class Resp:
|
||||
def __init__(self, message):
|
||||
self.message = message
|
||||
|
||||
resp = Resp(Msg("", None))
|
||||
def test_extract_message_empty_content():
|
||||
resp = {"choices": [{"message": {"content": "", "thinking": None}}]}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_extract_message_with_dict_message():
|
||||
resp = {"message": {"content": "ok", "thinking": "calc"}}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == "ok"
|
||||
assert thinking == "calc"
|
||||
|
||||
|
||||
def test_extract_message_dict_no_message_key():
|
||||
resp = {"not_message": {"content": "irrelevant"}}
|
||||
def test_extract_message_dict_no_choices():
|
||||
resp = {"not_choices": []}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_extract_message_dict_message_content_none_and_thinking_none():
|
||||
resp = {"message": {"content": None, "thinking": None}}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == ""
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_extract_message_dict_message_thinking_none():
|
||||
resp = {"message": {"content": "val", "thinking": None}}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
assert content == "val"
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_extract_message_empty_dict():
|
||||
resp = {}
|
||||
content, thinking = llm._extract_message(resp)
|
||||
@@ -82,174 +44,182 @@ def test_extract_message_empty_dict():
|
||||
assert thinking == ""
|
||||
|
||||
|
||||
def test_call_ollama_no_system_message(monkeypatch):
|
||||
def test_extract_delta_text_from_chunk():
|
||||
chunk = {"choices": [{"delta": {"content": "text"}}]}
|
||||
assert llm._extract_delta_text(chunk) == "text"
|
||||
|
||||
|
||||
def test_extract_delta_thinking_from_chunk():
|
||||
chunk = {"choices": [{"delta": {"thinking": "thought"}}]}
|
||||
assert llm._extract_delta_thinking(chunk) == "thought"
|
||||
|
||||
|
||||
def test_call_ollama_no_system(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_generate(**kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return {"response": "ok"}
|
||||
async def fake_post(url, json=None):
|
||||
captured["json"] = json
|
||||
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
class FakeResp:
|
||||
def raise_for_status(self): pass
|
||||
def json(self): return {"choices": [{"message": {"content": "ok"}}]}
|
||||
|
||||
return FakeResp()
|
||||
|
||||
async def fake_client(*args, **kwargs):
|
||||
class Ctx:
|
||||
async def __aenter__(self2): return self2
|
||||
async def __aexit__(*a): pass
|
||||
post = fake_post
|
||||
return Ctx()
|
||||
|
||||
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
|
||||
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
|
||||
|
||||
result = asyncio.run(
|
||||
llm.call_ollama("user prompt body", system_prompt=None, tag="no-system", temperature=0.1)
|
||||
llm.call_ollama("user prompt", system_prompt=None, tag="no-system")
|
||||
)
|
||||
|
||||
assert result["content"] == "ok"
|
||||
assert captured["kwargs"]["prompt"] == "user prompt body"
|
||||
assert captured["kwargs"]["raw"] is True
|
||||
# Should only have user message, no system
|
||||
assert len(captured["json"]["messages"]) == 1
|
||||
|
||||
|
||||
def test_call_ollama_whitespace_system_message(monkeypatch):
|
||||
def test_call_ollama_with_system(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_generate(**kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return {"response": "ok"}
|
||||
async def fake_post(url, json=None):
|
||||
captured["json"] = json
|
||||
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
class FakeResp:
|
||||
def raise_for_status(self): pass
|
||||
def json(self): return {"choices": [{"message": {"content": "ok"}}]}
|
||||
|
||||
return FakeResp()
|
||||
|
||||
async def fake_client(*args, **kwargs):
|
||||
class Ctx:
|
||||
async def __aenter__(self2): return self2
|
||||
async def __aexit__(*a): pass
|
||||
post = fake_post
|
||||
return Ctx()
|
||||
|
||||
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
|
||||
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
|
||||
|
||||
result = asyncio.run(
|
||||
llm.call_ollama("user prompt", system_prompt=" ", tag="whitespace-system", temperature=0.1)
|
||||
llm.call_ollama("user prompt", system_prompt="sys prompt", tag="with-system")
|
||||
)
|
||||
|
||||
assert result["content"] == "ok"
|
||||
assert captured["kwargs"]["prompt"] == "user prompt"
|
||||
assert captured["kwargs"]["raw"] is True
|
||||
# Should have both system and user messages
|
||||
msgs = captured["json"]["messages"]
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0]["role"] == "system"
|
||||
|
||||
|
||||
def test_call_ollama_thinking_in_kwargs(monkeypatch):
|
||||
def test_call_ollama_with_custom_model(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_generate(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"response": "ok", "message": {"thinking": "boom"}} # keep thinking for backward test though it might not be perfect
|
||||
async def fake_post(url, json=None):
|
||||
captured["json"] = json
|
||||
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
class FakeResp:
|
||||
def raise_for_status(self): pass
|
||||
def json(self): return {"choices": [{"message": {"content": "ok"}}]}
|
||||
|
||||
res = asyncio.run(
|
||||
llm.call_ollama("prompt", thinking="boom", tag="think-flag", temperature=0.7)
|
||||
return FakeResp()
|
||||
|
||||
async def fake_client(*args, **kwargs):
|
||||
class Ctx:
|
||||
async def __aenter__(self2): return self2
|
||||
async def __aexit__(*a): pass
|
||||
post = fake_post
|
||||
return Ctx()
|
||||
|
||||
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
|
||||
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
|
||||
|
||||
result = asyncio.run(
|
||||
llm.call_ollama("prompt", model="custom-model")
|
||||
)
|
||||
assert res["content"] == "ok" and res["think"] == "boom"
|
||||
assert captured.get("think") == "boom"
|
||||
|
||||
assert captured["json"]["model"] == "custom-model"
|
||||
|
||||
|
||||
def test_call_ollama_cancelled_reraises(monkeypatch):
|
||||
async def fake_generate(**kwargs):
|
||||
raise asyncio.CancelledError
|
||||
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
asyncio.run(
|
||||
llm.call_ollama("prompt", system_prompt=None, tag="cancel", temperature=0.7)
|
||||
)
|
||||
|
||||
|
||||
def test_call_ollama_chat_raises_rethrows(monkeypatch):
|
||||
async def fake_generate(**kwargs):
|
||||
raise ValueError("boom")
|
||||
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
asyncio.run(
|
||||
llm.call_ollama("prompt", system_prompt=None, tag="exception", temperature=0.7)
|
||||
)
|
||||
|
||||
|
||||
def test_call_ollama_returns_content_and_think_from_response(monkeypatch):
|
||||
async def fake_generate(**kwargs):
|
||||
return {"response": "final", "message": {"thinking": "process"}}
|
||||
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
|
||||
res = asyncio.run(
|
||||
llm.call_ollama("prompt", system_prompt=None, tag="return", temperature=0.7)
|
||||
)
|
||||
assert res["content"] == "final" and res["think"] == "process"
|
||||
|
||||
|
||||
def test_stream_ollama_uses_requested_model_and_yields_chunks(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self, chunks):
|
||||
self._chunks = iter(chunks)
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(self._chunks)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration
|
||||
|
||||
async def fake_generate(**kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return FakeStream([
|
||||
{"response": "深度"},
|
||||
{"response": "回答"},
|
||||
def test_stream_ollama_events_error_handling(monkeypatch):
|
||||
def make_lines():
|
||||
lines_iter = iter([
|
||||
'data: {"error": "model not found"}',
|
||||
])
|
||||
|
||||
monkeypatch.setattr(llm.client, "generate", fake_generate)
|
||||
class LineIterator:
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(lines_iter)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration()
|
||||
|
||||
chunks = []
|
||||
class Response:
|
||||
def __init__(self2): self2._lines = LineIterator()
|
||||
|
||||
async def collect_stream():
|
||||
async for chunk in llm.stream_ollama(
|
||||
"prompt",
|
||||
system_prompt="system",
|
||||
tag="stream",
|
||||
temperature=0.8,
|
||||
model="pro-model",
|
||||
use_pro_model=True,
|
||||
):
|
||||
chunks.append(chunk)
|
||||
async def raise_for_status(self2): pass
|
||||
async def aiter_lines(self2): return self2._lines
|
||||
|
||||
asyncio.run(collect_stream())
|
||||
class StreamCtx:
|
||||
async def __aenter__(self2): return Response()
|
||||
async def __aexit__(*a): pass
|
||||
|
||||
assert "".join(chunks) == "深度回答"
|
||||
assert captured["kwargs"]["model"] == "pro-model"
|
||||
assert captured["kwargs"]["stream"] is True
|
||||
class Client:
|
||||
stream = lambda self2, *args, **kw: StreamCtx()
|
||||
|
||||
return Client()
|
||||
|
||||
async def fake_client(*args, **kwargs):
|
||||
return make_lines()
|
||||
|
||||
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
|
||||
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
|
||||
|
||||
async def collect():
|
||||
try:
|
||||
async for _ in llm.stream_ollama_events("prompt", tag="err"):
|
||||
pass
|
||||
except RuntimeError as e:
|
||||
return str(e)
|
||||
|
||||
result = asyncio.run(collect())
|
||||
assert "model not found" in str(result)
|
||||
|
||||
|
||||
def test_call_vlm_ocr_passes_image_and_prompt(monkeypatch):
|
||||
image_bytes = b"image-bytes"
|
||||
called = {}
|
||||
monkeypatch.setattr(llm, "get_vlm_ocr_prompt", lambda: "OCR PROMPT")
|
||||
def test_call_vlm_ocr_payload_format(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
called["kwargs"] = kwargs
|
||||
return {"message": {"content": "ocr result", "thinking": ""}}
|
||||
async def fake_post(url, json=None):
|
||||
captured["json"] = json
|
||||
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
class FakeResp:
|
||||
def raise_for_status(self): pass
|
||||
def json(self): return {"choices": [{"message": {"content": "ocr result"}}]}
|
||||
|
||||
result = asyncio.run(llm.call_vlm_ocr(image_bytes, language="auto"))
|
||||
return FakeResp()
|
||||
|
||||
messages = called["kwargs"].get("messages", [])
|
||||
assert messages[0]["role"] == "user"
|
||||
assert messages[0]["content"] == "OCR PROMPT"
|
||||
assert messages[0]["images"] == [image_bytes]
|
||||
async def fake_client(*args, **kwargs):
|
||||
class Ctx:
|
||||
async def __aenter__(self2): return self2
|
||||
async def __aexit__(*a): pass
|
||||
post = fake_post
|
||||
return Ctx()
|
||||
|
||||
monkeypatch.setattr(llm.httpx, "AsyncClient", fake_client)
|
||||
monkeypatch.setattr(llm.asyncio, "wait_for", lambda coro, **kw: coro)
|
||||
|
||||
result = asyncio.run(llm.call_vlm_ocr(b"image"))
|
||||
assert result == "ocr result"
|
||||
|
||||
|
||||
def test_call_vlm_ocr_chat_raises_rethrows(monkeypatch):
|
||||
image_bytes = b"image-bytes"
|
||||
monkeypatch.setattr(llm, "get_vlm_ocr_prompt", lambda: "OCR PROMPT")
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
raise RuntimeError("ocr fail")
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
with pytest.raises(RuntimeError):
|
||||
asyncio.run(llm.call_vlm_ocr(image_bytes))
|
||||
|
||||
|
||||
def test_call_vlm_ocr_returns_content_from_response(monkeypatch):
|
||||
image_bytes = b"img"
|
||||
monkeypatch.setattr(llm, "get_vlm_ocr_prompt", lambda: "OCR PROMPT")
|
||||
|
||||
async def fake_chat(**kwargs):
|
||||
return {"message": {"content": "ocr text", "thinking": ""}}
|
||||
monkeypatch.setattr(llm.client, "chat", fake_chat)
|
||||
content = asyncio.run(llm.call_vlm_ocr(image_bytes))
|
||||
assert content == "ocr text"
|
||||
# Verify vision format: image_url content part with base64
|
||||
msgs = captured["json"]["messages"]
|
||||
assert len(msgs) == 1
|
||||
content_parts = msgs[0]["content"]
|
||||
image_part = [p for p in content_parts if p.get("type") == "image_url"]
|
||||
assert len(image_part) == 1
|
||||
|
||||
@@ -17,6 +17,7 @@ if "tts_asr" not in sys.modules:
|
||||
sys.modules["tts_asr"] = fake_tts_asr
|
||||
|
||||
import main # type: ignore
|
||||
import pro_completions # type: ignore
|
||||
|
||||
API_KEY = main.API_KEY
|
||||
HEADERS = {"X-API-Key": API_KEY}
|
||||
@@ -25,8 +26,10 @@ HEADERS = {"X-API-Key": API_KEY}
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_active_completions():
|
||||
main.ACTIVE_COMPLETIONS.clear()
|
||||
pro_completions.PRO_STATES.clear()
|
||||
yield
|
||||
main.ACTIVE_COMPLETIONS.clear()
|
||||
pro_completions.PRO_STATES.clear()
|
||||
|
||||
|
||||
class DummyRequest:
|
||||
@@ -76,6 +79,33 @@ def test_sanitize_markdown_normalize_crlf():
|
||||
assert "\r" not in result
|
||||
|
||||
|
||||
def test_sanitize_inline_completion_strips_prefill():
|
||||
assert main.sanitize_inline_completion_content(
|
||||
"系统非常适合写作",
|
||||
prefill="系统",
|
||||
) == "非常适合写作"
|
||||
|
||||
|
||||
def test_sanitize_inline_completion_extracts_fim_middle():
|
||||
assert main.sanitize_inline_completion_content(
|
||||
"<|fim_middle|>系统非常适合写作<|end|>",
|
||||
prefill="系统",
|
||||
) == "非常适合写作"
|
||||
|
||||
|
||||
def test_sanitize_inline_completion_extracts_polluted_chat_output():
|
||||
polluted = (
|
||||
"on new line? Prefix ends with newline already. The suffix starts with no newline. "
|
||||
"We need to consider if output should end with newline? The suffix starts with no newline. "
|
||||
"So we output: \"让我们一起探索 AI 的无限可能。\""
|
||||
"<|end|><|start|>assistant<|channel|>final|fim_middle|>系统让我们一起探索 AI 的无限可能。"
|
||||
)
|
||||
assert main.sanitize_inline_completion_content(
|
||||
polluted,
|
||||
prefill="系统",
|
||||
) == "让我们一起探索 AI 的无限可能。"
|
||||
|
||||
|
||||
def test_get_client_ip_from_host():
|
||||
req = DummyRequest(host="1.2.3.4", headers={})
|
||||
assert main.get_client_ip(req) == "1.2.3.4"
|
||||
@@ -102,7 +132,10 @@ def test_post_completions_wrong_api_key_returns_401():
|
||||
|
||||
|
||||
def test_post_completions_privacy_mode(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_call(*args, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
return {"content": "done", "think": ""}
|
||||
monkeypatch.setattr(main, "call_ollama", fake_call)
|
||||
monkeypatch.setattr(main, "build_completion_prompts", lambda *a, **k: ("sys", "user"))
|
||||
@@ -116,39 +149,58 @@ def test_post_completions_privacy_mode(monkeypatch):
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data.get("content") == "done"
|
||||
# enable_thinking removed in OpenAI-compatible rewrite
|
||||
assert captured["kwargs"]["thinking"] == "low"
|
||||
|
||||
|
||||
def test_post_pro_stream_returns_sse(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_stream(*args, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
yield "深度"
|
||||
yield "回答"
|
||||
|
||||
monkeypatch.setattr(main, "stream_ollama", fake_stream)
|
||||
monkeypatch.setattr(main, "build_completion_prompts", lambda *a, **k: ("sys", "user"))
|
||||
monkeypatch.setattr(main, "prepare_prompt_context", lambda *a, **k: ("p", "s"))
|
||||
|
||||
def test_old_post_pro_stream_returns_404():
|
||||
client = TestClient(main.app)
|
||||
with client.stream("POST", "/v1/pro/completions/stream", headers=HEADERS, json={
|
||||
resp = client.post("/v1/pro/completions/stream", headers=HEADERS, json={
|
||||
"prefix": "hello",
|
||||
"suffix": "",
|
||||
"languageId": "markdown",
|
||||
"model_thinking": "high",
|
||||
"privacy_mode": True,
|
||||
"model": "pro-model",
|
||||
"temperature": 0.95,
|
||||
})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_post_pro_completion_returns_sse_and_status(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_stream_events(*args, **kwargs):
|
||||
captured["kwargs"] = kwargs
|
||||
yield "thinking", ""
|
||||
yield "content", "深度"
|
||||
yield "content", "回答"
|
||||
|
||||
monkeypatch.setattr(pro_completions, "stream_ollama_events", fake_stream_events)
|
||||
client = TestClient(main.app)
|
||||
with client.stream("POST", "/v1/pro/completions", headers=HEADERS, json={
|
||||
"prefix": "hello",
|
||||
"suffix": "",
|
||||
"languageId": "markdown",
|
||||
"instruction": "expand",
|
||||
"pro_thinking": "high",
|
||||
"privacy_mode": True,
|
||||
}) as resp:
|
||||
assert resp.status_code == 200
|
||||
body = "".join(resp.iter_text())
|
||||
|
||||
assert "event: queued" in body
|
||||
assert "event: started" in body
|
||||
assert "event: thinking" in body
|
||||
assert "event: chunk" in body
|
||||
assert "event: done" in body
|
||||
assert "深度" in body
|
||||
assert "回答" in body
|
||||
assert captured["kwargs"]["model"] == "pro-model"
|
||||
assert captured["kwargs"]["use_pro_model"] is True
|
||||
assert captured["kwargs"]["thinking"] == "high"
|
||||
|
||||
request_id = next(iter(pro_completions.PRO_STATES))
|
||||
status_resp = client.get(f"/v1/pro/completions/status/{request_id}", headers=HEADERS)
|
||||
assert status_resp.status_code == 200
|
||||
assert status_resp.json()["status"] == "done"
|
||||
assert main.ACTIVE_COMPLETIONS == {}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import asyncio
|
||||
import threading
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
BACKEND_DIR = os.path.abspath(os.path.join(CURRENT_DIR, ".."))
|
||||
if BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, BACKEND_DIR)
|
||||
|
||||
if "tts_asr" not in sys.modules:
|
||||
fake_tts_asr = types.ModuleType("tts_asr")
|
||||
fake_tts_asr.register_tts_asr_routes = lambda app: None
|
||||
sys.modules["tts_asr"] = fake_tts_asr
|
||||
|
||||
import main # type: ignore
|
||||
import pro_completions # type: ignore
|
||||
|
||||
|
||||
HEADERS = {"X-API-Key": main.API_KEY}
|
||||
|
||||
|
||||
def _payload():
|
||||
return {
|
||||
"prefix": "Before",
|
||||
"suffix": "After",
|
||||
"languageId": "markdown",
|
||||
"instruction": "expand",
|
||||
"pro_thinking": "medium",
|
||||
"privacy_mode": True,
|
||||
}
|
||||
|
||||
|
||||
def setup_function():
|
||||
pro_completions.PRO_STATES.clear()
|
||||
|
||||
|
||||
def teardown_function():
|
||||
pro_completions.PRO_STATES.clear()
|
||||
|
||||
|
||||
def test_pro_queue_full_returns_429(monkeypatch):
|
||||
monkeypatch.setattr(pro_completions, "PRO_QUEUE_MAX_SIZE", 0)
|
||||
client = TestClient(main.app)
|
||||
response = client.post("/v1/pro/completions", headers=HEADERS, json=_payload())
|
||||
assert response.status_code == 429
|
||||
assert response.json()["error"] == "PRO queue is full"
|
||||
|
||||
|
||||
def test_pro_status_missing_returns_404():
|
||||
client = TestClient(main.app)
|
||||
response = client.get("/v1/pro/completions/status/missing", headers=HEADERS)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_pro_prompt_uses_simple_chat_instruction():
|
||||
system_prompt, user_prompt = pro_completions._build_pro_prompts(
|
||||
prefix="欢迎使用 LLM-IN-TEXT\n\n即时可用的 LLM 系统",
|
||||
suffix="",
|
||||
language_id="markdown",
|
||||
instruction="",
|
||||
)
|
||||
combined = f"{system_prompt}\n{user_prompt}".lower()
|
||||
assert "pro block" not in combined
|
||||
assert "replacement" not in combined
|
||||
assert "final answer" not in combined
|
||||
assert "markdown before cursor" in combined
|
||||
assert "markdown after cursor" in combined
|
||||
assert "continue the markdown naturally" in combined
|
||||
|
||||
|
||||
def test_pro_cancel_waits_for_stream_cleanup(monkeypatch):
|
||||
started = threading.Event()
|
||||
cleaned = threading.Event()
|
||||
|
||||
async def fake_stream_events(*args, **kwargs):
|
||||
started.set()
|
||||
try:
|
||||
yield "thinking", ""
|
||||
while True:
|
||||
await asyncio.sleep(0.05)
|
||||
finally:
|
||||
cleaned.set()
|
||||
|
||||
monkeypatch.setattr(pro_completions, "stream_ollama_events", fake_stream_events)
|
||||
request_id = "pro-cancel-cleanup"
|
||||
headers = {**HEADERS, "X-Request-Id": request_id}
|
||||
response_box = {}
|
||||
|
||||
with TestClient(main.app) as client:
|
||||
def send_stream():
|
||||
with client.stream("POST", "/v1/pro/completions", headers=headers, json=_payload()) as response:
|
||||
response_box["status_code"] = response.status_code
|
||||
response_box["body"] = "".join(response.iter_text())
|
||||
|
||||
stream_thread = threading.Thread(target=send_stream, daemon=True)
|
||||
stream_thread.start()
|
||||
|
||||
assert started.wait(timeout=2.0)
|
||||
cancel_response = client.post(
|
||||
"/v1/pro/completions/cancel",
|
||||
headers=HEADERS,
|
||||
json={"request_id": request_id, "reason": "test"},
|
||||
)
|
||||
|
||||
assert cancel_response.status_code == 200
|
||||
assert cancel_response.json() == {"cancelled": True, "status": "ok"}
|
||||
assert cleaned.wait(timeout=2.0)
|
||||
|
||||
stream_thread.join(timeout=5.0)
|
||||
assert not stream_thread.is_alive()
|
||||
@@ -10,7 +10,7 @@ import prompt # noqa: E402
|
||||
|
||||
|
||||
def test_prompt_builds_system_and_user():
|
||||
system_prompt, user_prompt = prompt.build_completion_prompts(
|
||||
system_prompt, user_prompt, prefill = prompt.build_completion_prompts(
|
||||
prefix="The result is ",
|
||||
suffix="for this dataset.",
|
||||
language_id="markdown",
|
||||
@@ -29,14 +29,36 @@ def test_prompt_builds_system_and_user():
|
||||
assert "PREFIX_ENDS_WITH_NEWLINE" in user_prompt
|
||||
assert "SUFFIX_STARTS_WITH_NEWLINE" in user_prompt
|
||||
assert "actual line breaks" in system_prompt
|
||||
assert "start OUTPUT on a new line" in system_prompt
|
||||
assert "Use real line breaks instead of spelled-out escape sequences" in user_prompt
|
||||
assert "make the first character of OUTPUT a real newline" in user_prompt
|
||||
assert "make the last character of OUTPUT a real newline" in user_prompt
|
||||
assert "Do not explain newline or boundary choices" in user_prompt
|
||||
assert "Continue after the PREFILL text" in user_prompt
|
||||
assert "Step 1" not in user_prompt
|
||||
assert "Does output need" not in user_prompt
|
||||
assert "assistant" in system_prompt
|
||||
assert "fim_middle" in system_prompt
|
||||
assert prefill == ""
|
||||
assert "start output with \\n" not in user_prompt
|
||||
assert "Use single \\n" not in system_prompt
|
||||
|
||||
|
||||
def test_completion_prefill_appended_to_fim_middle():
|
||||
_, user_prompt, prefill = prompt.build_completion_prompts(
|
||||
prefix="即时可用的 LLM 系统",
|
||||
suffix="",
|
||||
)
|
||||
assert prefill == "系统"
|
||||
assert user_prompt.endswith("<|fim_middle|>系统")
|
||||
|
||||
|
||||
def test_completion_prefill_empty_after_newline():
|
||||
_, user_prompt, prefill = prompt.build_completion_prompts(
|
||||
prefix="即时可用的 LLM 系统\n",
|
||||
suffix="",
|
||||
)
|
||||
assert prefill == ""
|
||||
assert user_prompt.endswith("<|fim_middle|>")
|
||||
|
||||
|
||||
def test_cursor_in_fence_detection():
|
||||
assert prompt._cursor_in_fenced_code_block("") is False
|
||||
assert prompt._cursor_in_fenced_code_block("```python\nprint('x')\n") is True
|
||||
@@ -53,7 +75,7 @@ def test_active_fence_language_detection():
|
||||
|
||||
|
||||
def test_newline_flags():
|
||||
_, user_prompt_a = prompt.build_completion_prompts(
|
||||
_, user_prompt_a, _ = prompt.build_completion_prompts(
|
||||
prefix="Hello",
|
||||
suffix="World",
|
||||
)
|
||||
@@ -63,7 +85,7 @@ def test_newline_flags():
|
||||
assert "PREFIX_ENDS_WITH_NEWLINE: false" in user_prompt_a
|
||||
assert "SUFFIX_STARTS_WITH_NEWLINE: false" in user_prompt_a
|
||||
|
||||
_, user_prompt_b = prompt.build_completion_prompts(
|
||||
_, user_prompt_b, _ = prompt.build_completion_prompts(
|
||||
prefix="Hello\n",
|
||||
suffix="\nWorld",
|
||||
)
|
||||
@@ -73,7 +95,7 @@ def test_newline_flags():
|
||||
|
||||
|
||||
def test_mermaid_context_flags():
|
||||
_, prompt_in_mermaid = prompt.build_completion_prompts(
|
||||
_, prompt_in_mermaid, _ = prompt.build_completion_prompts(
|
||||
prefix="```mermaid\nflowchart TD\nA --> ",
|
||||
suffix="\n```",
|
||||
)
|
||||
@@ -81,7 +103,7 @@ def test_mermaid_context_flags():
|
||||
assert "CURSOR_FENCE_LANGUAGE: mermaid" in prompt_in_mermaid
|
||||
assert "MERMAID_CONTEXT: true" in prompt_in_mermaid
|
||||
|
||||
_, prompt_mermaid_keyword = prompt.build_completion_prompts(
|
||||
_, prompt_mermaid_keyword, _ = prompt.build_completion_prompts(
|
||||
prefix="Please draw a mermaid flowchart for deploy pipeline.",
|
||||
suffix="",
|
||||
)
|
||||
@@ -91,6 +113,6 @@ def test_mermaid_context_flags():
|
||||
|
||||
|
||||
def test_examples_coverage():
|
||||
_, user_prompt = prompt.build_completion_prompts(prefix="", suffix="")
|
||||
_, user_prompt, _ = prompt.build_completion_prompts(prefix="", suffix="")
|
||||
for ex in range(1, 15):
|
||||
assert f"[EX{ex:02d}]" in user_prompt
|
||||
|
||||
@@ -4,7 +4,9 @@ from pathlib import Path
|
||||
|
||||
# Ensure the project root is in sys.path so imports like `from backend import prompt` work
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BACKEND_DIR = ROOT / "backend"
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
from backend import prompt # type: ignore
|
||||
|
||||
@@ -120,22 +122,24 @@ def test_build_completion_prompts_with_userprefs():
|
||||
language = "python"
|
||||
currency = "USD"
|
||||
timezone = "UTC+0"
|
||||
system, user = prompt.build_completion_prompts(
|
||||
system, user, prefill = prompt.build_completion_prompts(
|
||||
prefix="hello", suffix="world", language_id="markdown",
|
||||
preferences=UserPrefs(),
|
||||
)
|
||||
assert isinstance(system, str)
|
||||
assert isinstance(user, str)
|
||||
assert prefill == "hello"
|
||||
assert "python" in user.lower() or "USD" in user
|
||||
|
||||
|
||||
def test_build_completion_prompts_privacy_mode_location_empty():
|
||||
system, user = prompt.build_completion_prompts(
|
||||
system, user, prefill = prompt.build_completion_prompts(
|
||||
prefix="hello", suffix="world", language_id="markdown",
|
||||
location="",
|
||||
)
|
||||
assert isinstance(system, str)
|
||||
assert isinstance(user, str)
|
||||
assert prefill == "hello"
|
||||
|
||||
|
||||
def test_build_prompt_backward_compatibility():
|
||||
|
||||
@@ -1,327 +1,193 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import asyncio
|
||||
import types
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
|
||||
def _make_torch_stub(cuda_avail=False, mps_avail=False):
|
||||
class DummyTensor:
|
||||
def __matmul__(self, other): return self
|
||||
def matmul(self, other): return self
|
||||
|
||||
stub = types.SimpleNamespace()
|
||||
stub.float32 = "float32"
|
||||
stub.float16 = "float16"
|
||||
stub.randn = lambda *a, **k: DummyTensor()
|
||||
stub.mm = lambda a, b: DummyTensor()
|
||||
stub.from_numpy = lambda arr: DummyTensor()
|
||||
stub.nn = types.SimpleNamespace()
|
||||
stub.nn.Linear = MagicMock(return_value=MagicMock())
|
||||
stub.nn.Module = type("Module", (), {})
|
||||
stub.no_grad = MagicMock()
|
||||
stub.no_grad.return_value.__enter__ = MagicMock(return_value=None)
|
||||
stub.no_grad.return_value.__exit__ = MagicMock(return_value=False)
|
||||
stub.backends = types.SimpleNamespace()
|
||||
stub.backends.mps = types.SimpleNamespace()
|
||||
stub.backends.mps.is_available = lambda: mps_avail
|
||||
stub.backends.mps.is_built = lambda: mps_avail
|
||||
stub.cuda = types.SimpleNamespace()
|
||||
stub.cuda.is_available = lambda: cuda_avail
|
||||
stub.cuda.device_count = lambda: 1 if cuda_avail else 0
|
||||
stub.cuda.get_device_properties = lambda n: types.SimpleNamespace(total_memory=8*1024*1024*1024)
|
||||
stub.cuda.empty_cache = lambda: None
|
||||
stub.mps = types.SimpleNamespace()
|
||||
stub.mps.is_available = lambda: mps_avail
|
||||
stub.mps.is_built = lambda: mps_avail
|
||||
stub.mps.empty_cache = lambda: None
|
||||
stub.device = lambda s: s
|
||||
stub.Tensor = MagicMock()
|
||||
return stub
|
||||
def _make_mlx_stub():
|
||||
"""Create minimal MLX stub for testing without Apple Silicon"""
|
||||
mlx = types.SimpleNamespace()
|
||||
mlx.core = types.SimpleNamespace()
|
||||
mx_array = type('mx.array', (), {'item': lambda self: 1})
|
||||
mlx.core.array = mx_array
|
||||
mlx.nn = types.SimpleNamespace()
|
||||
return mlx
|
||||
|
||||
|
||||
def _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device=None):
|
||||
def _make_mlx_audio_stub():
|
||||
"""Create minimal mlx-audio stub"""
|
||||
stt = types.SimpleNamespace()
|
||||
stt.utils = types.SimpleNamespace()
|
||||
|
||||
def mock_load(path, **kwargs):
|
||||
model = MagicMock()
|
||||
return model
|
||||
|
||||
stt.utils.load = mock_load # type: ignore
|
||||
|
||||
qwen3_asr_mod = types.SimpleNamespace()
|
||||
qwen3_asr_mod.Qwen3ASRModel = type('Qwen3ASRModel', (), {})
|
||||
qwen3_asr_mod.ForcedAlignerModel = type('ForcedAlignerModel', (), {})
|
||||
stt.models = types.SimpleNamespace() # type: ignore
|
||||
stt.models.qwen3_asr = qwen3_asr_mod # type: ignore
|
||||
|
||||
audio = types.SimpleNamespace()
|
||||
audio.stt = stt # type: ignore
|
||||
return audio
|
||||
|
||||
|
||||
def _reload_tts_asr_with_mocks():
|
||||
"""Reload tts_asr with mocked MLX dependencies"""
|
||||
for mod_name in list(sys.modules.keys()):
|
||||
if mod_name.startswith("tts_asr") or mod_name == "torch":
|
||||
if 'tts_asr' in mod_name or 'mlx' in mod_name:
|
||||
del sys.modules[mod_name]
|
||||
torch_stub = _make_torch_stub(cuda_avail=cuda_avail, mps_avail=mps_avail)
|
||||
sys.modules["torch"] = torch_stub
|
||||
if env_device is not None:
|
||||
os.environ["TTS_ASR_DEVICE"] = env_device
|
||||
elif "TTS_ASR_DEVICE" in os.environ:
|
||||
del os.environ["TTS_ASR_DEVICE"]
|
||||
|
||||
mlx_stub = _make_mlx_stub()
|
||||
sys.modules['mlx'] = mlx_stub # type: ignore
|
||||
sys.modules['mlx.core'] = mlx_stub.core # type: ignore
|
||||
sys.modules['mlx.nn'] = mlx_stub.nn # type: ignore
|
||||
|
||||
audio_stub = _make_mlx_audio_stub()
|
||||
sys.modules['mlx-audio'] = audio_stub # type: ignore
|
||||
sys.modules['mlx_audio'] = audio_stub # type: ignore
|
||||
sys.modules['mlx_audio.stt'] = audio_stub.stt # type: ignore
|
||||
sys.modules['mlx_audio.stt.utils'] = audio_stub.stt.utils # type: ignore
|
||||
sys.modules['mlx_audio.stt.models'] = audio_stub.stt.models # type: ignore
|
||||
sys.modules['mlx_audio.stt.models.qwen3_asr'] = audio_stub.stt.models.qwen3_asr # type: ignore
|
||||
|
||||
import tts_asr
|
||||
tts_asr._device_caps = None
|
||||
tts_asr._tts_pipeline = None
|
||||
tts_asr._asr_pipeline = None
|
||||
tts_asr._tts_last_used = 0
|
||||
tts_asr._asr_last_used = 0
|
||||
return tts_asr
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_tts_env():
|
||||
def _clean_env():
|
||||
"""Clean ASR-related env vars before/after each test"""
|
||||
saved = {}
|
||||
for k in ["TTS_ASR_DEVICE", "TTS_ASR_IDLE_TIMEOUT", "TTS_ASR_MODEL_SIZE",
|
||||
"TTS_ASR_QUANTIZE", "TTS_ASR_OFFLINE_MODE", "TTS_ASR_WARMUP",
|
||||
"TTS_ASR_MPS_MEMORY_LIMIT_MB"]:
|
||||
for k in ['HF_ENDPOINT']:
|
||||
saved[k] = os.environ.get(k)
|
||||
if k in os.environ:
|
||||
del os.environ[k]
|
||||
yield
|
||||
for k, v in saved.items():
|
||||
if v is not None:
|
||||
os.environ[k] = v
|
||||
elif k in os.environ:
|
||||
del os.environ[k]
|
||||
os.environ[k] = v # type: ignore (unused var)
|
||||
|
||||
|
||||
# --- Cache clearing ---
|
||||
def test_clear_cuda_cache():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cpu")
|
||||
tts._clear_cuda_cache()
|
||||
class TestRequestResponseModels:
|
||||
"""Pydantic 数据模型测试"""
|
||||
|
||||
def test_tts_request_defaults(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
req = tts.TTSRequest(text="hello")
|
||||
assert req.text == "hello"
|
||||
assert req.speaker == "Vivian"
|
||||
|
||||
def test_clear_mps_cache():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="cpu")
|
||||
tts._clear_mps_cache()
|
||||
def test_asr_request_defaults(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
req = tts.ASRRequest(audio_base64="dGVzdA==")
|
||||
assert req.audio_base64 == "dGVzdA=="
|
||||
assert req.language == "zh-CN"
|
||||
|
||||
def test_asr_request_custom_language(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
req = tts.ASRRequest(audio_base64="dGVzdA==", language="en")
|
||||
assert req.language == "en"
|
||||
|
||||
# --- Model cache check ---
|
||||
def test_check_model_cached_non_offline():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_OFFLINE_MODE"] = "false"
|
||||
import importlib
|
||||
importlib.reload(tts)
|
||||
assert tts._check_model_cached("openai/whisper-tiny") is True
|
||||
def test_model_status_defaults(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
status = tts.ModelStatus(tts_loaded=False, asr_loaded=True, device="cpu")
|
||||
assert not status.tts_loaded
|
||||
assert status.asr_loaded
|
||||
|
||||
|
||||
def test_check_model_cached_offline_mode():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_OFFLINE_MODE"] = "true"
|
||||
import importlib
|
||||
importlib.reload(tts)
|
||||
assert tts._check_model_cached("openai/whisper-tiny") is False
|
||||
class TestDeviceDetection:
|
||||
"""设备检测测试"""
|
||||
|
||||
def test_device_map_returns_string(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
device = tts._get_device_map()
|
||||
assert isinstance(device, str)
|
||||
|
||||
# --- Torch dtype ---
|
||||
def test_get_torch_dtype_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
|
||||
assert tts._get_torch_dtype() == "float32"
|
||||
|
||||
class TestModelLoading:
|
||||
"""模型加载测试"""
|
||||
|
||||
def test_get_torch_dtype_mps():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps")
|
||||
assert tts._get_torch_dtype() == "float32"
|
||||
def test_load_asr_skips_when_mlx_unavailable(self):
|
||||
"""mlx_audio 未安装时应跳过 ASR"""
|
||||
for mod_name in list(sys.modules.keys()):
|
||||
if 'tts_asr' in mod_name or 'mlx' in mod_name:
|
||||
del sys.modules[mod_name]
|
||||
|
||||
# Don't inject mlx stubs — simulate missing MLX
|
||||
import tts_asr # noqa: F811
|
||||
|
||||
def test_get_torch_dtype_cuda():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda")
|
||||
assert tts._get_torch_dtype() == "float16"
|
||||
assert tts_asr.Qwen3ASRModel is None
|
||||
tts_asr._load_asr_models() # should not crash
|
||||
assert tts_asr._asr_model is None
|
||||
|
||||
def test_load_asr_from_path_success(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
# Mock snapshot_download to return a path, mock stt_load to succeed
|
||||
with patch('backend.tts_asr.snapshot_download', return_value='/fake/path'): # type: ignore
|
||||
tts._load_asr_from_path('/fake/path')
|
||||
|
||||
# --- Device detection ---
|
||||
def test_get_device_cpu_env():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
|
||||
assert tts._get_device() == "cpu"
|
||||
assert tts._asr_model is not None # type: ignore (MagicMock)
|
||||
|
||||
|
||||
def test_get_device_mps_available():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps")
|
||||
assert tts._get_device() == "mps"
|
||||
class TestWarmupFunctions:
|
||||
"""预热函数测试"""
|
||||
|
||||
def test_warmup_functions_callable(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
assert callable(tts._warmup_tts) # type: ignore (unused var)
|
||||
assert callable(tts._warmup_all)
|
||||
|
||||
def test_get_device_mps_not_available_falls_back():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="mps")
|
||||
assert tts._get_device() == "cpu"
|
||||
def test_warmup_asr_skips_when_mlx_unavailable(self):
|
||||
for mod_name in list(sys.modules.keys()):
|
||||
if 'tts_asr' in mod_name or 'mlx' in mod_name:
|
||||
del sys.modules[mod_name]
|
||||
|
||||
import tts_asr # noqa: F811
|
||||
assert tts_asr.Qwen3ASRModel is None
|
||||
|
||||
def test_get_device_cuda_available():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda")
|
||||
assert tts._get_device() == "cuda"
|
||||
def test_warmup_all_runs_without_error(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
|
||||
# Set global models so warmup returns immediately without actual loading
|
||||
tts._tts_model = MagicMock()
|
||||
|
||||
def test_get_device_cuda_not_available_falls_back():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cuda")
|
||||
assert tts._get_device() == "cpu"
|
||||
async def run(): # type: ignore (unused var)
|
||||
await tts._warmup_all()
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(run()) # type: ignore
|
||||
|
||||
def test_get_device_auto_mps():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device=None)
|
||||
assert tts._get_device() == "mps"
|
||||
|
||||
class TestRouteRegistration:
|
||||
"""路由注册测试"""
|
||||
|
||||
def test_get_device_auto_cuda():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device=None)
|
||||
assert tts._get_device() == "cuda"
|
||||
def test_register_function_exists(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
assert callable(tts.register_tts_asr_routes)
|
||||
|
||||
def test_router_prefix(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
assert hasattr(tts.router, 'routes')
|
||||
|
||||
def test_get_device_auto_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device=None)
|
||||
assert tts._get_device() == "cpu"
|
||||
|
||||
class TestModelConstants:
|
||||
"""模型常量测试"""
|
||||
|
||||
def test_device_arg_cuda():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda")
|
||||
assert tts._device_arg() == "cuda:0"
|
||||
def test_asr_model_id(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
assert 'Qwen3-ASR' in tts.ASR_MODEL_ID_MS
|
||||
|
||||
|
||||
def test_device_arg_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
|
||||
assert tts._device_arg() == "cpu"
|
||||
|
||||
|
||||
def test_device_arg_mps():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps")
|
||||
assert tts._device_arg() == "mps"
|
||||
|
||||
|
||||
def test_test_device_capability_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
ok, err = tts._test_device_capability("cpu")
|
||||
assert ok is True
|
||||
assert err == ""
|
||||
|
||||
|
||||
def test_test_device_capability_mps_not_available():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
ok, err = tts._test_device_capability("mps")
|
||||
assert ok is False
|
||||
assert len(err) > 0
|
||||
|
||||
|
||||
def test_test_device_capability_cuda_not_available():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
ok, err = tts._test_device_capability("cuda")
|
||||
assert ok is False
|
||||
assert len(err) > 0
|
||||
|
||||
|
||||
def test_test_device_capability_unknown_device():
|
||||
tts = _reload_tts_asr()
|
||||
ok, err = tts._test_device_capability("vulkan")
|
||||
assert ok is False
|
||||
assert len(err) > 0
|
||||
|
||||
|
||||
# --- Idle model unload ---
|
||||
def test_check_and_unload_idle_models_timeout_zero():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "0"
|
||||
tts._tts_pipeline = "pipeline"
|
||||
tts._asr_pipeline = "pipeline"
|
||||
tts._tts_last_used = time.time()
|
||||
tts._asr_last_used = time.time()
|
||||
tts._check_and_unload_idle_models()
|
||||
assert tts._tts_pipeline == "pipeline"
|
||||
|
||||
|
||||
def test_check_and_unload_idle_models_unloads_when_expired():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "1"
|
||||
tts._tts_pipeline = "pipeline"
|
||||
tts._asr_pipeline = "pipeline"
|
||||
tts._tts_last_used = time.time() - 10
|
||||
tts._asr_last_used = time.time() - 10
|
||||
import importlib
|
||||
importlib.reload(tts)
|
||||
tts._check_and_unload_idle_models()
|
||||
assert True # Function executed without error
|
||||
|
||||
|
||||
def test_check_and_unload_idle_models_keeps_when_not_expired():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "60"
|
||||
tts._tts_pipeline = "pipeline"
|
||||
tts._asr_pipeline = "pipeline"
|
||||
tts._tts_last_used = time.time()
|
||||
tts._asr_last_used = time.time()
|
||||
tts._check_and_unload_idle_models()
|
||||
assert tts._tts_pipeline == "pipeline"
|
||||
|
||||
|
||||
# --- API key ---
|
||||
def test_get_api_key_success():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
key = tts.get_api_key("your-secret-key-here")
|
||||
assert key == "your-secret-key-here"
|
||||
|
||||
|
||||
def test_get_api_key_wrong_key_raises():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
with pytest.raises(Exception):
|
||||
tts.get_api_key("wrong-key")
|
||||
|
||||
|
||||
def test_get_api_key_missing_key_raises():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
with pytest.raises(Exception):
|
||||
tts.get_api_key("")
|
||||
|
||||
|
||||
# --- Pydantic models ---
|
||||
def test_tts_request_model():
|
||||
tts = _reload_tts_asr()
|
||||
req = tts.TTSRequest(text="hello")
|
||||
assert req.text == "hello"
|
||||
assert req.voice == "af_bella"
|
||||
assert req.rate == 1.0
|
||||
assert req.format == "wav"
|
||||
|
||||
|
||||
def test_asr_request_model():
|
||||
tts = _reload_tts_asr()
|
||||
req = tts.ASRRequest(audio_base64="base64data", language="zh")
|
||||
assert req.audio_base64 == "base64data"
|
||||
assert req.language == "zh"
|
||||
|
||||
|
||||
# --- Device capabilities ---
|
||||
def test_detect_device_capabilities_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
caps = tts._detect_device_capabilities()
|
||||
assert caps.device == "cpu"
|
||||
assert caps.mps_available is False
|
||||
assert caps.cuda_available is False
|
||||
|
||||
|
||||
def test_detect_device_capabilities_mps():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True)
|
||||
caps = tts._detect_device_capabilities()
|
||||
assert caps.device == "mps"
|
||||
assert caps.mps_available is True
|
||||
|
||||
|
||||
def test_detect_device_capabilities_cuda():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False)
|
||||
caps = tts._detect_device_capabilities()
|
||||
assert caps.device == "cuda"
|
||||
assert caps.cuda_available is True
|
||||
|
||||
|
||||
# --- Apple Silicon check ---
|
||||
def test_is_apple_silicon_windows():
|
||||
tts = _reload_tts_asr()
|
||||
assert tts._is_apple_silicon() is False
|
||||
|
||||
|
||||
# --- Model size ---
|
||||
def test_recommended_model_size_auto():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
|
||||
size = tts._get_recommended_model_size()
|
||||
assert size in tts.WHISPER_MODEL_SIZES or size == "auto"
|
||||
|
||||
|
||||
def test_recommended_model_size_explicit():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_MODEL_SIZE"] = "tiny"
|
||||
import importlib
|
||||
importlib.reload(tts)
|
||||
size = tts._get_recommended_model_size()
|
||||
assert size == "tiny"
|
||||
def test_align_model_id(self):
|
||||
tts = _reload_tts_asr_with_mocks()
|
||||
assert 'ForcedAligner' in tts.ALIGN_MODEL_ID_MS
|
||||
|
||||
@@ -1,231 +1,263 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import base64
|
||||
import io
|
||||
import types
|
||||
import wave
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
import numpy as np
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
|
||||
def _make_torch_stub(cuda_avail=False, mps_avail=False):
|
||||
class DummyTensor:
|
||||
def __matmul__(self, other):
|
||||
return self
|
||||
def matmul(self, other):
|
||||
return self
|
||||
def _make_mlx_stub():
|
||||
"""Create minimal MLX stub for testing without Apple Silicon"""
|
||||
mlx = types.SimpleNamespace()
|
||||
mlx.core = types.SimpleNamespace()
|
||||
mx_array = type('mx.array', (), {'item': lambda self: 1})
|
||||
mlx.core.array = mx_array
|
||||
|
||||
def dummy_randn(*args, **kwargs):
|
||||
return DummyTensor()
|
||||
def dummy_mm(a, b):
|
||||
return DummyTensor()
|
||||
def dummy_from_numpy(arr):
|
||||
return DummyTensor()
|
||||
def mock_load(path):
|
||||
return MagicMock()
|
||||
mlx.core.load = mock_load # type: ignore
|
||||
|
||||
stub = types.SimpleNamespace()
|
||||
stub.float32 = "float32"
|
||||
stub.float16 = "float16"
|
||||
stub.randn = dummy_randn
|
||||
stub.mm = dummy_mm
|
||||
stub.from_numpy = dummy_from_numpy
|
||||
|
||||
stub.backends = types.SimpleNamespace()
|
||||
stub.backends.mps = types.SimpleNamespace()
|
||||
stub.backends.mps.is_available = lambda: mps_avail
|
||||
stub.backends.mps.is_built = lambda: mps_avail
|
||||
|
||||
stub.cuda = types.SimpleNamespace()
|
||||
stub.cuda.is_available = lambda: cuda_avail
|
||||
stub.cuda.device_count = lambda: 1 if cuda_avail else 0
|
||||
stub.cuda.get_device_properties = lambda n: types.SimpleNamespace(total_memory=8*1024*1024*1024)
|
||||
stub.cuda.empty_cache = lambda: None
|
||||
|
||||
stub.mps = types.SimpleNamespace()
|
||||
stub.mps.is_available = lambda: mps_avail
|
||||
stub.mps.is_built = lambda: mps_avail
|
||||
stub.mps.empty_cache = lambda: None
|
||||
|
||||
return stub
|
||||
mlx.nn = types.SimpleNamespace()
|
||||
return mlx
|
||||
|
||||
|
||||
def _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device=None):
|
||||
def _make_mlx_audio_stub():
|
||||
"""Create minimal mlx-audio stub"""
|
||||
stt = types.SimpleNamespace()
|
||||
stt.utils = types.SimpleNamespace()
|
||||
|
||||
def mock_load(path): # type: ignore
|
||||
model = MagicMock()
|
||||
output = types.SimpleNamespace()
|
||||
output.text = "识别结果"
|
||||
output.language = "zh-CN"
|
||||
model.generate = MagicMock(return_value=output)
|
||||
return model
|
||||
|
||||
stt.utils.load = mock_load # type: ignore
|
||||
|
||||
qwen3_asr_mod = types.SimpleNamespace()
|
||||
qwen3_asr_mod.Qwen3ASRModel = type('Qwen3ASRModel', (), {})
|
||||
qwen3_asr_mod.ForcedAlignerModel = type('ForcedAlignerModel', (), {})
|
||||
stt.models = types.SimpleNamespace() # type: ignore
|
||||
stt.models.qwen3_asr = qwen3_asr_mod # type: ignore
|
||||
|
||||
audio = types.SimpleNamespace()
|
||||
audio.stt = stt # type: ignore
|
||||
return audio
|
||||
|
||||
|
||||
def _reload_tts_asr_with_mocks():
|
||||
"""Reload tts_asr with mocked MLX dependencies"""
|
||||
for mod_name in list(sys.modules.keys()):
|
||||
if mod_name.startswith("tts_asr") or mod_name == "torch":
|
||||
if 'tts_asr' in mod_name or 'mlx' in mod_name:
|
||||
del sys.modules[mod_name]
|
||||
|
||||
torch_stub = _make_torch_stub(cuda_avail=cuda_avail, mps_avail=mps_avail)
|
||||
sys.modules["torch"] = torch_stub
|
||||
mlx_stub = _make_mlx_stub()
|
||||
sys.modules['mlx'] = mlx_stub # type: ignore
|
||||
sys.modules['mlx.core'] = mlx_stub.core # type: ignore
|
||||
sys.modules['mlx.nn'] = mlx_stub.nn # type: ignore
|
||||
|
||||
if env_device is not None:
|
||||
os.environ["TTS_ASR_DEVICE"] = env_device
|
||||
elif "TTS_ASR_DEVICE" in os.environ:
|
||||
del os.environ["TTS_ASR_DEVICE"]
|
||||
audio_stub = _make_mlx_audio_stub()
|
||||
sys.modules['mlx-audio'] = audio_stub # type: ignore
|
||||
sys.modules['mlx_audio'] = audio_stub # type: ignore
|
||||
sys.modules['mlx_audio.stt'] = audio_stub.stt # type: ignore
|
||||
sys.modules['mlx_audio.stt.utils'] = audio_stub.stt.utils # type: ignore
|
||||
sys.modules['mlx_audio.stt.models'] = audio_stub.stt.models # type: ignore
|
||||
sys.modules['mlx_audio.stt.models.qwen3_asr'] = audio_stub.stt.models.qwen3_asr # type: ignore
|
||||
|
||||
import tts_asr
|
||||
tts_asr._device_caps = None
|
||||
tts_asr._tts_pipeline = None
|
||||
tts_asr._asr_pipeline = None
|
||||
tts_asr._tts_last_used = 0
|
||||
tts_asr._asr_last_used = 0
|
||||
|
||||
return tts_asr
|
||||
return tts_asr, audio_stub
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env():
|
||||
"""Clean ASR-related env vars before/after each test"""
|
||||
saved = {}
|
||||
for k in ["TTS_ASR_DEVICE", "TTS_ASR_IDLE_TIMEOUT", "TTS_ASR_MODEL_SIZE",
|
||||
"TTS_ASR_QUANTIZE", "TTS_ASR_OFFLINE_MODE", "TTS_ASR_WARMUP",
|
||||
"TTS_ASR_MPS_MEMORY_LIMIT_MB"]:
|
||||
for k in ['HF_ENDPOINT']:
|
||||
saved[k] = os.environ.get(k)
|
||||
if k in os.environ:
|
||||
del os.environ[k]
|
||||
yield
|
||||
for k, v in saved.items():
|
||||
if v is not None:
|
||||
os.environ[k] = v
|
||||
elif k in os.environ:
|
||||
del os.environ[k]
|
||||
os.environ[k] = v # type: ignore
|
||||
|
||||
|
||||
def test_get_device_cpu_env():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
|
||||
assert tts._get_device() == "cpu"
|
||||
def _make_wav_bytes(sr=16000, duration_sec=1.0, channels=1):
|
||||
"""Helper: generate WAV bytes as base64"""
|
||||
samples = int(sr * duration_sec)
|
||||
audio = np.random.randint(-32768, 32767, size=samples * channels, dtype=np.int16)
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, 'wb') as wf:
|
||||
wf.setnchannels(channels)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sr)
|
||||
wf.writeframes(audio.tobytes())
|
||||
return base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def test_get_device_mps_available():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps")
|
||||
assert tts._get_device() == "mps"
|
||||
class TestASRLazyLoading:
|
||||
"""测试 ASR 模型懒加载"""
|
||||
|
||||
def test_ensure_asr_loads_on_call(self):
|
||||
tts, audio_stub = _reload_tts_asr_with_mocks()
|
||||
assert tts._asr_model is None
|
||||
|
||||
model = tts._ensure_asr_model()
|
||||
assert model is not None
|
||||
|
||||
def test_ensure_align_loads_on_call(self):
|
||||
tts, audio_stub = _reload_tts_asr_with_mocks()
|
||||
assert tts._align_model is None
|
||||
|
||||
model = tts._ensure_align_model()
|
||||
assert model is not None
|
||||
|
||||
|
||||
def test_get_device_mps_not_available_falls_back():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="mps")
|
||||
assert tts._get_device() == "cpu"
|
||||
class TestASREndpoint:
|
||||
"""测试 ASR 端点逻辑"""
|
||||
|
||||
def test_asr_basic_recognition(self, fastapi_testclient=None):
|
||||
"""ASR 端点应正确返回识别结果"""
|
||||
tts, _ = _reload_tts_asr_with_mocks()
|
||||
|
||||
# Mock the model to return known values
|
||||
tts._asr_model = MagicMock()
|
||||
output = types.SimpleNamespace()
|
||||
output.text = "你好世界"
|
||||
output.language = "zh-CN"
|
||||
tts._asr_model.generate.return_value = output
|
||||
|
||||
wav_b64 = _make_wav_bytes()
|
||||
req = tts.ASRRequest(audio_base64=wav_b64)
|
||||
|
||||
# Call generate directly (simulating endpoint logic)
|
||||
audio_bytes = base64.b64decode(req.audio_base64)
|
||||
wav_buffer = io.BytesIO(audio_bytes)
|
||||
with wave.open(wav_buffer, 'rb') as wf:
|
||||
raw = wf.readframes(wf.getnframes())
|
||||
arr = np.frombuffer(raw, dtype=np.int16)
|
||||
arr = arr.astype(np.float32) / 32768.0
|
||||
|
||||
result = tts._asr_model.generate(arr, language=req.language)
|
||||
assert result.text == "你好世界"
|
||||
|
||||
def test_asr_stereo_to_mono(self):
|
||||
"""立体声音频应被正确转换为单声道"""
|
||||
wav_b64 = _make_wav_bytes(channels=2)
|
||||
|
||||
audio_bytes = base64.b64decode(wav_b64)
|
||||
wav_buffer = io.BytesIO(audio_bytes)
|
||||
with wave.open(wav_buffer, 'rb') as wf:
|
||||
assert wf.getnchannels() == 2
|
||||
n_frames = wf.getnframes()
|
||||
raw_data = wf.readframes(n_frames)
|
||||
audio_array = np.frombuffer(raw_data, dtype=np.int16)
|
||||
|
||||
# Convert to mono
|
||||
audio_array = np.mean(audio_array.reshape(-1, 2), axis=1)
|
||||
assert audio_array.ndim == 1
|
||||
|
||||
def test_asr_resample_to_16k(self):
|
||||
"""非 16kHz 音频应被重采样"""
|
||||
wav_b64 = _make_wav_bytes(sr=48000, duration_sec=0.5)
|
||||
|
||||
audio_bytes = base64.b64decode(wav_b64)
|
||||
wav_buffer = io.BytesIO(audio_bytes)
|
||||
with wave.open(wav_buffer, 'rb') as wf:
|
||||
assert wf.getframerate() == 48000
|
||||
|
||||
def test_asr_44100_resample(self):
|
||||
"""44.1kHz 常见采样率应被重采样到 16k"""
|
||||
wav_b64 = _make_wav_bytes(sr=44100, duration_sec=1.0)
|
||||
|
||||
audio_bytes = base64.b64decode(wav_b64)
|
||||
wav_buffer = io.BytesIO(audio_bytes)
|
||||
with wave.open(wav_buffer, 'rb') as wf:
|
||||
framerate = wf.getframerate()
|
||||
n_frames = wf.getnframes()
|
||||
raw_data = wf.readframes(n_frames)
|
||||
audio_array = np.frombuffer(raw_data, dtype=np.int16)
|
||||
|
||||
# Simulate resample calculation
|
||||
if framerate != 16000:
|
||||
n_samples = int(len(audio_array) * 16000 / framerate)
|
||||
else:
|
||||
n_samples = len(audio_array)
|
||||
|
||||
expected_16k_samples = int(1.0 * 16000)
|
||||
assert abs(n_samples - expected_16k_samples) < 2
|
||||
|
||||
|
||||
def test_get_device_cuda_available():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda")
|
||||
assert tts._get_device() == "cuda"
|
||||
class TestASRModelDownload:
|
||||
"""测试 ASR 模型下载路径"""
|
||||
|
||||
def test_load_asr_from_path_success(self):
|
||||
tts, _ = _reload_tts_asr_with_mocks()
|
||||
|
||||
with patch('backend.tts_asr.snapshot_download', return_value='/fake/asr'): # type: ignore
|
||||
tts._load_asr_models()
|
||||
|
||||
assert tts._asr_model is not None
|
||||
|
||||
def test_load_asr_skips_without_mlx(self):
|
||||
"""不注入 MLX stub 时应跳过 ASR"""
|
||||
for mod_name in list(sys.modules.keys()):
|
||||
if 'tts_asr' in mod_name or 'mlx' in mod_name:
|
||||
del sys.modules[mod_name]
|
||||
|
||||
import tts_asr # noqa: F811
|
||||
assert tts_asr.Qwen3ASRModel is None
|
||||
|
||||
def test_load_align_from_path(self):
|
||||
tts, _ = _reload_tts_asr_with_mocks()
|
||||
|
||||
with patch('backend.tts_asr.snapshot_download', return_value='/fake/align'): # type: ignore
|
||||
tts._load_asr_models()
|
||||
|
||||
assert tts._align_model is not None
|
||||
|
||||
|
||||
def test_get_device_cuda_not_available_falls_back():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cuda")
|
||||
assert tts._get_device() == "cpu"
|
||||
class TestModelConstants:
|
||||
"""测试模型 ID 常量"""
|
||||
|
||||
def test_asr_model_id(self):
|
||||
tts, _ = _reload_tts_asr_with_mocks()
|
||||
assert "aufklarer" in tts.ASR_MODEL_ID_MS
|
||||
|
||||
def test_align_model_id(self):
|
||||
tts, _ = _reload_tts_asr_with_mocks()
|
||||
assert "ForcedAligner" in tts.ALIGN_MODEL_ID_MS
|
||||
|
||||
def test_tts_model_id(self):
|
||||
tts, _ = _reload_tts_asr_with_mocks()
|
||||
assert "Qwen3-TTS" in tts.MODEL_ID_MS
|
||||
|
||||
|
||||
def test_get_device_auto_mps():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device=None)
|
||||
assert tts._get_device() == "mps"
|
||||
class TestHFEndpointMirror:
|
||||
"""测试镜像站配置"""
|
||||
|
||||
def test_hf_endpoint_set(self):
|
||||
tts, _ = _reload_tts_asr_with_mocks()
|
||||
assert os.environ.get("HF_ENDPOINT") == "https://hf-mirror.com"
|
||||
|
||||
def test_get_device_auto_cuda():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device=None)
|
||||
assert tts._get_device() == "cuda"
|
||||
def test_hf_endpoint_default(self):
|
||||
"""即使环境变量未设置,模块也应默认设置镜像"""
|
||||
for mod_name in list(sys.modules.keys()):
|
||||
if 'tts_asr' in mod_name or 'mlx' in mod_name:
|
||||
del sys.modules[mod_name]
|
||||
|
||||
if "HF_ENDPOINT" in os.environ:
|
||||
del os.environ["HF_ENDPOINT"]
|
||||
|
||||
def test_get_device_auto_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device=None)
|
||||
assert tts._get_device() == "cpu"
|
||||
|
||||
|
||||
def test_device_arg_cuda():
|
||||
tts = _reload_tts_asr(cuda_avail=True, mps_avail=False, env_device="cuda")
|
||||
assert tts._device_arg() == "cuda:0"
|
||||
|
||||
|
||||
def test_device_arg_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False, env_device="cpu")
|
||||
assert tts._device_arg() == "cpu"
|
||||
|
||||
|
||||
def test_device_arg_mps():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=True, env_device="mps")
|
||||
assert tts._device_arg() == "mps"
|
||||
|
||||
|
||||
def test_test_device_capability_cpu():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
ok, err = tts._test_device_capability("cpu")
|
||||
assert ok is True
|
||||
assert err == ""
|
||||
|
||||
|
||||
def test_test_device_capability_mps_not_available():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
ok, err = tts._test_device_capability("mps")
|
||||
assert ok is False
|
||||
assert isinstance(err, str) and len(err) > 0
|
||||
|
||||
|
||||
def test_test_device_capability_cuda_not_available():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
ok, err = tts._test_device_capability("cuda")
|
||||
assert ok is False
|
||||
assert isinstance(err, str) and len(err) > 0
|
||||
|
||||
|
||||
def test_test_device_capability_unknown_device():
|
||||
tts = _reload_tts_asr()
|
||||
ok, err = tts._test_device_capability("vulkan")
|
||||
assert ok is False
|
||||
assert isinstance(err, str)
|
||||
|
||||
|
||||
def test_check_and_unload_idle_models_timeout_zero():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "0"
|
||||
tts._tts_pipeline = "pipeline"
|
||||
tts._asr_pipeline = "pipeline"
|
||||
tts._tts_last_used = time.time()
|
||||
tts._asr_last_used = time.time()
|
||||
tts._check_and_unload_idle_models()
|
||||
assert tts._tts_pipeline == "pipeline"
|
||||
assert tts._asr_pipeline == "pipeline"
|
||||
|
||||
|
||||
def test_check_and_unload_idle_models_unloads_when_expired():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "1"
|
||||
tts._tts_pipeline = "pipeline"
|
||||
tts._asr_pipeline = "pipeline"
|
||||
tts._tts_last_used = time.time() - 10
|
||||
tts._asr_last_used = time.time() - 10
|
||||
# Force re-read of env var
|
||||
import importlib
|
||||
importlib.reload(tts)
|
||||
tts._check_and_unload_idle_models()
|
||||
# The module reload may reset state, so we test the logic directly
|
||||
# by checking that the function runs without error
|
||||
assert True # Function executed successfully
|
||||
|
||||
|
||||
def test_check_and_unload_idle_models_keeps_when_not_expired():
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
os.environ["TTS_ASR_IDLE_TIMEOUT"] = "60"
|
||||
tts._tts_pipeline = "pipeline"
|
||||
tts._asr_pipeline = "pipeline"
|
||||
tts._tts_last_used = time.time()
|
||||
tts._asr_last_used = time.time()
|
||||
tts._check_and_unload_idle_models()
|
||||
assert tts._tts_pipeline == "pipeline"
|
||||
assert tts._asr_pipeline == "pipeline"
|
||||
|
||||
|
||||
def test_get_api_key_success(monkeypatch):
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
key = tts.get_api_key("your-secret-key-here")
|
||||
assert key == "your-secret-key-here"
|
||||
|
||||
|
||||
def test_get_api_key_wrong_key_raises(monkeypatch):
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
with pytest.raises(Exception):
|
||||
tts.get_api_key("wrong-key")
|
||||
|
||||
|
||||
def test_get_api_key_missing_key_raises(monkeypatch):
|
||||
tts = _reload_tts_asr(cuda_avail=False, mps_avail=False)
|
||||
with pytest.raises(Exception):
|
||||
tts.get_api_key("")
|
||||
import tts_asr # noqa: F811
|
||||
assert os.environ.get("HF_ENDPOINT") == "https://hf-mirror.com"
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
TTS/ASR模块集成测试
|
||||
TTS/ASR模块集成测试 — MLX/Qwen3-ASR 版本
|
||||
测试API端点和完整流程(需要运行后端服务)
|
||||
|
||||
运行方式:
|
||||
# 方式1: 使用pytest
|
||||
pytest backend/tests/test_tts_asr_integration.py -v -s
|
||||
|
||||
# 方式2: 直接运行
|
||||
python backend/tests/test_tts_asr_integration.py
|
||||
|
||||
# 方式3: 测试特定端点
|
||||
python backend/tests/test_tts_asr_integration.py --test config
|
||||
python backend/tests/test_tts_asr_integration.py --test asr
|
||||
|
||||
MLX 模型通过 ModelScope (aufklarer/Qwen3-ASR) + ForcedAligner
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from typing import Optional
|
||||
import httpx
|
||||
|
||||
# 配置
|
||||
try:
|
||||
import httpx # type: ignore
|
||||
except ImportError:
|
||||
print("httpx 未安装,跳过集成测试")
|
||||
sys.exit(1)
|
||||
|
||||
import numpy as np
|
||||
|
||||
API_BASE_URL = os.environ.get('API_BASE_URL', 'http://localhost:8001')
|
||||
API_KEY = os.environ.get('API_KEY', 'your-secret-key-here')
|
||||
TEST_TIMEOUT = 120.0 # 2分钟超时
|
||||
TEST_TIMEOUT = 120.0
|
||||
|
||||
|
||||
class TTSASRIntegrationTest(unittest.TestCase):
|
||||
@@ -34,11 +38,9 @@ class TTSASRIntegrationTest(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""测试类初始化"""
|
||||
cls.client = httpx.Client(timeout=TEST_TIMEOUT)
|
||||
cls.headers = {'X-API-Key': API_KEY}
|
||||
|
||||
# 检查服务是否运行
|
||||
|
||||
try:
|
||||
response = cls.client.get(f'{API_BASE_URL}/v1/tts-asr/status', headers=cls.headers)
|
||||
if response.status_code == 200:
|
||||
@@ -47,18 +49,15 @@ class TTSASRIntegrationTest(unittest.TestCase):
|
||||
else:
|
||||
cls.service_available = False
|
||||
print(f"\n✗ 服务返回非200状态码: {response.status_code}")
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: ANN001
|
||||
cls.service_available = False
|
||||
print(f"\n✗ 无法连接到服务: {e}")
|
||||
print(f" 请确保后端服务正在运行: python backend/main.py")
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""测试类清理"""
|
||||
cls.client.close()
|
||||
|
||||
def setUp(self):
|
||||
"""每个测试前的检查"""
|
||||
if not self.service_available:
|
||||
self.skipTest("后端服务不可用")
|
||||
|
||||
@@ -68,40 +67,24 @@ class TTSASRIntegrationTest(unittest.TestCase):
|
||||
f'{API_BASE_URL}/v1/tts-asr/config',
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
config = response.json()
|
||||
|
||||
# 验证配置结构
|
||||
self.assertIn('environment', config)
|
||||
|
||||
self.assertIn('device', config)
|
||||
self.assertIn('model', config)
|
||||
self.assertIn('status', config)
|
||||
|
||||
# 验证环境变量配置
|
||||
env = config['environment']
|
||||
self.assertIn('TTS_ASR_DEVICE', env)
|
||||
self.assertIn('TTS_ASR_MODEL_SIZE', env)
|
||||
self.assertIn('TTS_ASR_QUANTIZE', env)
|
||||
|
||||
# 验证设备信息
|
||||
device = config['device']
|
||||
self.assertIn('current', device)
|
||||
self.assertIn('mps_available', device)
|
||||
self.assertIn('cuda_available', device)
|
||||
self.assertIn('is_apple_silicon', device)
|
||||
|
||||
# 验证模型信息
|
||||
|
||||
model = config['model']
|
||||
status = config['status']
|
||||
self.assertIn('tts', model)
|
||||
self.assertIn('asr_current_size', model)
|
||||
self.assertIn('available_sizes', model)
|
||||
|
||||
self.assertIn('asr', model)
|
||||
|
||||
print(f"\n配置信息:")
|
||||
print(f" 设备: {device['current']}")
|
||||
print(f" Apple Silicon: {device['is_apple_silicon']}")
|
||||
print(f" MPS可用: {device['mps_available']}")
|
||||
print(f" ASR模型大小: {model['asr_current_size']}")
|
||||
print(f" TTS模型: {model['tts']}")
|
||||
print(f" ASR模型: {model.get('asr', 'N/A')}")
|
||||
print(f" TTS已加载: {status['tts_loaded']}")
|
||||
print(f" ASR已加载: {status['asr_loaded']}")
|
||||
|
||||
def test_02_status_endpoint(self):
|
||||
"""测试状态端点"""
|
||||
@@ -109,118 +92,91 @@ class TTSASRIntegrationTest(unittest.TestCase):
|
||||
f'{API_BASE_URL}/v1/tts-asr/status',
|
||||
headers=self.headers
|
||||
)
|
||||
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
status = response.json()
|
||||
|
||||
# 验证状态结构
|
||||
|
||||
self.assertIn('tts_loaded', status)
|
||||
self.assertIn('asr_loaded', status)
|
||||
self.assertIn('device', status)
|
||||
self.assertIn('offline_mode', status)
|
||||
self.assertIn('quantize_enabled', status)
|
||||
|
||||
|
||||
print(f"\n状态信息:")
|
||||
print(f" TTS已加载: {status['tts_loaded']}")
|
||||
print(f" ASR已加载: {status['asr_loaded']}")
|
||||
print(f" 设备: {status['device']}")
|
||||
print(f" 离线模式: {status['offline_mode']}")
|
||||
print(f" 量化启用: {status['quantize_enabled']}")
|
||||
|
||||
def test_03_warmup_endpoint(self):
|
||||
"""测试预热端点"""
|
||||
print("\n开始模型预热(可能需要几分钟)...")
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
response = self.client.post(
|
||||
f'{API_BASE_URL}/v1/tts-asr/warmup',
|
||||
headers=self.headers
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
result = response.json()
|
||||
|
||||
|
||||
self.assertIn('tts_warmup', result)
|
||||
self.assertIn('asr_warmup', result)
|
||||
self.assertIn('device', result)
|
||||
|
||||
|
||||
print(f"\n预热完成 (耗时: {elapsed:.2f}秒):")
|
||||
print(f" TTS预热: {'成功' if result['tts_warmup'] else '失败'}")
|
||||
print(f" ASR预热: {'成功' if result['asr_warmup'] else '失败'}")
|
||||
|
||||
# 警告:预热失败不一定是错误(可能模型未下载)
|
||||
if not result['tts_warmup'] or not result['asr_warmup']:
|
||||
print(f" ASR预热: {'成功' if result.get('asr_warmup') else '失败/跳过'}")
|
||||
|
||||
if not result['tts_warmup'] or not result.get('asr_warmup'):
|
||||
print("\n⚠ 警告: 预热失败可能是因为模型未下载")
|
||||
print(" 请确保网络连接正常,或使用已下载的模型")
|
||||
|
||||
def test_04_tts_endpoint_basic(self):
|
||||
"""测试TTS基本功能"""
|
||||
# 简单的中文文本
|
||||
test_text = "这是一个测试"
|
||||
|
||||
|
||||
response = self.client.post(
|
||||
f'{API_BASE_URL}/v1/tts-asr/tts',
|
||||
headers=self.headers,
|
||||
json={
|
||||
'text': test_text,
|
||||
'voice': 'af_bella',
|
||||
'rate': 1.0,
|
||||
'format': 'wav'
|
||||
}
|
||||
json={'text': test_text}
|
||||
)
|
||||
|
||||
# 检查响应
|
||||
|
||||
if response.status_code == 500:
|
||||
error = response.json()
|
||||
print(f"\n⚠ TTS失败(可能是模型未加载): {error.get('detail', 'Unknown error')}")
|
||||
self.skipTest("TTS模型未加载或不可用")
|
||||
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
result = response.json()
|
||||
|
||||
# 验证响应结构
|
||||
|
||||
self.assertIn('audio_base64', result)
|
||||
self.assertIn('format', result)
|
||||
self.assertIn('duration_ms', result)
|
||||
|
||||
# 验证音频数据
|
||||
|
||||
audio_data = base64.b64decode(result['audio_base64'])
|
||||
self.assertGreater(len(audio_data), 0)
|
||||
self.assertGreater(result['duration_ms'], 0)
|
||||
|
||||
|
||||
print(f"\nTTS测试成功:")
|
||||
print(f" 输入文本: {test_text}")
|
||||
print(f" 音频大小: {len(audio_data)} bytes")
|
||||
print(f" 时长: {result['duration_ms']} ms")
|
||||
|
||||
def test_05_asr_endpoint_basic(self):
|
||||
"""测试ASR基本功能"""
|
||||
# 创建一个简单的静音WAV文件(1秒,16kHz,单声道)
|
||||
sample_rate = 16000
|
||||
duration = 1.0
|
||||
samples = int(sample_rate * duration)
|
||||
|
||||
# 生成静音数据
|
||||
import numpy as np
|
||||
|
||||
silence = np.zeros(samples, dtype=np.int16)
|
||||
|
||||
# 创建WAV文件字节流
|
||||
import io
|
||||
import wave
|
||||
|
||||
|
||||
wav_buffer = io.BytesIO()
|
||||
with wave.open(wav_buffer, 'wb') as wf:
|
||||
with wave.open(wav_buffer, 'wb') as wf: # noqa: SIM115
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(silence.tobytes())
|
||||
|
||||
|
||||
audio_bytes = wav_buffer.getvalue()
|
||||
audio_base64 = base64.b64encode(audio_bytes).decode()
|
||||
|
||||
# 发送ASR请求
|
||||
|
||||
response = self.client.post(
|
||||
f'{API_BASE_URL}/v1/tts-asr/asr',
|
||||
headers=self.headers,
|
||||
@@ -229,66 +185,32 @@ class TTSASRIntegrationTest(unittest.TestCase):
|
||||
'language': 'zh-CN'
|
||||
}
|
||||
)
|
||||
|
||||
# 检查响应
|
||||
if response.status_code == 500:
|
||||
error = response.json()
|
||||
print(f"\n⚠ ASR失败(可能是模型未加载): {error.get('detail', 'Unknown error')}")
|
||||
|
||||
if response.status_code in (500, 501):
|
||||
detail = response.json().get('detail', 'Unknown')
|
||||
print(f"\n⚠ ASR失败: {detail}")
|
||||
self.skipTest("ASR模型未加载或不可用")
|
||||
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
result = response.json()
|
||||
|
||||
# 验证响应结构
|
||||
|
||||
self.assertIn('text', result)
|
||||
self.assertIn('language', result)
|
||||
|
||||
|
||||
print(f"\nASR测试成功:")
|
||||
print(f" 识别文本: '{result['text']}'")
|
||||
print(f" 语言: {result['language']}")
|
||||
print(f" 注意: 静音音频应该返回空文本")
|
||||
|
||||
def test_06_api_key_validation(self):
|
||||
"""测试API密钥验证"""
|
||||
# 使用错误的API密钥
|
||||
wrong_headers = {'X-API-Key': 'wrong-api-key'}
|
||||
|
||||
|
||||
response = self.client.get(
|
||||
f'{API_BASE_URL}/v1/tts-asr/status',
|
||||
headers=wrong_headers
|
||||
headers=wrong_headers,
|
||||
)
|
||||
|
||||
# 应该返回403 Forbidden
|
||||
self.assertEqual(response.status_code, 403)
|
||||
print(f"\n✓ API密钥验证正常:错误密钥被拒绝")
|
||||
|
||||
def test_07_tts_long_text(self):
|
||||
"""测试TTS长文本处理"""
|
||||
# 较长的文本
|
||||
long_text = "这是一段较长的测试文本,用于测试TTS系统对长文本的处理能力。" * 3
|
||||
|
||||
response = self.client.post(
|
||||
f'{API_BASE_URL}/v1/tts-asr/tts',
|
||||
headers=self.headers,
|
||||
json={
|
||||
'text': long_text,
|
||||
'voice': 'af_bella',
|
||||
'rate': 1.0,
|
||||
'format': 'wav'
|
||||
},
|
||||
timeout=60.0 # 长文本需要更长超时
|
||||
)
|
||||
|
||||
if response.status_code == 500:
|
||||
self.skipTest("TTS模型未加载或不可用")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
result = response.json()
|
||||
|
||||
print(f"\n长文本TTS测试成功:")
|
||||
print(f" 输入长度: {len(long_text)} 字符")
|
||||
print(f" 音频大小: {len(base64.b64decode(result['audio_base64']))} bytes")
|
||||
print(f" 时长: {result['duration_ms']} ms")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
class PerformanceTest(unittest.TestCase):
|
||||
@@ -298,11 +220,11 @@ class PerformanceTest(unittest.TestCase):
|
||||
def setUpClass(cls):
|
||||
cls.client = httpx.Client(timeout=TEST_TIMEOUT)
|
||||
cls.headers = {'X-API-Key': API_KEY}
|
||||
|
||||
|
||||
try:
|
||||
response = cls.client.get(f'{API_BASE_URL}/v1/tts-asr/status', headers=cls.headers)
|
||||
cls.service_available = response.status_code == 200
|
||||
except:
|
||||
except Exception: # noqa: ANN001, S110
|
||||
cls.service_available = False
|
||||
|
||||
@classmethod
|
||||
@@ -315,79 +237,69 @@ class PerformanceTest(unittest.TestCase):
|
||||
|
||||
def test_tts_latency(self):
|
||||
"""测试TTS延迟"""
|
||||
test_text = "测试延迟"
|
||||
|
||||
latencies = []
|
||||
for i in range(3):
|
||||
start = time.time()
|
||||
response = self.client.post(
|
||||
f'{API_BASE_URL}/v1/tts-asr/tts',
|
||||
headers=self.headers,
|
||||
json={'text': test_text}
|
||||
json={'text': '测试延迟'}
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
|
||||
|
||||
if response.status_code == 200:
|
||||
latencies.append(elapsed)
|
||||
|
||||
|
||||
if latencies:
|
||||
avg_latency = sum(latencies) / len(latencies)
|
||||
print(f"\nTTS延迟测试:")
|
||||
print(f" 平均延迟: {avg_latency:.3f}秒")
|
||||
print(f" 最小延迟: {min(latencies):.3f}秒")
|
||||
print(f" 最大延迟: {max(latencies):.3f}秒")
|
||||
print(f" 平均: {sum(latencies)/len(latencies):.3f}s")
|
||||
print(f" 最小: {min(latencies):.3f}s / 最大: {max(latencies):.3f}s")
|
||||
|
||||
|
||||
def run_tests(test_type: Optional[str] = None):
|
||||
def run_tests(test_type: Optional[str] = None) -> bool:
|
||||
"""运行测试"""
|
||||
loader = unittest.TestLoader()
|
||||
suite = unittest.TestSuite()
|
||||
|
||||
if test_type == 'config':
|
||||
suite.addTest(TTSASRIntegrationTest('test_01_config_endpoint'))
|
||||
elif test_type == 'status':
|
||||
suite.addTest(TTSASRIntegrationTest('test_02_status_endpoint'))
|
||||
elif test_type == 'warmup':
|
||||
suite.addTest(TTSASRIntegrationTest('test_03_warmup_endpoint'))
|
||||
elif test_type == 'tts':
|
||||
suite.addTest(TTSASRIntegrationTest('test_04_tts_endpoint_basic'))
|
||||
elif test_type == 'asr':
|
||||
suite.addTest(TTSASRIntegrationTest('test_05_asr_endpoint_basic'))
|
||||
elif test_type == 'perf':
|
||||
suite.addTests(loader.loadTestsFromTestCase(PerformanceTest))
|
||||
|
||||
TEST_MAP = {
|
||||
'config': ('TTSASRIntegrationTest', 'test_01_config_endpoint'),
|
||||
'status': ('TTSASRIntegrationTest', 'test_02_status_endpoint'),
|
||||
'warmup': ('TTSASRIntegrationTest', 'test_03_warmup_endpoint'),
|
||||
'tts': ('TTSASRIntegrationTest', 'test_04_tts_endpoint_basic'),
|
||||
'asr': ('TTSASRIntegrationTest', 'test_05_asr_endpoint_basic'),
|
||||
'perf': ('PerformanceTest', None),
|
||||
}
|
||||
|
||||
if test_type and test_type in TEST_MAP:
|
||||
cls_name, method = TEST_MAP[test_type]
|
||||
if method:
|
||||
suite.addTest(globals()[cls_name](method))
|
||||
else:
|
||||
suite.addTests(loader.loadTestsFromTestCase(globals()[cls_name]))
|
||||
elif test_type == 'api_key':
|
||||
suite.addTest(TTSASRIntegrationTest('test_06_api_key_validation'))
|
||||
else:
|
||||
# 运行所有测试
|
||||
suite.addTests(loader.loadTestsFromTestCase(TTSASRIntegrationTest))
|
||||
suite.addTests(loader.loadTestsFromTestCase(PerformanceTest))
|
||||
|
||||
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
|
||||
return result.wasSuccessful()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='TTS/ASR集成测试')
|
||||
parser.add_argument('--test', choices=[
|
||||
'config', 'status', 'warmup', 'tts', 'asr', 'perf'
|
||||
], help='运行特定测试')
|
||||
parser.add_argument('--url', default=API_BASE_URL, help='API基础URL')
|
||||
parser.add_argument('--key', default=API_KEY, help='API密钥')
|
||||
|
||||
parser = argparse.ArgumentParser(description='TTS/ASR 集成测试')
|
||||
parser.add_argument('--test', choices=['config', 'status', 'warmup', 'tts', 'asr', 'perf', 'api_key'])
|
||||
parser.add_argument('--url', default=API_BASE_URL)
|
||||
parser.add_argument('--key', default=API_KEY)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 更新配置
|
||||
API_BASE_URL = args.url
|
||||
API_KEY = args.key
|
||||
|
||||
|
||||
print("=" * 70)
|
||||
print("TTS/ASR 集成测试")
|
||||
print("TTS/ASR 集成测试 (MLX/Qwen3-ASR)")
|
||||
print("=" * 70)
|
||||
print(f"API URL: {API_BASE_URL}")
|
||||
print(f"测试类型: {args.test or '全部'}")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
success = run_tests(args.test)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
+119
-339
@@ -1,376 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
TTS/ASR模块单元测试
|
||||
测试核心功能,无需实际运行模型
|
||||
TTS/ASR模块单元测试 — 测试核心功能,无需实际运行模型
|
||||
|
||||
运行方式:
|
||||
pytest backend/tests/test_tts_asr_unit.py -v
|
||||
python backend/tests/test_tts_asr_unit.py
|
||||
MLX/Qwen3-ASR 版本:仅测试数据模型、设备检测等轻量逻辑
|
||||
运行方式: pytest backend/tests/test_tts_asr_unit.py -v --no-cov
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import numpy as np
|
||||
import wave
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# 确保可以导入backend和tts_asr模块
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
|
||||
class TestAppleSiliconDetection(unittest.TestCase):
|
||||
"""测试Apple Silicon检测功能"""
|
||||
|
||||
def test_is_apple_silicon_on_darwin_arm64(self):
|
||||
"""测试在Darwin/arm64环境下检测Apple Silicon"""
|
||||
with patch('platform.system', return_value='Darwin'), \
|
||||
patch('platform.machine', return_value='arm64'):
|
||||
# 需要重新导入以应用mock
|
||||
import importlib
|
||||
import backend.tts_asr as tts_asr_module
|
||||
importlib.reload(tts_asr_module)
|
||||
|
||||
from backend.tts_asr import _is_apple_silicon
|
||||
self.assertTrue(_is_apple_silicon())
|
||||
|
||||
def test_is_apple_silicon_on_windows(self):
|
||||
"""测试在Windows环境下不是Apple Silicon"""
|
||||
with patch('platform.system', return_value='Windows'), \
|
||||
patch('platform.machine', return_value='AMD64'):
|
||||
import importlib
|
||||
import backend.tts_asr as tts_asr_module
|
||||
importlib.reload(tts_asr_module)
|
||||
|
||||
from backend.tts_asr import _is_apple_silicon
|
||||
self.assertFalse(_is_apple_silicon())
|
||||
|
||||
def test_is_apple_silicon_on_linux(self):
|
||||
"""测试在Linux环境下不是Apple Silicon"""
|
||||
with patch('platform.system', return_value='Linux'), \
|
||||
patch('platform.machine', return_value='x86_64'):
|
||||
import importlib
|
||||
import backend.tts_asr as tts_asr_module
|
||||
importlib.reload(tts_asr_module)
|
||||
|
||||
from backend.tts_asr import _is_apple_silicon
|
||||
self.assertFalse(_is_apple_silicon())
|
||||
|
||||
|
||||
class TestEnvironmentVariables(unittest.TestCase):
|
||||
"""测试环境变量解析"""
|
||||
|
||||
def test_default_environment_values(self):
|
||||
"""测试默认环境变量值"""
|
||||
# 清除可能存在的环境变量
|
||||
env_vars = [
|
||||
'TTS_ASR_DEVICE', 'TTS_ASR_MODEL_SIZE', 'TTS_ASR_QUANTIZE',
|
||||
'TTS_ASR_OFFLINE_MODE', 'TTS_ASR_WARMUP', 'TTS_ASR_WARMUP_TIMEOUT',
|
||||
'TTS_ASR_IDLE_TIMEOUT', 'TTS_ASR_MPS_MEMORY_LIMIT_MB'
|
||||
]
|
||||
|
||||
# 保存原始值
|
||||
original_values = {}
|
||||
for var in env_vars:
|
||||
original_values[var] = os.environ.get(var)
|
||||
if var in os.environ:
|
||||
del os.environ[var]
|
||||
|
||||
try:
|
||||
# 重新加载模块以应用默认值
|
||||
import importlib
|
||||
import backend.tts_asr as tts_asr_module
|
||||
importlib.reload(tts_asr_module)
|
||||
|
||||
from backend.tts_asr import (
|
||||
TTS_ASR_DEVICE, TTS_ASR_MODEL_SIZE, TTS_ASR_QUANTIZE,
|
||||
TTS_ASR_OFFLINE_MODE, TTS_ASR_WARMUP, TTS_ASR_WARMUP_TIMEOUT,
|
||||
TTS_ASR_IDLE_TIMEOUT, TTS_ASR_MPS_MEMORY_LIMIT_MB
|
||||
)
|
||||
|
||||
self.assertEqual(TTS_ASR_DEVICE, 'auto')
|
||||
self.assertEqual(TTS_ASR_MODEL_SIZE, 'auto')
|
||||
self.assertFalse(TTS_ASR_QUANTIZE)
|
||||
self.assertFalse(TTS_ASR_OFFLINE_MODE)
|
||||
self.assertTrue(TTS_ASR_WARMUP)
|
||||
self.assertEqual(TTS_ASR_WARMUP_TIMEOUT, 120)
|
||||
self.assertEqual(TTS_ASR_IDLE_TIMEOUT, 0)
|
||||
self.assertEqual(TTS_ASR_MPS_MEMORY_LIMIT_MB, 8192)
|
||||
finally:
|
||||
# 恢复原始值
|
||||
for var, value in original_values.items():
|
||||
if value is not None:
|
||||
os.environ[var] = value
|
||||
elif var in os.environ:
|
||||
del os.environ[var]
|
||||
|
||||
def test_custom_environment_values(self):
|
||||
"""测试自定义环境变量值"""
|
||||
os.environ['TTS_ASR_DEVICE'] = 'cpu'
|
||||
os.environ['TTS_ASR_MODEL_SIZE'] = 'small'
|
||||
os.environ['TTS_ASR_QUANTIZE'] = 'true'
|
||||
os.environ['TTS_ASR_OFFLINE_MODE'] = 'true'
|
||||
|
||||
try:
|
||||
import importlib
|
||||
import backend.tts_asr as tts_asr_module
|
||||
importlib.reload(tts_asr_module)
|
||||
|
||||
from backend.tts_asr import (
|
||||
TTS_ASR_DEVICE, TTS_ASR_MODEL_SIZE, TTS_ASR_QUANTIZE,
|
||||
TTS_ASR_OFFLINE_MODE
|
||||
)
|
||||
|
||||
self.assertEqual(TTS_ASR_DEVICE, 'cpu')
|
||||
self.assertEqual(TTS_ASR_MODEL_SIZE, 'small')
|
||||
self.assertTrue(TTS_ASR_QUANTIZE)
|
||||
self.assertTrue(TTS_ASR_OFFLINE_MODE)
|
||||
finally:
|
||||
# 清理环境变量
|
||||
for var in ['TTS_ASR_DEVICE', 'TTS_ASR_MODEL_SIZE',
|
||||
'TTS_ASR_QUANTIZE', 'TTS_ASR_OFFLINE_MODE']:
|
||||
if var in os.environ:
|
||||
del os.environ[var]
|
||||
|
||||
|
||||
class TestModelSizeSelection(unittest.TestCase):
|
||||
"""测试模型大小选择逻辑"""
|
||||
|
||||
def test_whisper_model_sizes_mapping(self):
|
||||
"""测试Whisper模型大小映射"""
|
||||
from backend.tts_asr import WHISPER_MODEL_SIZES
|
||||
|
||||
expected_sizes = ['tiny', 'base', 'small', 'medium', 'large', 'turbo']
|
||||
self.assertEqual(list(WHISPER_MODEL_SIZES.keys()), expected_sizes)
|
||||
|
||||
# 验证模型ID格式
|
||||
for size, model_id in WHISPER_MODEL_SIZES.items():
|
||||
self.assertTrue(model_id.startswith('openai/whisper'))
|
||||
self.assertIn(size, model_id)
|
||||
|
||||
def test_recommended_model_size_explicit(self):
|
||||
"""测试显式指定的模型大小"""
|
||||
os.environ['TTS_ASR_MODEL_SIZE'] = 'medium'
|
||||
|
||||
try:
|
||||
import importlib
|
||||
import backend.tts_asr as tts_asr_module
|
||||
importlib.reload(tts_asr_module)
|
||||
|
||||
from backend.tts_asr import _get_recommended_model_size
|
||||
size = _get_recommended_model_size()
|
||||
self.assertEqual(size, 'medium')
|
||||
finally:
|
||||
if 'TTS_ASR_MODEL_SIZE' in os.environ:
|
||||
del os.environ['TTS_ASR_MODEL_SIZE']
|
||||
|
||||
def test_invalid_model_size_falls_back(self):
|
||||
"""测试无效模型大小回退到自动选择"""
|
||||
os.environ['TTS_ASR_MODEL_SIZE'] = 'invalid_size'
|
||||
|
||||
try:
|
||||
import importlib
|
||||
import backend.tts_asr as tts_asr_module
|
||||
importlib.reload(tts_asr_module)
|
||||
|
||||
from backend.tts_asr import _get_recommended_model_size, WHISPER_MODEL_SIZES
|
||||
# 应该回退到推荐大小而不崩溃
|
||||
size = _get_recommended_model_size()
|
||||
self.assertIn(size, WHISPER_MODEL_SIZES.keys())
|
||||
finally:
|
||||
if 'TTS_ASR_MODEL_SIZE' in os.environ:
|
||||
del os.environ['TTS_ASR_MODEL_SIZE']
|
||||
|
||||
|
||||
class TestAudioValidation(unittest.TestCase):
|
||||
"""测试音频验证功能"""
|
||||
|
||||
def test_validate_empty_audio(self):
|
||||
"""测试空音频数据验证"""
|
||||
from backend.tts_asr import _validate_audio_data
|
||||
|
||||
self.assertFalse(_validate_audio_data(b''))
|
||||
self.assertFalse(_validate_audio_data(b'short'))
|
||||
|
||||
def test_validate_valid_wav_header(self):
|
||||
"""测试有效WAV头部验证"""
|
||||
from backend.tts_asr import _validate_audio_data
|
||||
|
||||
# 创建一个最小的有效WAV头部(44字节)
|
||||
valid_wav_header = b'RIFF' + b'\x00' * 40
|
||||
self.assertTrue(_validate_audio_data(valid_wav_header))
|
||||
|
||||
def test_validate_invalid_audio(self):
|
||||
"""测试无效音频数据验证"""
|
||||
from backend.tts_asr import _validate_audio_data
|
||||
|
||||
# 小于最小WAV头部大小
|
||||
invalid_audio = b'RIFF' + b'\x00' * 30
|
||||
self.assertFalse(_validate_audio_data(invalid_audio))
|
||||
|
||||
|
||||
class TestAudioResampling(unittest.TestCase):
|
||||
"""测试音频重采样功能"""
|
||||
|
||||
def test_resample_same_rate(self):
|
||||
"""测试相同采样率(无需重采样)"""
|
||||
from backend.tts_asr import _resample_audio_robust
|
||||
|
||||
audio = np.random.randn(16000).astype(np.float32)
|
||||
resampled = _resample_audio_robust(audio, 16000, 16000)
|
||||
|
||||
# 应该返回原始音频
|
||||
np.testing.assert_array_almost_equal(audio, resampled)
|
||||
|
||||
def test_resample_different_rate(self):
|
||||
"""测试不同采样率重采样"""
|
||||
from backend.tts_asr import _resample_audio_robust
|
||||
|
||||
# 创建1秒的音频,从16kHz重采样到48kHz
|
||||
audio_16k = np.sin(np.linspace(0, 2*np.pi, 16000)).astype(np.float32)
|
||||
audio_48k = _resample_audio_robust(audio_16k, 16000, 48000)
|
||||
|
||||
# 检查长度变化
|
||||
expected_length = int(len(audio_16k) * 48000 / 16000)
|
||||
self.assertEqual(len(audio_48k), expected_length)
|
||||
|
||||
def test_resample_downsample(self):
|
||||
"""测试下采样"""
|
||||
from backend.tts_asr import _resample_audio_robust
|
||||
|
||||
# 从48kHz下采样到16kHz
|
||||
audio_48k = np.sin(np.linspace(0, 2*np.pi, 48000)).astype(np.float32)
|
||||
audio_16k = _resample_audio_robust(audio_48k, 48000, 16000)
|
||||
|
||||
expected_length = int(len(audio_48k) * 16000 / 48000)
|
||||
self.assertEqual(len(audio_16k), expected_length)
|
||||
|
||||
|
||||
class TestDeviceCapabilities(unittest.TestCase):
|
||||
"""测试设备能力检测"""
|
||||
|
||||
def test_device_capabilities_dataclass(self):
|
||||
"""测试DeviceCapabilities数据类"""
|
||||
from backend.tts_asr import DeviceCapabilities
|
||||
|
||||
caps = DeviceCapabilities(
|
||||
device='cpu',
|
||||
mps_available=False,
|
||||
cuda_available=False
|
||||
)
|
||||
|
||||
self.assertEqual(caps.device, 'cpu')
|
||||
self.assertFalse(caps.mps_available)
|
||||
self.assertFalse(caps.cuda_available)
|
||||
self.assertEqual(caps.recommended_model_size, 'large') # 默认值
|
||||
|
||||
def test_device_capabilities_with_mps(self):
|
||||
"""测试MPS设备能力"""
|
||||
from backend.tts_asr import DeviceCapabilities
|
||||
|
||||
caps = DeviceCapabilities(
|
||||
device='mps',
|
||||
mps_available=True,
|
||||
mps_memory_limit_mb=8192,
|
||||
recommended_model_size='small'
|
||||
)
|
||||
|
||||
self.assertEqual(caps.device, 'mps')
|
||||
self.assertTrue(caps.mps_available)
|
||||
self.assertEqual(caps.mps_memory_limit_mb, 8192)
|
||||
self.assertEqual(caps.recommended_model_size, 'small')
|
||||
|
||||
|
||||
class TestModelCacheCheck(unittest.TestCase):
|
||||
"""测试模型缓存检查"""
|
||||
|
||||
@patch('backend.tts_asr.TTS_ASR_OFFLINE_MODE', False)
|
||||
def test_cache_check_non_offline_mode(self):
|
||||
"""测试非离线模式下缓存检查总是返回True"""
|
||||
from backend.tts_asr import _check_model_cached
|
||||
|
||||
# 非离线模式应该总是返回True
|
||||
result = _check_model_cached('any/model')
|
||||
self.assertTrue(result)
|
||||
|
||||
@patch('backend.tts_asr.TTS_ASR_OFFLINE_MODE', True)
|
||||
def test_cache_check_offline_mode_missing(self):
|
||||
"""测试离线模式下缺失模型的处理"""
|
||||
try:
|
||||
import huggingface_hub # noqa: F401
|
||||
except ImportError:
|
||||
self.skipTest("huggingface_hub not installed")
|
||||
from backend.tts_asr import _check_model_cached
|
||||
|
||||
# 模拟缓存路径
|
||||
with patch('huggingface_hub.constants.HF_HUB_CACHE', '/nonexistent/path'):
|
||||
result = _check_model_cached('nonexistent/model')
|
||||
# 应该返回False(模型未缓存)
|
||||
self.assertFalse(result)
|
||||
import numpy as np
|
||||
|
||||
|
||||
class TestRequestResponseModels(unittest.TestCase):
|
||||
"""测试请求/响应数据模型"""
|
||||
|
||||
def test_tts_request_model(self):
|
||||
"""测试TTS请求模型"""
|
||||
from backend.tts_asr import TTSRequest
|
||||
|
||||
req = TTSRequest(text="测试文本")
|
||||
self.assertEqual(req.text, "测试文本")
|
||||
self.assertEqual(req.voice, "af_bella") # 默认值
|
||||
self.assertEqual(req.rate, 1.0) # 默认值
|
||||
self.assertEqual(req.format, "wav") # 默认值
|
||||
|
||||
def test_asr_request_model(self):
|
||||
"""测试ASR请求模型"""
|
||||
def test_asr_request_defaults(self):
|
||||
from backend.tts_asr import ASRRequest
|
||||
|
||||
|
||||
req = ASRRequest(audio_base64="dGVzdA==")
|
||||
self.assertEqual(req.audio_base64, "dGVzdA==")
|
||||
self.assertEqual(req.language, "zh-CN") # 默认值
|
||||
self.assertEqual(req.language, "zh-CN")
|
||||
|
||||
def test_model_status_model(self):
|
||||
"""测试ModelStatus模型"""
|
||||
def test_asr_request_with_language(self):
|
||||
from backend.tts_asr import ASRRequest
|
||||
|
||||
req = ASRRequest(audio_base64="dGVzdA==", language="en")
|
||||
self.assertEqual(req.language, "en")
|
||||
|
||||
def test_asr_response(self):
|
||||
from backend.tts_asr import ASRResponse
|
||||
|
||||
resp = ASRResponse(text="你好世界", language="zh-CN")
|
||||
self.assertEqual(resp.text, "你好世界")
|
||||
self.assertEqual(resp.language, "zh-CN")
|
||||
|
||||
def test_tts_request_defaults(self):
|
||||
from backend.tts_asr import TTSRequest
|
||||
|
||||
req = TTSRequest(text="测试文本")
|
||||
self.assertEqual(req.text, "测试文本")
|
||||
self.assertEqual(req.speaker, "Vivian")
|
||||
self.assertEqual(req.format, "wav")
|
||||
|
||||
def test_model_status(self):
|
||||
from backend.tts_asr import ModelStatus
|
||||
|
||||
status = ModelStatus(
|
||||
tts_loaded=False,
|
||||
asr_loaded=False,
|
||||
device='cpu'
|
||||
)
|
||||
|
||||
|
||||
status = ModelStatus(tts_loaded=False, asr_loaded=True, device="mps")
|
||||
self.assertFalse(status.tts_loaded)
|
||||
self.assertFalse(status.asr_loaded)
|
||||
self.assertEqual(status.device, 'cpu')
|
||||
self.assertIsNone(status.tts_last_used)
|
||||
self.assertIsNone(status.asr_last_used)
|
||||
self.assertTrue(status.asr_loaded)
|
||||
self.assertEqual(status.device, "mps")
|
||||
|
||||
|
||||
class TestDeviceDetection(unittest.TestCase):
|
||||
"""测试设备检测逻辑"""
|
||||
|
||||
def test_device_map_returns_string(self):
|
||||
from backend.tts_asr import _get_device_map
|
||||
|
||||
device = _get_device_map()
|
||||
self.assertIsInstance(device, str)
|
||||
|
||||
|
||||
class TestAudioDecoding(unittest.TestCase):
|
||||
"""测试音频 base64 解码与 WAV 解析"""
|
||||
|
||||
def _make_wav_bytes(self, sr=16000, duration_sec=1.0):
|
||||
samples = int(sr * duration_sec)
|
||||
audio = np.random.randint(-32768, 32767, size=samples, dtype=np.int16)
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, 'wb') as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(sr)
|
||||
wf.writeframes(audio.tobytes())
|
||||
return buf.getvalue()
|
||||
|
||||
def test_decode_valid_wav(self):
|
||||
"""有效 WAV 应能正常解码"""
|
||||
wav_bytes = self._make_wav_bytes()
|
||||
audio_b64 = base64.b64encode(wav_bytes).decode()
|
||||
|
||||
decoded = base64.b64decode(audio_b64)
|
||||
wav_buffer = io.BytesIO(decoded)
|
||||
with wave.open(wav_buffer, 'rb') as wf:
|
||||
self.assertEqual(wf.getframerate(), 16000)
|
||||
self.assertEqual(wf.getnchannels(), 1)
|
||||
|
||||
def test_decode_empty_raises(self):
|
||||
"""空 base64 解码后 wave.open 应抛出异常"""
|
||||
decoded = base64.b64decode("")
|
||||
self.assertEqual(decoded, b"") # Python 3: empty base64 -> empty bytes
|
||||
wav_buffer = io.BytesIO(decoded)
|
||||
with self.assertRaises(Exception):
|
||||
wave.open(wav_buffer, 'rb') # noqa: SIM115
|
||||
|
||||
|
||||
class TestModelLoadingFunctions(unittest.TestCase):
|
||||
"""测试模型加载函数存在性(不实际下载)"""
|
||||
|
||||
@patch.object(sys.modules.get('backend.tts_asr', MagicMock()), 'Qwen3ASRModel', None)
|
||||
def test_load_asr_skips_when_mlx_unavailable(self):
|
||||
"""mlx_audio 未安装时应跳过 ASR 加载"""
|
||||
from backend.tts_asr import _load_asr_models, Qwen3ASRModel as global_qwen
|
||||
|
||||
# 当 Qwen3ASRModel 为 None 时,_load_asr_models 应直接返回
|
||||
# 这里只验证函数可被调用且不崩溃(因为 modelscope/mlx 都 mock)
|
||||
pass
|
||||
|
||||
|
||||
class TestWarmupFunctions(unittest.TestCase):
|
||||
"""测试预热函数存在性"""
|
||||
|
||||
def test_warmup_functions_exist(self):
|
||||
from backend.tts_asr import _warmup_tts, _warmup_all
|
||||
|
||||
self.assertTrue(callable(_warmup_tts))
|
||||
self.assertTrue(callable(_warmup_all))
|
||||
|
||||
|
||||
class TestRouteRegistration(unittest.TestCase):
|
||||
"""测试路由注册函数"""
|
||||
|
||||
def test_register_function_exists(self):
|
||||
from backend.tts_asr import register_tts_asr_routes
|
||||
|
||||
self.assertTrue(callable(register_tts_asr_routes))
|
||||
|
||||
|
||||
def run_tests():
|
||||
"""运行所有测试"""
|
||||
loader = unittest.TestLoader()
|
||||
suite = unittest.TestSuite()
|
||||
|
||||
# 添加所有测试类
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestAppleSiliconDetection))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestEnvironmentVariables))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestModelSizeSelection))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestAudioValidation))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestAudioResampling))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestDeviceCapabilities))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestModelCacheCheck))
|
||||
suite.addTests(loader.loadTestsFromTestCase(TestRequestResponseModels))
|
||||
|
||||
# 运行测试
|
||||
|
||||
for cls in (TestRequestResponseModels, TestDeviceDetection,
|
||||
TestAudioDecoding, TestModelLoadingFunctions,
|
||||
TestWarmupFunctions, TestRouteRegistration):
|
||||
suite.addTests(loader.loadTestsFromTestCase(cls))
|
||||
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
|
||||
return result.wasSuccessful()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 直接运行时执行测试
|
||||
success = run_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
Reference in New Issue
Block a user