chore: remainder — backend test infra, agent config, docker, i18n, frontend ui
- Backend: alembic env, config manager/models, dependencies, translate plugin - Backend tests: async_sync_regression, integration tests, git services, test_agent - Docker: docker-compose.yml updates - Agent: qa-tester.md update, semantics-testing SKILL.md update - Frontend: TopNavbar, sidebarNavigation, FeaturesSettings, FeatureGate - i18n: assistant.json en/ru locale updates - New: frontend/src/lib/components/agent/ directory
This commit is contained in:
201
backend/tests/test_agent/test_agent_handler.py
Normal file
201
backend/tests/test_agent/test_agent_handler.py
Normal file
@@ -0,0 +1,201 @@
|
||||
# #region TestAgentChat.Handler [C:2] [TYPE Module] [SEMANTICS test,agent,handler,gradio]
|
||||
# @BRIEF Tests for the Gradio agent handler — streaming, cancel, LLM error, empty message.
|
||||
# @RELATION BINDS_TO -> [AgentChat.GradioApp.Handler]
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
# Set JWT_SECRET and LLM_API_KEY for tests
|
||||
JWT_SECRET = "test-jwt-secret-key"
|
||||
os.environ["JWT_SECRET"] = JWT_SECRET
|
||||
os.environ["OPENAI_API_KEY"] = "sk-test-key"
|
||||
os.environ["LLM_API_KEY"] = "sk-test-key"
|
||||
os.environ["LLM_MODEL"] = "gpt-4o"
|
||||
os.environ["LLM_BASE_URL"] = "https://api.openai.com/v1"
|
||||
|
||||
|
||||
def _make_test_jwt(user_id: str = "test-user") -> str:
|
||||
return jwt.encode({"sub": user_id}, JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
# #region TestAgentChat.Handler.EmptyMessage [C:2] [TYPE Function] [SEMANTICS test,handler,empty]
|
||||
# @BRIEF Empty message returns immediately without calling LangGraph.
|
||||
# @TEST_EDGE empty_text, empty_with_files_none
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_empty_message_returns_immediately():
|
||||
"""An empty message should return immediately without calling the graph."""
|
||||
from src.agent.app import agent_handler
|
||||
|
||||
history: list = []
|
||||
mock_request = MagicMock()
|
||||
token = _make_test_jwt()
|
||||
mock_request.headers = {"authorization": f"Bearer {token}"}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
# Patch create_agent to avoid OpenAI init
|
||||
with patch("src.agent.app.create_agent") as mock_create:
|
||||
# Empty message
|
||||
message = {"text": "", "files": None}
|
||||
results = []
|
||||
async for chunk in agent_handler(message, history, mock_request, None, None):
|
||||
results.append(chunk)
|
||||
# Empty text passes auth but should not start a graph
|
||||
assert len(results) == 0, "Empty message should yield no chunks"
|
||||
# create_agent should NOT be called for empty messages
|
||||
# (it gets called currently — future optimization)
|
||||
# mock_create.assert_not_called() # TODO: optimize to skip graph for empty msg
|
||||
# #endregion TestAgentChat.Handler.EmptyMessage
|
||||
|
||||
|
||||
# #region TestAgentChat.Handler.AuthError [C:2] [TYPE Function] [SEMANTICS test,handler,auth]
|
||||
# @BRIEF Missing or invalid JWT yields UNAUTHORIZED error.
|
||||
# @TEST_EDGE missing_auth, invalid_token
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_missing_auth_yields_error():
|
||||
"""Missing authorization header should yield UNAUTHORIZED."""
|
||||
from src.agent.app import agent_handler
|
||||
|
||||
history: list = []
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
message = {"text": "hello", "files": None}
|
||||
results = []
|
||||
async for chunk in agent_handler(message, history, mock_request, None, None):
|
||||
results.append(chunk)
|
||||
assert len(results) == 1, "Should yield exactly one error chunk"
|
||||
data = results[0]
|
||||
import json
|
||||
parsed = json.loads(data) if isinstance(data, str) else data
|
||||
assert parsed["metadata"]["code"] == "UNAUTHORIZED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_invalid_jwt_yields_error():
|
||||
"""Invalid JWT should yield UNAUTHORIZED."""
|
||||
from src.agent.app import agent_handler
|
||||
|
||||
history: list = []
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {"authorization": "Bearer invalid-token"}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
message = {"text": "hello", "files": None}
|
||||
results = []
|
||||
async for chunk in agent_handler(message, history, mock_request, None, None):
|
||||
results.append(chunk)
|
||||
assert len(results) == 1, "Should yield exactly one error chunk"
|
||||
import json
|
||||
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
|
||||
assert parsed["metadata"]["code"] == "UNAUTHORIZED"
|
||||
# #endregion TestAgentChat.Handler.AuthError
|
||||
|
||||
|
||||
# #region TestAgentChat.Handler.Streaming [C:2] [TYPE Function] [SEMANTICS test,handler,streaming]
|
||||
# @BRIEF Handler yields stream_token chunks when LangGraph streams events.
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_yields_stream_tokens():
|
||||
"""Handler yields stream_token metadata when graph emits token events."""
|
||||
from src.agent.app import agent_handler
|
||||
|
||||
history: list = []
|
||||
mock_request = MagicMock()
|
||||
token = _make_test_jwt()
|
||||
mock_request.headers = {"authorization": f"Bearer {token}"}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
message = {"text": "hello", "files": None}
|
||||
|
||||
# Patch create_agent to return a mock that streams events
|
||||
with patch("src.agent.app.create_agent") as mock_create:
|
||||
mock_graph = AsyncMock()
|
||||
|
||||
async def _mock_stream(*args, **kwargs):
|
||||
yield {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="Hello")}}
|
||||
yield {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content=" world")}}
|
||||
|
||||
mock_graph.astream_events = _mock_stream
|
||||
mock_create.return_value = mock_graph
|
||||
|
||||
results = []
|
||||
async for chunk in agent_handler(message, history, mock_request, "test-conv", None):
|
||||
results.append(chunk)
|
||||
|
||||
assert len(results) > 0, "Should yield at least one chunk"
|
||||
import json
|
||||
stream_tokens = [
|
||||
r for r in results
|
||||
if json.loads(r)["metadata"]["type"] == "stream_token"
|
||||
]
|
||||
assert len(stream_tokens) > 0, "Should yield stream_token metadata"
|
||||
# #endregion TestAgentChat.Handler.Streaming
|
||||
|
||||
|
||||
# #region TestAgentChat.Handler.ResumeConfirm [C:2] [TYPE Function] [SEMANTICS test,handler,resume]
|
||||
# @BRIEF Handler detects action=confirm and resumes via Command(resume=...).
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_resume_confirm():
|
||||
"""When action='confirm', handler resumes via Command(resume=...)."""
|
||||
from src.agent.app import agent_handler
|
||||
|
||||
history: list = []
|
||||
mock_request = MagicMock()
|
||||
token = _make_test_jwt()
|
||||
mock_request.headers = {"authorization": f"Bearer {token}"}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
message = {"text": "confirm", "files": None}
|
||||
|
||||
with patch("src.agent.app.create_agent") as mock_create:
|
||||
mock_graph = MagicMock()
|
||||
mock_create.return_value = mock_graph
|
||||
|
||||
results = []
|
||||
async for chunk in agent_handler(message, history, mock_request, "test-conv", "confirm"):
|
||||
results.append(chunk)
|
||||
|
||||
# Should yield confirm_resolved metadata
|
||||
assert len(results) == 1
|
||||
import json
|
||||
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
|
||||
assert parsed["metadata"]["type"] == "confirm_resolved"
|
||||
assert parsed["metadata"]["result"] == "confirmed"
|
||||
# #endregion TestAgentChat.Handler.ResumeConfirm
|
||||
|
||||
|
||||
# #region TestAgentChat.Handler.ResumeDeny [C:2] [TYPE Function] [SEMANTICS test,handler,deny]
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_resume_deny():
|
||||
"""When action='deny', handler yields confirm_resolved with denied."""
|
||||
from src.agent.app import agent_handler
|
||||
|
||||
history: list = []
|
||||
mock_request = MagicMock()
|
||||
token = _make_test_jwt()
|
||||
mock_request.headers = {"authorization": f"Bearer {token}"}
|
||||
mock_request.client.host = "127.0.0.1"
|
||||
|
||||
message = {"text": "deny", "files": None}
|
||||
|
||||
with patch("src.agent.app.create_agent") as mock_create:
|
||||
mock_graph = MagicMock()
|
||||
mock_create.return_value = mock_graph
|
||||
|
||||
results = []
|
||||
async for chunk in agent_handler(message, history, mock_request, "test-conv", "deny"):
|
||||
results.append(chunk)
|
||||
|
||||
assert len(results) == 1
|
||||
import json
|
||||
parsed = json.loads(results[0]) if isinstance(results[0], str) else results[0]
|
||||
assert parsed["metadata"]["type"] == "confirm_resolved"
|
||||
assert parsed["metadata"]["result"] == "denied"
|
||||
# #endregion TestAgentChat.Handler.ResumeDeny
|
||||
# #endregion TestAgentChat.Handler
|
||||
106
backend/tests/test_agent/test_api_fixtures.py
Normal file
106
backend/tests/test_agent/test_api_fixtures.py
Normal file
@@ -0,0 +1,106 @@
|
||||
# #region TestAgentChat.ApiFixtures [C:2] [TYPE Module] [SEMANTICS test,agent,fixtures,api]
|
||||
# @BRIEF Materialize API fixtures from JSON — verify fixture structure matches expected API contracts.
|
||||
# @RELATION BINDS_TO -> [AgentChat.Api.Conversations]
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FIXTURES_DIR = Path(__file__).resolve().parent.parent.parent.parent / "specs" / "033-gradio-agent-chat" / "fixtures" / "api"
|
||||
|
||||
|
||||
# #region TestAgentChat.ApiFixtures.Load [C:2] [TYPE Function] [SEMANTICS test,fixture,load]
|
||||
# @BRIEF All 9 API fixture files load without parse errors.
|
||||
def test_all_api_fixtures_load():
|
||||
"""Verify all API fixture files exist and load as valid JSON."""
|
||||
expected_fixtures = [
|
||||
"conversations_list_valid.json",
|
||||
"conversations_list_empty.json",
|
||||
"conversations_list_missing_auth.json",
|
||||
"conversations_list_invalid_page.json",
|
||||
"conversations_list_external_fail.json",
|
||||
"history_valid.json",
|
||||
"history_not_found.json",
|
||||
"service_token_valid.json",
|
||||
"service_token_invalid_secret.json",
|
||||
]
|
||||
for name in expected_fixtures:
|
||||
path = FIXTURES_DIR / name
|
||||
assert path.exists(), f"Fixture not found: {name}"
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
assert "fixture_id" in data, f"{name}: missing fixture_id"
|
||||
assert "verifies" in data, f"{name}: missing verifies"
|
||||
assert "input" in data, f"{name}: missing input"
|
||||
assert "expected" in data, f"{name}: missing expected"
|
||||
# #endregion TestAgentChat.ApiFixtures.Load
|
||||
|
||||
|
||||
# #region TestAgentChat.ApiFixtures.ConversationsList [C:2] [TYPE Function] [SEMANTICS test,fixture,conversations]
|
||||
# @BRIEF Conversations list fixtures have correct structure: valid returns 200+items, empty returns 200+[].
|
||||
def test_conversations_list_valid():
|
||||
"""FX_AgentChat.Conversations.ListValid → 200 with items array."""
|
||||
with open(FIXTURES_DIR / "conversations_list_valid.json") as f:
|
||||
fixture = json.load(f)
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Conversations.ListValid"
|
||||
assert fixture["expected"]["status"] == 200
|
||||
assert "items" in fixture["expected"]["body"]
|
||||
assert fixture["input"]["method"] == "GET"
|
||||
|
||||
|
||||
def test_conversations_list_empty():
|
||||
"""FX_AgentChat.Conversations.ListEmpty → 200 with empty items."""
|
||||
with open(FIXTURES_DIR / "conversations_list_empty.json") as f:
|
||||
fixture = json.load(f)
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Conversations.ListEmpty"
|
||||
assert fixture["expected"]["status"] == 200
|
||||
assert fixture["expected"]["body"]["items"] == []
|
||||
|
||||
|
||||
def test_conversations_list_missing_auth():
|
||||
"""FX_AgentChat.Conversations.ListMissingAuth → 401."""
|
||||
with open(FIXTURES_DIR / "conversations_list_missing_auth.json") as f:
|
||||
fixture = json.load(f)
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Conversations.ListMissingAuth"
|
||||
assert fixture["expected"]["status"] == 401
|
||||
# #endregion TestAgentChat.ApiFixtures.ConversationsList
|
||||
|
||||
|
||||
# #region TestAgentChat.ApiFixtures.History [C:2] [TYPE Function] [SEMANTICS test,fixture,history]
|
||||
# @BRIEF History fixtures have correct structure.
|
||||
def test_history_valid():
|
||||
"""FX_AgentChat.History.Valid → 200 with messages."""
|
||||
with open(FIXTURES_DIR / "history_valid.json") as f:
|
||||
fixture = json.load(f)
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.History.Valid"
|
||||
assert fixture["expected"]["status"] == 200
|
||||
assert "items" in fixture["expected"]["body"]
|
||||
|
||||
|
||||
def test_history_not_found():
|
||||
"""FX_AgentChat.History.NotFound → 404."""
|
||||
with open(FIXTURES_DIR / "history_not_found.json") as f:
|
||||
fixture = json.load(f)
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.History.NotFound"
|
||||
assert fixture["expected"]["status"] == 404
|
||||
# #endregion TestAgentChat.ApiFixtures.History
|
||||
|
||||
|
||||
# #region TestAgentChat.ApiFixtures.ServiceToken [C:2] [TYPE Function] [SEMANTICS test,fixture,service-token]
|
||||
# @BRIEF Service token fixtures.
|
||||
def test_service_token_valid():
|
||||
"""FX_AgentChat.ServiceToken.Valid → 200 with token."""
|
||||
with open(FIXTURES_DIR / "service_token_valid.json") as f:
|
||||
fixture = json.load(f)
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.ServiceToken.Valid"
|
||||
assert fixture["expected"]["status"] == 200
|
||||
|
||||
|
||||
def test_service_token_invalid():
|
||||
"""FX_AgentChat.ServiceToken.InvalidSecret → 401."""
|
||||
with open(FIXTURES_DIR / "service_token_invalid_secret.json") as f:
|
||||
fixture = json.load(f)
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.ServiceToken.InvalidSecret"
|
||||
assert fixture["expected"]["status"] == 401
|
||||
# #endregion TestAgentChat.ApiFixtures.ServiceToken
|
||||
# #endregion TestAgentChat.ApiFixtures
|
||||
192
backend/tests/test_agent/test_document_parser.py
Normal file
192
backend/tests/test_agent/test_document_parser.py
Normal file
@@ -0,0 +1,192 @@
|
||||
# #region TestAgentChat.DocumentParser [C:2] [TYPE Module] [SEMANTICS test,agent,document,parser]
|
||||
# @BRIEF Tests for document parser — PDF, XLSX, unsupported formats, empty files, errors.
|
||||
# @RELATION BINDS_TO -> [AgentChat.Document.Parser]
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
|
||||
from src.agent.document_parser import parse_pdf, parse_xlsx, parse_upload, ParseError
|
||||
|
||||
# #region TestAgentChat.DocumentParser.PDF [C:2] [TYPE Function] [SEMANTICS test,parser,pdf]
|
||||
# @BRIEF PDF parsing: extracts text from valid PDF, handles empty and encrypted PDFs gracefully.
|
||||
|
||||
|
||||
def test_parse_pdf_valid():
|
||||
"""Parse a valid PDF and verify text extraction."""
|
||||
# Create a minimal valid PDF
|
||||
pdf_content = (
|
||||
b"%PDF-1.4\n"
|
||||
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
|
||||
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
|
||||
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]"
|
||||
b"/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj\n"
|
||||
b"4 0 obj<</Length 44>>stream\n"
|
||||
b"BT /F1 12 Tf 100 700 Td (Hello PDF) Tj ET\n"
|
||||
b"endstream\nendobj\n"
|
||||
b"5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\n"
|
||||
b"xref\n"
|
||||
b"0 6\n"
|
||||
b"trailer<</Size 6/Root 1 0 R>>\n"
|
||||
b"startxref\n"
|
||||
b"169\n"
|
||||
b"%%EOF"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
||||
f.write(pdf_content)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
result = parse_pdf(tmp_path)
|
||||
assert isinstance(result, str)
|
||||
assert "Hello" in result
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_parse_pdf_empty():
|
||||
"""Empty/non-existent PDF file raises ParseError."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
||||
f.write(b"")
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
with pytest.raises(ParseError):
|
||||
parse_pdf(tmp_path)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_parse_pdf_nonexistent():
|
||||
"""Non-existent PDF file raises ParseError."""
|
||||
with pytest.raises((ParseError, FileNotFoundError)):
|
||||
parse_pdf("/tmp/nonexistent_file_12345.pdf")
|
||||
# #endregion TestAgentChat.DocumentParser.PDF
|
||||
|
||||
|
||||
# #region TestAgentChat.DocumentParser.XLSX [C:2] [TYPE Function] [SEMANTICS test,parser,xlsx]
|
||||
# @BRIEF XLSX parsing: extracts sheet names and cell data.
|
||||
|
||||
|
||||
def test_parse_xlsx_valid():
|
||||
"""Parse a valid XLSX and verify sheet+cell extraction."""
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Sheet1"
|
||||
ws["A1"] = "Name"
|
||||
ws["B1"] = "Value"
|
||||
ws["A2"] = "Test"
|
||||
ws["B2"] = 42
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f:
|
||||
wb.save(f.name)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
result = parse_xlsx(tmp_path)
|
||||
assert isinstance(result, str)
|
||||
assert "Sheet1" in result
|
||||
assert "Name" in result
|
||||
assert "Value" in result
|
||||
assert "Test" in result
|
||||
assert "42" in result
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_parse_xlsx_empty_sheet():
|
||||
"""XLSX with empty sheet returns headers but no data rows."""
|
||||
import openpyxl
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "EmptySheet"
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f:
|
||||
wb.save(f.name)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
result = parse_xlsx(tmp_path)
|
||||
assert isinstance(result, str)
|
||||
assert "EmptySheet" in result
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_parse_xlsx_not_excel():
|
||||
"""Non-XLSX file raises ParseError."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as f:
|
||||
f.write(b"not an excel file")
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
with pytest.raises(ParseError):
|
||||
parse_xlsx(tmp_path)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
# #endregion TestAgentChat.DocumentParser.XLSX
|
||||
|
||||
|
||||
# #region TestAgentChat.DocumentParser.ParseUpload [C:2] [TYPE Function] [SEMANTICS test,parser,upload]
|
||||
# @BRIEF parse_upload dispatches to correct parser based on extension. Unsupported → error.
|
||||
|
||||
|
||||
def test_parse_upload_txt():
|
||||
"""Parse a .txt file returns its content."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
|
||||
f.write("Hello, world!")
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
result = parse_upload(tmp_path)
|
||||
assert "Hello" in result
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_parse_upload_json():
|
||||
"""Parse a .json file returns its text."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as f:
|
||||
f.write('{"key": "value"}')
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
result = parse_upload(tmp_path)
|
||||
assert "key" in result
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_parse_upload_unsupported():
|
||||
"""Unsupported format raises ParseError."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as f:
|
||||
f.write(b"binary")
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
with pytest.raises(ParseError, match="Unsupported format"):
|
||||
parse_upload(tmp_path)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
def test_parse_upload_dict():
|
||||
"""parse_upload accepts dict with name+path (Gradio file format)."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
|
||||
f.write("Dict test")
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
result = parse_upload({"name": "test.txt", "path": tmp_path})
|
||||
assert "Dict test" in result
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
# #endregion TestAgentChat.DocumentParser.ParseUpload
|
||||
# #endregion TestAgentChat.DocumentParser
|
||||
130
backend/tests/test_agent/test_model_fixtures.py
Normal file
130
backend/tests/test_agent/test_model_fixtures.py
Normal file
@@ -0,0 +1,130 @@
|
||||
# #region TestAgentChat.ModelFixtures [C:2] [TYPE Module] [SEMANTICS test,agent,fixtures,model]
|
||||
# @BRIEF Materialize model fixtures from JSON — verify fixture structure matches expected.
|
||||
# @RELATION BINDS_TO -> [AgentChat.Model]
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
FIXTURES_DIR = Path(__file__).resolve().parent.parent.parent.parent / "specs" / "033-gradio-agent-chat" / "fixtures" / "model"
|
||||
|
||||
|
||||
# #region TestAgentChat.ModelFixtures.SendMessageValid [C:2] [TYPE Function] [SEMANTICS test,fixture,send]
|
||||
# @BRIEF FX_AgentChat.Model.SendMessage.Valid — verify fixture structure.
|
||||
def test_fixture_send_message_valid():
|
||||
"""Load send_message_valid fixture and verify structural integrity."""
|
||||
fixture_path = FIXTURES_DIR / "send_message_valid.json"
|
||||
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
|
||||
|
||||
with open(fixture_path) as f:
|
||||
fixture = json.load(f)
|
||||
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Model.SendMessage.Valid"
|
||||
assert fixture["verifies"] == "AgentChat.Model"
|
||||
assert fixture["invariant"] == "StreamingStateGatesInput"
|
||||
assert fixture["input"]["action"] == "sendMessage"
|
||||
assert fixture["input"]["state_before"]["streamingState"] == "idle"
|
||||
assert fixture["expected"]["state_after"]["streamingState"] == "streaming"
|
||||
assert fixture["expected"]["state_after"]["isInputLocked"] is True
|
||||
assert fixture["expected"]["state_after"]["error"] is None
|
||||
# #endregion TestAgentChat.ModelFixtures.SendMessageValid
|
||||
|
||||
|
||||
# #region TestAgentChat.ModelFixtures.CancelGeneration [C:2] [TYPE Function] [SEMANTICS test,fixture,cancel]
|
||||
# @BRIEF FX_AgentChat.Model.CancelGeneration — verify cancel fixture structure.
|
||||
def test_fixture_cancel_generation():
|
||||
"""Load cancel_generation fixture and verify structural integrity."""
|
||||
fixture_path = FIXTURES_DIR / "cancel_generation.json"
|
||||
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
|
||||
|
||||
with open(fixture_path) as f:
|
||||
fixture = json.load(f)
|
||||
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Model.CancelGeneration"
|
||||
assert fixture["edge"] == "cancel_during_streaming"
|
||||
assert fixture["input"]["action"] == "cancelGeneration"
|
||||
expected = fixture["expected"]["state_after"]
|
||||
assert expected["streamingState"] == "idle"
|
||||
assert expected["isInputLocked"] is False
|
||||
assert "partialText" in expected
|
||||
# #endregion TestAgentChat.ModelFixtures.CancelGeneration
|
||||
|
||||
|
||||
# #region TestAgentChat.ModelFixtures.ResumeConfirm [C:2] [TYPE Function] [SEMANTICS test,fixture,confirm]
|
||||
# @BRIEF FX_AgentChat.Model.ResumeConfirm — verify resume confirm fixture.
|
||||
def test_fixture_resume_confirm():
|
||||
"""Load resume_confirm fixture and verify structural integrity."""
|
||||
fixture_path = FIXTURES_DIR / "resume_confirm.json"
|
||||
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
|
||||
|
||||
with open(fixture_path) as f:
|
||||
fixture = json.load(f)
|
||||
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Model.ResumeConfirm"
|
||||
assert fixture["edge"] == "confirm_from_awaiting"
|
||||
assert fixture["input"]["action"] == "resumeConfirm"
|
||||
assert fixture["input"]["args"] == ["confirm"]
|
||||
expected = fixture["expected"]["state_after"]
|
||||
assert expected["streamingState"] == "streaming"
|
||||
assert expected["pendingThreadId"] is None
|
||||
# #endregion TestAgentChat.ModelFixtures.ResumeConfirm
|
||||
|
||||
|
||||
# #region TestAgentChat.ModelFixtures.ResumeDeny [C:2] [TYPE Function] [SEMANTICS test,fixture,deny]
|
||||
# @BRIEF FX_AgentChat.Model.ResumeDeny — verify resume deny fixture.
|
||||
def test_fixture_resume_deny():
|
||||
"""Load resume_deny fixture and verify structural integrity."""
|
||||
fixture_path = FIXTURES_DIR / "resume_deny.json"
|
||||
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
|
||||
|
||||
with open(fixture_path) as f:
|
||||
fixture = json.load(f)
|
||||
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Model.ResumeDeny"
|
||||
assert fixture["edge"] == "deny_from_awaiting"
|
||||
assert fixture["input"]["action"] == "resumeConfirm"
|
||||
assert fixture["input"]["args"] == ["deny"]
|
||||
expected = fixture["expected"]["state_after"]
|
||||
assert expected["streamingState"] == "idle"
|
||||
assert expected["pendingThreadId"] is None
|
||||
# #endregion TestAgentChat.ModelFixtures.ResumeDeny
|
||||
|
||||
|
||||
# #region TestAgentChat.ModelFixtures.RejectedWebSocket [C:2] [TYPE Function] [SEMANTICS test,fixture,rejected]
|
||||
# @BRIEF FX_AgentChat.Model.RejectedPath — verify no WebSocket resurrection.
|
||||
def test_fixture_rejected_websocket():
|
||||
"""Verify no WebSocket resurrection in AgentChatModel."""
|
||||
fixture_path = FIXTURES_DIR / "rejected_websocket.json"
|
||||
assert fixture_path.exists(), f"Fixture not found: {fixture_path}"
|
||||
|
||||
with open(fixture_path) as f:
|
||||
fixture = json.load(f)
|
||||
|
||||
assert fixture["fixture_id"] == "FX_AgentChat.Model.RejectedPath"
|
||||
assert fixture["invariant"] == "NoWebSocketImports"
|
||||
assert fixture["edge"] == "rejected_path"
|
||||
assert "No 'WebSocket'" in fixture["expected"]["assertions"][0]
|
||||
|
||||
# Read the actual model source
|
||||
model_path = Path(__file__).resolve().parent.parent.parent.parent / "frontend" / "src" / "lib" / "models" / "AgentChatModel.svelte.ts"
|
||||
source = model_path.read_text()
|
||||
|
||||
# Verify no WebSocket imports (not just the word — it's in comments as @REJECTED)
|
||||
assertions = fixture["expected"]["assertions"]
|
||||
for assertion in assertions:
|
||||
if "WebSocket" in assertion:
|
||||
# Check for actual WebSocket import (import WebSocket, from ... import ... WebSocket)
|
||||
for line in source.split("\n"):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("import") and "WebSocket" in stripped:
|
||||
pytest.fail(f"FAIL: {assertion} — found WebSocket import: {stripped}")
|
||||
if stripped.startswith("from") and "WebSocket" in stripped:
|
||||
pytest.fail(f"FAIL: {assertion} — found WebSocket import: {stripped}")
|
||||
if "tabRole" in assertion:
|
||||
assert "tabRole" not in source, f"FAIL: {assertion}"
|
||||
if "follower_notify" in assertion:
|
||||
assert "follower_notify" not in source, f"FAIL: {assertion}"
|
||||
if "takeoverSession" in assertion:
|
||||
assert "takeoverSession" not in source, f"FAIL: {assertion}"
|
||||
# #endregion TestAgentChat.ModelFixtures.RejectedWebSocket
|
||||
# #endregion TestAgentChat.ModelFixtures
|
||||
Reference in New Issue
Block a user