import json
import unittest
from unittest.mock import patch
from pydantic import ValidationError
from app import DEPTHS, SEARCH_CACHE, ResearchReport, SectionPlan, align_sections_to_plan, cached_search, chat_json, deduplicate, infer_section_source_ids, normalize_comparison, normalize_process, parse_json_object, prepare_evidence_inputs, preview_frame, render_presentation, render_section_content, render_value, safe_slug, search_literature, serializable_validation_errors
def base_report(section):
return {
"title": "Test report", "subtitle": "Topic-adaptive structured output",
"research_type": "engineering decision",
"executive_summary": ["A substantive conclusion supported by the report."],
"sections": [dict(section, id=f"section-{i}") for i in range(3)],
"references": [], "limitations": ["Recent literature was not externally verified."],
"english_understanding": {},
}
class AppTests(unittest.TestCase):
def test_slug(self):
self.assertEqual(safe_slug("Diffusion Transformer 研究"), "diffusion-transformer")
def test_deduplicate_versions(self):
self.assertEqual(len(deduplicate([{"id": "1234.5678v1"}, {"id": "1234.5678v2"}])), 1)
def test_evidence_inputs_are_bounded_and_prioritize_deep_reads(self):
papers = [{"id": str(i), "title": f"Paper {i}", "summary": "Summary"} for i in range(10)]
result = prepare_evidence_inputs(papers, {"8": "deep text", "9": "deep text"}, 3)
self.assertEqual(len(result), 3)
self.assertEqual([item["id"] for item in result[:2]], ["8", "9"])
self.assertIn("selected_text_extract", result[0])
@patch("app.openalex_search", return_value=[])
@patch("app.arxiv_search")
def test_search_balances_multiple_query_intents(self, mocked_arxiv, _mocked_openalex):
mocked_arxiv.side_effect = [
[{"id": f"classic-{i}", "title": f"Classic {i}"} for i in range(5)],
[{"id": f"recent-{i}", "title": f"Recent {i}"} for i in range(5)],
]
papers, warnings = search_literature(["classic", "recent"], 4)
self.assertEqual([paper["id"] for paper in papers], ["classic-0", "recent-0", "classic-1", "recent-1"])
self.assertEqual(warnings, [])
@patch("app.openalex_search", return_value=[{"id": "oa-1", "title": "Fallback"}])
@patch("app.arxiv_search", return_value=[])
def test_search_uses_openalex_when_arxiv_returns_empty(self, _mocked_arxiv, mocked_openalex):
papers, _ = search_literature(["topic"], 5)
self.assertEqual(papers[0]["id"], "oa-1")
mocked_openalex.assert_called_once()
@patch("app.openalex_search", side_effect=TimeoutError("offline"))
@patch("app.arxiv_search", side_effect=TimeoutError("offline"))
def test_search_stops_repeating_failed_providers(self, mocked_arxiv, mocked_openalex):
papers, warnings = search_literature([f"query-{i}" for i in range(5)], 20)
self.assertEqual(papers, [])
self.assertEqual(mocked_arxiv.call_count, 2)
self.assertEqual(mocked_openalex.call_count, 2)
self.assertTrue(any("已停止剩余重复请求" in warning for warning in warnings))
def test_search_cache_avoids_duplicate_external_calls(self):
SEARCH_CACHE.clear()
search_fn = unittest.mock.Mock(return_value=[{"id": "cached", "title": "Cached paper"}])
first = cached_search("test-provider", "same query", 5, search_fn)
second = cached_search("test-provider", "same query", 5, search_fn)
self.assertEqual(first, second)
self.assertEqual(search_fn.call_count, 1)
def test_depth_increases_analysis_not_unbounded_search_calls(self):
self.assertEqual([DEPTHS[name]["queries"] for name in ("quick", "standard", "deep")], [2, 3, 4])
self.assertEqual([DEPTHS[name]["deep"] for name in ("quick", "standard", "deep")], [2, 4, 8])
def test_missing_standard_and_deep_sections_are_exposed_for_repair(self):
plans = [
SectionPlan(id=f"section-{i}", title=f"Section {i}", purpose="Explain one distinct technical decision.", layout="text")
for i in range(4)
]
partial = {"sections": [{"id": "section-0", "takeaway": "Only the first section was returned.", "content": {"points": ["A substantive point"]}}]}
aligned = align_sections_to_plan(partial, plans)
self.assertEqual(len(aligned["sections"]), 4)
self.assertEqual([item["id"] for item in aligned["sections"]], [plan.id for plan in plans])
self.assertEqual(aligned["sections"][1]["content"], {})
def test_parser_removes_invalid_string_line_continuation(self):
self.assertEqual(parse_json_object('{"title":"A\\\nB","value":1}'), {"title": "AB", "value": 1})
@patch("app.generate_text", side_effect=['{"title":"broken\\\n', '{"title":"valid"}'])
def test_json_generation_retries_malformed_response(self, mocked_generate):
self.assertEqual(chat_json("system", "prompt", 2048), {"title": "valid"})
self.assertEqual(mocked_generate.call_count, 2)
self.assertIn("Regenerate from scratch", mocked_generate.call_args.args[0])
def test_render_is_downloadable_document(self):
section = {"title": "Scope", "purpose": "Define the technical decision boundary.", "takeaway": "The boundary determines the useful evidence.", "layout": "text", "content": {"points": ["A meaningful point", "Another meaningful point"]}, "source_ids": []}
output = render_presentation(base_report(section), [], "2026-01-01")
self.assertIn("", output)
self.assertIn('', output)
self.assertIn('id="overview"', output)
self.assertIn('id="agenda"', output)
self.assertIn('data-slide-target="section-1"', output)
def test_preview_allows_browser_print_dialog(self):
frame = preview_frame("
Deck")
self.assertIn('sandbox="allow-scripts allow-popups allow-modals"', frame)
def test_source_chip_links_to_rendered_reference(self):
section = {"title": "Evidence", "purpose": "Connect a claim to retrieved evidence.", "takeaway": "The cited result supports this bounded conclusion.", "layout": "evidence", "content": {"claim": "A supported claim", "conditions": ["A stated condition"]}, "source_ids": ["paper-1"]}
report = base_report(section)
report["references"] = ["paper-1"]
paper = {"id": "paper-1", "title": "A Retrieved Paper", "authors": ["A. Author"], "published": "2025-01-01", "url": "https://example.com/paper"}
output = render_presentation(report, [paper], "2026-01-01")
self.assertIn('href="#ref-paper-1"', output)
self.assertIn('id="ref-paper-1"', output)
def test_empty_report_is_rejected(self):
with self.assertRaises(ValidationError):
ResearchReport.model_validate({})
def test_technical_evolution_report(self):
section = {"title": "Evolution", "purpose": "Explain changes across technical generations.", "takeaway": "Each generation resolves a prior bottleneck.", "layout": "timeline", "content": {"nodes": [{"year": "2020", "title": "A", "innovation": "First mechanism"}, {"year": "2024", "title": "B", "innovation": "Second mechanism"}]}, "source_ids": []}
self.assertEqual(ResearchReport.model_validate(base_report(section)).sections[0].layout, "timeline")
def test_paper_analysis_report(self):
section = {"title": "Paper", "purpose": "Assess the paper contribution and evidence.", "takeaway": "The method trades accuracy for efficiency.", "layout": "paper-analysis", "content": {"method": "A detailed method", "evidence": ["A reported experiment"], "limitations": ["A narrow benchmark"]}, "source_ids": []}
self.assertEqual(ResearchReport.model_validate(base_report(section)).sections[0].layout, "paper-analysis")
def test_evidence_gap_report(self):
section = {"title": "Evidence gap", "purpose": "Separate known facts from hypotheses.", "takeaway": "More evidence is required before choosing a method.", "layout": "evidence-gap", "content": {"interpretation": "The term may be ambiguous.", "related_topics": ["Adjacent field"], "hypotheses": ["Possible interpretation"], "required_inputs": ["Definition or internal data"]}, "source_ids": []}
self.assertEqual(ResearchReport.model_validate(base_report(section)).sections[0].layout, "evidence-gap")
def test_evidence_gap_accepts_equivalent_semantic_fields(self):
section = {"title": "Evidence gap", "purpose": "Separate available evidence from unresolved questions.", "takeaway": "Deployment requires evidence that is not yet public.", "layout": "evidence-gap", "content": {"known_evidence": ["A related result exists"], "unknowns": ["No direct benchmark"], "next_steps": ["Run an internal evaluation"]}, "source_ids": []}
validated = ResearchReport.model_validate(base_report(section))
self.assertEqual(validated.sections[0].content["unknowns"], ["No direct benchmark"])
def test_paper_analysis_accepts_equivalent_semantic_fields(self):
section = {"title": "Paper analysis", "purpose": "Analyze a paper without enforcing field spelling.", "takeaway": "The result is useful under a bounded condition.", "layout": "paper-analysis", "content": {"method_summary": "A compact architecture", "experimental_results": ["A controlled comparison"], "caveats": ["A narrow dataset"]}, "source_ids": []}
self.assertEqual(ResearchReport.model_validate(base_report(section)).sections[0].layout, "paper-analysis")
def test_invalid_empty_section(self):
section = {"title": "Empty", "purpose": "This section should fail validation.", "takeaway": "There is no substantive supporting content.", "layout": "text", "content": {}, "source_ids": []}
with self.assertRaises(ValidationError):
ResearchReport.model_validate(base_report(section))
def test_validation_feedback_is_json_serializable(self):
section = {"title": "Invalid", "purpose": "Trigger a custom validator error.", "takeaway": "The timeline is intentionally incomplete.", "layout": "timeline", "content": {"nodes": []}, "source_ids": []}
try:
ResearchReport.model_validate(base_report(section))
except ValidationError as exc:
errors = serializable_validation_errors(exc)
else:
self.fail("Expected report validation to fail")
json.dumps(errors)
self.assertNotIn("ctx", errors[0])
def test_comparison_requires_rows(self):
section = {"title": "Compare", "purpose": "Compare engineering alternatives fairly.", "takeaway": "No choice is universally optimal here.", "layout": "comparison", "content": {"columns": ["speed", "quality"], "rows": []}, "source_ids": []}
with self.assertRaises(ValidationError):
ResearchReport.model_validate(base_report(section))
def test_nested_json_is_rendered_as_semantic_html(self):
output = render_value({"findings": [{"claim": "Works well", "conditions": ["GPU", "FP16"]}]})
self.assertIn("", output)
self.assertIn("Works well", output)
self.assertIn("GPU", output)
self.assertNotIn("{"", output)
self.assertNotIn("['GPU'", output)
def test_detail_and_subpoints_stay_in_one_point_card(self):
output = render_value({
"detail": "生成模型学习数据分布。",
"subpoints": ["显式密度模型", "隐式生成模型", "扩散模型"],
})
self.assertIn('class="point-detail"', output)
self.assertIn('class="point-subpoints"', output)
self.assertNotIn("Detail
", output)
self.assertNotIn("Subpoints
", output)
self.assertLess(output.index("生成模型学习数据分布"), output.index("显式密度模型"))
def test_timeline_keeps_nonstandard_fields(self):
output = render_section_content({"layout": "timeline", "content": {"nodes": [
{"stage": "Early", "method": "Baseline", "problem": "High cost", "tradeoffs": ["Memory", "Latency"]},
{"stage": "Later", "method": "Improved", "problem": "Low recall", "tradeoffs": ["Complexity"]},
]}})
self.assertIn("High cost", output)
self.assertIn("Memory", output)
self.assertNotIn("{'", output)
def test_comparison_does_not_duplicate_dimension_column(self):
content = {
"columns": ["方法维度", "Debevec & Malik (1997) - HDR重建", "Mertens et al. (2007) - 多曝光融合"],
"rows": [
{"name": "核心目标", "values": ["恢复场景物理辐射度图", "直接生成视觉质量高的显示图像"]},
{"dimension": "是否需要CRF标定", "values": ["是", "否"]},
],
}
row_label, columns, rows = normalize_comparison(content)
self.assertEqual(row_label, "方法维度")
self.assertEqual(len(columns), 2)
self.assertTrue(all(len(values) == 2 for _, values in rows))
output = render_section_content({"layout": "comparison", "content": content})
self.assertEqual(output.count("方法维度 | "), 1)
self.assertIn("恢复场景物理辐射度图", output)
def test_process_is_split_into_interactive_branches(self):
content = {"steps": [
{"name": "输入多曝光LDR图像序列", "description": "公共输入"},
{"name": "HDR重建:辐射度标定", "description": "恢复CRF"},
{"name": "HDR重建:权重融合", "description": "辐射域融合"},
{"name": "多曝光融合:多尺度分解", "description": "拉普拉斯金字塔"},
{"name": "多曝光融合:权重图生成与融合", "description": "质量权重"},
]}
common, branches = normalize_process(content)
self.assertEqual(len(common), 1)
self.assertEqual([branch["name"] for branch in branches], ["HDR重建", "多曝光融合"])
output = render_section_content({"layout": "process", "content": content})
self.assertIn("flow-branches", output)
self.assertIn("