- Move agent code from backend/src/agent/ to agent/src/ss_tools/agent/ - Extract shared stdlib-only utilities to shared/src/ss_tools/shared/ - Add #region/#endregion contracts to all ~140 functions (INV_1 compliance) - Update docker files, entrypoint, build scripts for new package layout - Backend now imports ss_tools.shared._llm_health (no gradio/langchain deps) - Add specs for 036-039 feature plans
304 lines
10 KiB
Python
304 lines
10 KiB
Python
# #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
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from ss_tools.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)
|
|
|
|
|
|
def test_parse_upload_file_path_fallback():
|
|
"""parse_upload falls back to file_path key if path is missing."""
|
|
with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f:
|
|
f.write("Fallback key test")
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
result = parse_upload({"name": "test.txt", "file_path": tmp_path})
|
|
assert "Fallback" in result
|
|
finally:
|
|
os.unlink(tmp_path)
|
|
# #endregion TestAgentChat.DocumentParser.ParseUpload
|
|
|
|
|
|
# #region TestAgentChat.DocumentParser.ImportErrors [C:2] [TYPE Function] [SEMANTICS test,parser,import,error]
|
|
# @BRIEF Test ImportError paths in parse_pdf and parse_xlsx.
|
|
|
|
def test_parse_pdf_import_error():
|
|
"""pdfplumber import fails → ParseError."""
|
|
import builtins
|
|
real_import = builtins.__import__
|
|
|
|
def raise_on_pdfplumber(name, globals=None, locals=None, fromlist=(), level=0):
|
|
if name == 'pdfplumber':
|
|
raise ImportError(f"No module named pdfplumber", name=name)
|
|
return real_import(name, globals, locals, fromlist, level)
|
|
|
|
with patch('builtins.__import__', side_effect=raise_on_pdfplumber):
|
|
with pytest.raises(ParseError, match="pdfplumber not installed"):
|
|
parse_pdf("/tmp/dummy.pdf")
|
|
|
|
|
|
def test_parse_xlsx_import_error():
|
|
"""openpyxl import fails → ParseError."""
|
|
import builtins
|
|
real_import = builtins.__import__
|
|
|
|
def raise_on_openpyxl(name, globals=None, locals=None, fromlist=(), level=0):
|
|
if name == 'openpyxl':
|
|
raise ImportError(f"No module named openpyxl", name=name)
|
|
return real_import(name, globals, locals, fromlist, level)
|
|
|
|
with patch('builtins.__import__', side_effect=raise_on_openpyxl):
|
|
with pytest.raises(ParseError, match="openpyxl not installed"):
|
|
parse_xlsx("/tmp/dummy.xlsx")
|
|
# #endregion TestAgentChat.DocumentParser.ImportErrors
|
|
|
|
|
|
# #region TestAgentChat.DocumentParser.PyPDF2Fallback [C:2] [TYPE Function] [SEMANTICS test,parser,pypdf2,fallback]
|
|
# @BRIEF When pdfplumber fails, PyPDF2 is used as fallback path.
|
|
|
|
def test_parse_pdf_pypdf2_fallback():
|
|
"""pdfplumber failure triggers PyPDF2 fallback."""
|
|
# Build mock PyPDF2 module in sys.modules
|
|
mock_pypdf2 = MagicMock()
|
|
mock_reader = MagicMock()
|
|
mock_page = MagicMock()
|
|
mock_page.extract_text.return_value = "Fallback PyPDF2 text"
|
|
mock_reader.pages = [mock_page]
|
|
mock_pypdf2.PdfReader = MagicMock(return_value=mock_reader)
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
|
f.write(b"dummy pdf content")
|
|
tmp_path = f.name
|
|
|
|
try:
|
|
# Patch pdfplumber.open directly (the real module) — NOT document_parser.pdfplumber
|
|
# because pdfplumber is imported inside the function body, not at module level.
|
|
with patch('pdfplumber.open', side_effect=Exception("pdfplumber error")), \
|
|
patch.dict('sys.modules', {'PyPDF2': mock_pypdf2}):
|
|
result = parse_pdf(tmp_path)
|
|
assert isinstance(result, str)
|
|
assert "PyPDF2" in result
|
|
mock_pypdf2.PdfReader.assert_called_once()
|
|
finally:
|
|
os.unlink(tmp_path)
|
|
# #endregion TestAgentChat.DocumentParser.PyPDF2Fallback
|
|
|
|
|
|
# #region TestAgentChat.DocumentParser.ParseUploadBranches [C:2] [TYPE Function] [SEMANTICS test,parser,upload,branches]
|
|
# @BRIEF Test parse_upload dispatches to correct parser for PDF, XLSX, XLS.
|
|
|
|
def test_parse_upload_pdf_branch():
|
|
"""parse_upload with .pdf dispatches to parse_pdf."""
|
|
import ss_tools.agent.document_parser as dp
|
|
with patch.object(dp, 'parse_pdf', return_value="pdf result") as mock_parse:
|
|
result = parse_upload({"name": "report.pdf", "path": "/tmp/report.pdf"})
|
|
assert result == "pdf result"
|
|
mock_parse.assert_called_once_with("/tmp/report.pdf")
|
|
|
|
|
|
def test_parse_upload_xlsx_branch():
|
|
"""parse_upload with .xlsx dispatches to parse_xlsx."""
|
|
import ss_tools.agent.document_parser as dp
|
|
with patch.object(dp, 'parse_xlsx', return_value="xlsx result") as mock_parse:
|
|
result = parse_upload({"name": "data.xlsx", "path": "/tmp/data.xlsx"})
|
|
assert result == "xlsx result"
|
|
mock_parse.assert_called_once_with("/tmp/data.xlsx")
|
|
|
|
|
|
def test_parse_upload_xls_branch():
|
|
"""parse_upload with .xls (legacy) also dispatches to parse_xlsx."""
|
|
import ss_tools.agent.document_parser as dp
|
|
with patch.object(dp, 'parse_xlsx', return_value="xls result") as mock_parse:
|
|
result = parse_upload({"name": "legacy.xls", "path": "/tmp/legacy.xls"})
|
|
assert result == "xls result"
|
|
mock_parse.assert_called_once_with("/tmp/legacy.xls")
|
|
# #endregion TestAgentChat.DocumentParser.ParseUploadBranches
|
|
# #endregion TestAgentChat.DocumentParser
|