chore: import upstream snapshot with attribution
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests for text optimization module."""
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Edge-case tests for citation exporters — math mode, list EOF, BibTeX URL, and RIS unicode."""
|
||||
|
||||
|
||||
class TestInlineMathPreservedBetweenDollars:
|
||||
"""Verify content between $ markers is NOT escaped during LaTeX export."""
|
||||
|
||||
def test_inline_math_preserved_between_dollars(self):
|
||||
"""$x^2 + y^2$ should keep ^ and + unescaped inside math mode."""
|
||||
from local_deep_research.text_optimization.citation_formatter import (
|
||||
LaTeXExporter,
|
||||
)
|
||||
|
||||
exporter = LaTeXExporter()
|
||||
content = "# Title\n\nThe formula $x^2 + y^2 = r^2$ is important."
|
||||
result = exporter.export_to_latex(content)
|
||||
|
||||
# Math content between $ should be preserved (not escaped)
|
||||
assert "$x^2 + y^2 = r^2$" in result
|
||||
|
||||
|
||||
class TestDisplayMathDoubleDollarPreserved:
|
||||
"""Verify $$...$$ display math blocks are preserved during LaTeX export."""
|
||||
|
||||
def test_display_math_double_dollar_preserved(self):
|
||||
"""$$E = mc^2$$ should preserve the math content unescaped."""
|
||||
from local_deep_research.text_optimization.citation_formatter import (
|
||||
LaTeXExporter,
|
||||
)
|
||||
|
||||
exporter = LaTeXExporter()
|
||||
content = "# Title\n\n$$E = mc^2$$\n\nSome text."
|
||||
result = exporter.export_to_latex(content)
|
||||
|
||||
# The display math content should be preserved
|
||||
assert "E = mc^2" in result
|
||||
# The ^ should NOT be escaped to \textasciicircum{} inside math
|
||||
assert r"\textasciicircum" not in result
|
||||
|
||||
|
||||
class TestListAtEndOfContentGetsClosingTag:
|
||||
"""Verify list items at EOF trigger \\end{itemize} via _convert_lists lines 940-941."""
|
||||
|
||||
def test_list_at_end_of_content_gets_closing_tag(self):
|
||||
"""List items at end of content (no trailing text) should get \\end{itemize}."""
|
||||
from local_deep_research.text_optimization.citation_formatter import (
|
||||
LaTeXExporter,
|
||||
)
|
||||
|
||||
exporter = LaTeXExporter()
|
||||
# Content ends with list items, no trailing non-list text
|
||||
content = "# Title\n\nSome intro text.\n\n- Item one\n- Item two\n- Item three"
|
||||
result = exporter.export_to_latex(content)
|
||||
|
||||
begin_count = result.count("\\begin{itemize}")
|
||||
end_count = result.count("\\end{itemize}")
|
||||
assert begin_count == end_count == 1
|
||||
# The last \end{itemize} should be after the last \item
|
||||
last_item_pos = result.rfind("\\item")
|
||||
last_end_pos = result.rfind("\\end{itemize}")
|
||||
assert last_end_pos > last_item_pos
|
||||
|
||||
|
||||
class TestSourceWithoutUrlOmitsBibtexUrlField:
|
||||
"""Verify BibTeX entry omits url= field when source has no URL."""
|
||||
|
||||
def test_source_without_url_omits_bibtex_url_field(self):
|
||||
"""[1] Title without URL line → BibTeX entry should not contain url = {...}."""
|
||||
from local_deep_research.text_optimization.citation_formatter import (
|
||||
QuartoExporter,
|
||||
)
|
||||
|
||||
exporter = QuartoExporter()
|
||||
# Source without URL line
|
||||
content = "# Report\n\nSome text [1].\n\n## Sources\n[1] A Book Title Without URL"
|
||||
bib_content = exporter._generate_bibliography(content)
|
||||
|
||||
assert "ref1" in bib_content
|
||||
assert "A Book Title Without URL" in bib_content
|
||||
# Should NOT have url field since no URL was provided
|
||||
assert "url = " not in bib_content
|
||||
assert "howpublished" not in bib_content
|
||||
|
||||
|
||||
class TestRisExporterHandlesUnicodeTitle:
|
||||
"""Verify RIS exporter preserves Unicode characters in TI field."""
|
||||
|
||||
def test_ris_exporter_handles_unicode_title(self):
|
||||
"""Unicode title '日本語' in RIS TI field should be preserved."""
|
||||
from local_deep_research.text_optimization.citation_formatter import (
|
||||
RISExporter,
|
||||
)
|
||||
|
||||
exporter = RISExporter()
|
||||
content = (
|
||||
"## Sources\n[1] 日本語の研究論文\n URL: https://example.jp/paper"
|
||||
)
|
||||
ris = exporter.export_to_ris(content)
|
||||
|
||||
assert "TI - " in ris
|
||||
assert "日本語" in ris
|
||||
assert "UR - https://example.jp/paper" in ris
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,167 @@
|
||||
"""Edge-case tests for citation_formatter — idempotency, parsing, and export boundaries."""
|
||||
|
||||
|
||||
def _make_document(body, sources):
|
||||
"""Helper: combine body text with a sources section."""
|
||||
return body + "\n\n## Sources\n" + sources
|
||||
|
||||
|
||||
class TestAlreadyFormattedCitations:
|
||||
"""Ensure already-formatted citations are not double-processed."""
|
||||
|
||||
def test_already_formatted_not_rematched(self):
|
||||
"""[[1]](url) should NOT be re-formatted to [[[1]]](url)."""
|
||||
from local_deep_research.text_optimization.citation_formatter import (
|
||||
CitationFormatter,
|
||||
CitationMode,
|
||||
)
|
||||
|
||||
formatter = CitationFormatter(CitationMode.NUMBER_HYPERLINKS)
|
||||
doc = _make_document(
|
||||
"See [[1]](https://example.com) for details.",
|
||||
"[1] Example Source\n URL: https://example.com",
|
||||
)
|
||||
result = formatter.format_document(doc)
|
||||
# The negative lookbehind (?<![\[) should prevent matching inside [[ ]]
|
||||
assert "[[[1]]]" not in result
|
||||
|
||||
def test_format_document_idempotent(self):
|
||||
"""Calling format_document twice produces identical output."""
|
||||
from local_deep_research.text_optimization.citation_formatter import (
|
||||
CitationFormatter,
|
||||
CitationMode,
|
||||
)
|
||||
|
||||
formatter = CitationFormatter(CitationMode.NUMBER_HYPERLINKS)
|
||||
doc = _make_document(
|
||||
"See [1] for details.",
|
||||
"[1] Example Source\n URL: https://example.com",
|
||||
)
|
||||
first = formatter.format_document(doc)
|
||||
second = formatter.format_document(first)
|
||||
assert first == second
|
||||
|
||||
|
||||
class TestSourceParsing:
|
||||
"""Tests for _parse_sources edge cases."""
|
||||
|
||||
def test_parse_sources_tab_indented_url(self):
|
||||
"""Tab indentation in \\tURL: should be parsed."""
|
||||
from local_deep_research.text_optimization.citation_formatter import (
|
||||
CitationFormatter,
|
||||
CitationMode,
|
||||
)
|
||||
|
||||
formatter = CitationFormatter(CitationMode.NUMBER_HYPERLINKS)
|
||||
sources_text = "## Sources\n[1] Title here\n\tURL: https://example.com"
|
||||
sources = formatter._parse_sources(sources_text)
|
||||
# The regex uses \\s* which matches tabs
|
||||
assert "1" in sources
|
||||
_, url = sources["1"]
|
||||
assert url == "https://example.com"
|
||||
|
||||
|
||||
class TestDomainIdAssignment:
|
||||
"""Tests for domain ID assignment order."""
|
||||
|
||||
def test_domain_id_assignment_preserves_source_order(self):
|
||||
"""Source 1 gets -1, source 5 gets -2 deterministically for same domain."""
|
||||
from local_deep_research.text_optimization.citation_formatter import (
|
||||
CitationFormatter,
|
||||
CitationMode,
|
||||
)
|
||||
|
||||
formatter = CitationFormatter(CitationMode.DOMAIN_ID_ALWAYS_HYPERLINKS)
|
||||
doc = _make_document(
|
||||
"See [1] and [5].",
|
||||
"[1] First Article\n URL: https://arxiv.org/abs/1234\n"
|
||||
"[5] Second Article\n URL: https://arxiv.org/abs/5678",
|
||||
)
|
||||
result = formatter.format_document(doc)
|
||||
# Both are from arxiv.org, should get -1 and -2
|
||||
assert "arxiv.org-1" in result
|
||||
assert "arxiv.org-2" in result
|
||||
|
||||
|
||||
class TestRISExporterBoundary:
|
||||
"""Tests for RIS exporter deduplication boundary."""
|
||||
|
||||
def test_ris_exporter_stops_at_all_sources(self):
|
||||
"""Deduplication boundary at '## ALL SOURCES' section."""
|
||||
from local_deep_research.text_optimization.citation_formatter import (
|
||||
RISExporter,
|
||||
)
|
||||
|
||||
exporter = RISExporter()
|
||||
content = (
|
||||
"## Sources\n"
|
||||
"[1] First Source\n URL: https://example.com\n\n"
|
||||
"## ALL SOURCES\n"
|
||||
"[1] First Source (duplicate)\n URL: https://example.com\n"
|
||||
"[2] Second Source\n URL: https://other.com"
|
||||
)
|
||||
ris = exporter.export_to_ris(content)
|
||||
# Should only include source [1] from before the ALL SOURCES marker
|
||||
assert "ref1" in ris
|
||||
assert "ref2" not in ris
|
||||
|
||||
|
||||
class TestLaTeXListBalance:
|
||||
"""Tests for LaTeX list conversion."""
|
||||
|
||||
def test_latex_list_balanced_with_empty_lines(self):
|
||||
"""\\begin{itemize} and \\end{itemize} are always balanced."""
|
||||
from local_deep_research.text_optimization.citation_formatter import (
|
||||
LaTeXExporter,
|
||||
)
|
||||
|
||||
exporter = LaTeXExporter()
|
||||
content = (
|
||||
"# Title\n\n- Item one\n- Item two\n\nSome text\n\n- Item three\n"
|
||||
)
|
||||
result = exporter.export_to_latex(content)
|
||||
begin_count = result.count("\\begin{itemize}")
|
||||
end_count = result.count("\\end{itemize}")
|
||||
assert begin_count == end_count
|
||||
assert begin_count >= 1
|
||||
|
||||
|
||||
class TestSourceWordPattern:
|
||||
"""Tests for 'Source X' pattern matching."""
|
||||
|
||||
def test_source_word_with_trailing_period(self):
|
||||
"""'Source 1.' at end-of-sentence converts correctly."""
|
||||
from local_deep_research.text_optimization.citation_formatter import (
|
||||
CitationFormatter,
|
||||
CitationMode,
|
||||
)
|
||||
|
||||
formatter = CitationFormatter(CitationMode.NUMBER_HYPERLINKS)
|
||||
doc = _make_document(
|
||||
"According to Source 1.",
|
||||
"[1] Example\n URL: https://example.com",
|
||||
)
|
||||
result = formatter.format_document(doc)
|
||||
# "Source 1" should be replaced; the period remains
|
||||
assert "Source 1" not in result.split("## Sources")[0]
|
||||
|
||||
def test_comma_citations_with_spaces(self):
|
||||
"""[1, 2, 3] with varying space patterns."""
|
||||
from local_deep_research.text_optimization.citation_formatter import (
|
||||
CitationFormatter,
|
||||
CitationMode,
|
||||
)
|
||||
|
||||
formatter = CitationFormatter(CitationMode.NUMBER_HYPERLINKS)
|
||||
doc = _make_document(
|
||||
"See [1, 2, 3] for details.",
|
||||
"[1] Source A\n URL: https://a.com\n"
|
||||
"[2] Source B\n URL: https://b.com\n"
|
||||
"[3] Source C\n URL: https://c.com",
|
||||
)
|
||||
result = formatter.format_document(doc)
|
||||
body = result.split("## Sources")[0]
|
||||
# Each citation should be individually formatted
|
||||
assert "[[1]]" in body
|
||||
assert "[[2]]" in body
|
||||
assert "[[3]]" in body
|
||||
@@ -0,0 +1,380 @@
|
||||
"""High-value pure logic tests for citation_formatter.py."""
|
||||
|
||||
import pytest
|
||||
|
||||
from local_deep_research.text_optimization.citation_formatter import (
|
||||
CitationFormatter,
|
||||
CitationMode,
|
||||
LaTeXExporter,
|
||||
QuartoExporter,
|
||||
RISExporter,
|
||||
find_sources_section,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CitationMode enum
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCitationMode:
|
||||
def test_enum_values(self):
|
||||
assert CitationMode.NUMBER_HYPERLINKS.value == "number_hyperlinks"
|
||||
assert CitationMode.DOMAIN_HYPERLINKS.value == "domain_hyperlinks"
|
||||
assert CitationMode.DOMAIN_ID_HYPERLINKS.value == "domain_id_hyperlinks"
|
||||
assert (
|
||||
CitationMode.DOMAIN_ID_ALWAYS_HYPERLINKS.value
|
||||
== "domain_id_always_hyperlinks"
|
||||
)
|
||||
assert (
|
||||
CitationMode.SOURCE_TAGGED_HYPERLINKS.value
|
||||
== "source_tagged_hyperlinks"
|
||||
)
|
||||
assert CitationMode.NO_HYPERLINKS.value == "no_hyperlinks"
|
||||
|
||||
def test_enum_member_count(self):
|
||||
# NUMBER_HYPERLINKS, DOMAIN_HYPERLINKS, DOMAIN_ID_HYPERLINKS,
|
||||
# DOMAIN_ID_ALWAYS_HYPERLINKS, SOURCE_TAGGED_HYPERLINKS, NO_HYPERLINKS
|
||||
assert len(CitationMode) == 6
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_sources_section
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindSourcesSection:
|
||||
def test_markdown_heading_sources(self):
|
||||
text = "Some text\n## Sources\n[1] Foo"
|
||||
assert find_sources_section(text) == text.index("## Sources")
|
||||
|
||||
def test_markdown_heading_references(self):
|
||||
text = "Body\n# References\n[1] Bar"
|
||||
assert find_sources_section(text) == text.index("# References")
|
||||
|
||||
def test_plain_label_sources(self):
|
||||
text = "Body paragraph\nSources:\n[1] Item"
|
||||
assert find_sources_section(text) == text.index("Sources:")
|
||||
|
||||
def test_case_insensitive(self):
|
||||
text = "Body\n## BIBLIOGRAPHY\n[1] X"
|
||||
assert find_sources_section(text) != -1
|
||||
|
||||
def test_no_section_returns_negative_one(self):
|
||||
assert find_sources_section("No references here.") == -1
|
||||
|
||||
def test_empty_string(self):
|
||||
assert find_sources_section("") == -1
|
||||
|
||||
def test_citations_heading_detected(self):
|
||||
text = "Content\n### Citations\nSome refs"
|
||||
assert find_sources_section(text) == text.index("### Citations")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CitationFormatter._extract_domain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractDomain:
|
||||
def setup_method(self):
|
||||
self.fmt = CitationFormatter()
|
||||
|
||||
def test_known_domain_arxiv(self):
|
||||
assert (
|
||||
self.fmt._extract_domain("https://arxiv.org/abs/1234")
|
||||
== "arxiv.org"
|
||||
)
|
||||
|
||||
def test_known_domain_github(self):
|
||||
assert (
|
||||
self.fmt._extract_domain("https://github.com/user/repo")
|
||||
== "github.com"
|
||||
)
|
||||
|
||||
def test_www_prefix_stripped(self):
|
||||
assert (
|
||||
self.fmt._extract_domain("https://www.example.com/page")
|
||||
== "example.com"
|
||||
)
|
||||
|
||||
def test_subdomain_returns_main_domain(self):
|
||||
assert (
|
||||
self.fmt._extract_domain("https://docs.python.org/3/")
|
||||
== "python.org"
|
||||
)
|
||||
|
||||
def test_empty_string_returns_empty(self):
|
||||
# urlparse("") produces empty netloc; no exception is raised
|
||||
assert self.fmt._extract_domain("") == ""
|
||||
|
||||
def test_no_scheme_url_returns_empty(self):
|
||||
# urlparse without scheme puts everything in path, netloc is empty
|
||||
assert self.fmt._extract_domain("not-a-url") == ""
|
||||
|
||||
def test_none_url_raises(self):
|
||||
# None triggers TypeError which is not caught by the handler
|
||||
with pytest.raises(TypeError):
|
||||
self.fmt._extract_domain(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CitationFormatter._parse_sources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseSources:
|
||||
def setup_method(self):
|
||||
self.fmt = CitationFormatter()
|
||||
|
||||
def test_basic_source_with_url(self):
|
||||
text = "[1] My Title\n URL: https://example.com"
|
||||
result = self.fmt._parse_sources(text)
|
||||
assert "1" in result
|
||||
assert result["1"] == ("My Title", "https://example.com")
|
||||
|
||||
def test_source_without_url(self):
|
||||
text = "[2] Title Only"
|
||||
result = self.fmt._parse_sources(text)
|
||||
assert "2" in result
|
||||
assert result["2"][1] == ""
|
||||
|
||||
def test_multiple_sources(self):
|
||||
text = "[1] First\n[2] Second"
|
||||
result = self.fmt._parse_sources(text)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_empty_string(self):
|
||||
result = self.fmt._parse_sources("")
|
||||
assert result == {}
|
||||
|
||||
def test_comma_separated_citation_numbers(self):
|
||||
text = "[3, 4] Shared Title\n URL: https://shared.com"
|
||||
result = self.fmt._parse_sources(text)
|
||||
assert "3" in result
|
||||
assert "4" in result
|
||||
assert result["3"][0] == "Shared Title"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CitationFormatter._replace_comma_citations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReplaceCommaCitations:
|
||||
def setup_method(self):
|
||||
self.fmt = CitationFormatter()
|
||||
|
||||
def test_comma_separated_replaced(self):
|
||||
lookup = {"1": ("T1", "http://a.com"), "2": ("T2", "http://b.com")}
|
||||
|
||||
def format_one(num, data):
|
||||
_, url = data
|
||||
return f"[[{num}]]({url})"
|
||||
|
||||
result = self.fmt._replace_comma_citations(
|
||||
"See [1, 2] here", lookup, format_one
|
||||
)
|
||||
assert "[[1]](http://a.com)" in result
|
||||
assert "[[2]](http://b.com)" in result
|
||||
assert "[1, 2]" not in result
|
||||
|
||||
def test_missing_citation_falls_back(self):
|
||||
lookup = {"1": ("T1", "http://a.com")}
|
||||
|
||||
def format_one(num, data):
|
||||
return f"[{num}]"
|
||||
|
||||
result = self.fmt._replace_comma_citations(
|
||||
"See [1, 99] here", lookup, format_one
|
||||
)
|
||||
assert "[99]" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CitationFormatter._format_number_hyperlinks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatNumberHyperlinks:
|
||||
def setup_method(self):
|
||||
self.fmt = CitationFormatter()
|
||||
|
||||
def test_basic_hyperlink(self):
|
||||
sources = {"1": ("Title", "https://example.com")}
|
||||
result = self.fmt._format_number_hyperlinks(
|
||||
"See [1] for details.", sources
|
||||
)
|
||||
assert "[[1]](https://example.com)" in result
|
||||
|
||||
def test_source_without_url_unchanged(self):
|
||||
sources = {"1": ("Title", "")}
|
||||
result = self.fmt._format_number_hyperlinks(
|
||||
"See [1] for details.", sources
|
||||
)
|
||||
assert "[1]" in result
|
||||
assert "]()" not in result
|
||||
|
||||
def test_source_word_pattern(self):
|
||||
sources = {"1": ("Title", "https://example.com")}
|
||||
result = self.fmt._format_number_hyperlinks(
|
||||
"According to Source 1.", sources
|
||||
)
|
||||
assert "[[1]](https://example.com)" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CitationFormatter._format_domain_id_hyperlinks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatDomainIdHyperlinks:
|
||||
def setup_method(self):
|
||||
self.fmt = CitationFormatter()
|
||||
|
||||
def test_single_domain_no_id_suffix(self):
|
||||
sources = {"1": ("Title", "https://arxiv.org/abs/123")}
|
||||
result = self.fmt._format_domain_id_hyperlinks("See [1].", sources)
|
||||
assert "[[arxiv.org]]" in result
|
||||
|
||||
def test_multiple_same_domain_gets_id(self):
|
||||
sources = {
|
||||
"1": ("A", "https://arxiv.org/abs/1"),
|
||||
"2": ("B", "https://arxiv.org/abs/2"),
|
||||
}
|
||||
result = self.fmt._format_domain_id_hyperlinks(
|
||||
"See [1] and [2].", sources
|
||||
)
|
||||
assert "arxiv.org-1" in result
|
||||
assert "arxiv.org-2" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LaTeXExporter._escape_latex
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLatexEscapeLatex:
|
||||
def setup_method(self):
|
||||
self.exporter = LaTeXExporter()
|
||||
|
||||
def test_ampersand(self):
|
||||
assert self.exporter._escape_latex("A & B") == r"A \& B"
|
||||
|
||||
def test_percent(self):
|
||||
assert self.exporter._escape_latex("100%") == r"100\%"
|
||||
|
||||
def test_underscore(self):
|
||||
assert self.exporter._escape_latex("my_var") == r"my\_var"
|
||||
|
||||
def test_hash(self):
|
||||
assert self.exporter._escape_latex("#1") == r"\#1"
|
||||
|
||||
def test_tilde(self):
|
||||
assert r"\textasciitilde{}" in self.exporter._escape_latex("~")
|
||||
|
||||
def test_empty_string(self):
|
||||
assert self.exporter._escape_latex("") == ""
|
||||
|
||||
def test_multiple_specials(self):
|
||||
result = self.exporter._escape_latex("A & B % C")
|
||||
assert r"\&" in result
|
||||
assert r"\%" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LaTeXExporter._convert_lists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLatexConvertLists:
|
||||
def setup_method(self):
|
||||
self.exporter = LaTeXExporter()
|
||||
|
||||
def test_single_bullet(self):
|
||||
result = self.exporter._convert_lists("- Hello")
|
||||
assert r"\begin{itemize}" in result
|
||||
assert r"\item Hello" in result
|
||||
assert r"\end{itemize}" in result
|
||||
|
||||
def test_multiple_bullets(self):
|
||||
result = self.exporter._convert_lists("- One\n- Two\n- Three")
|
||||
assert result.count(r"\item") == 3
|
||||
# Only one begin/end pair
|
||||
assert result.count(r"\begin{itemize}") == 1
|
||||
assert result.count(r"\end{itemize}") == 1
|
||||
|
||||
def test_no_bullets_unchanged(self):
|
||||
text = "Just a paragraph."
|
||||
result = self.exporter._convert_lists(text)
|
||||
assert r"\begin{itemize}" not in result
|
||||
|
||||
def test_list_ends_on_non_list_line(self):
|
||||
result = self.exporter._convert_lists("- Item\nParagraph text")
|
||||
assert r"\end{itemize}" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RISExporter basic entry formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRISExporter:
|
||||
def setup_method(self):
|
||||
self.exporter = RISExporter()
|
||||
|
||||
def test_no_sources_section_returns_empty(self):
|
||||
result = self.exporter.export_to_ris("No references here at all.")
|
||||
assert result == ""
|
||||
|
||||
def test_basic_export_contains_ris_markers(self):
|
||||
content = (
|
||||
"Body text\n## Sources\n[1] My Paper\n URL: https://example.com"
|
||||
)
|
||||
result = self.exporter.export_to_ris(content)
|
||||
assert "TY - ELEC" in result
|
||||
assert "ER - " in result
|
||||
assert "ID - ref1" in result
|
||||
|
||||
def test_url_included_in_entry(self):
|
||||
content = "Text\n## Sources\n[1] Paper Title\n URL: https://example.com/paper"
|
||||
result = self.exporter.export_to_ris(content)
|
||||
assert "UR - https://example.com/paper" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QuartoExporter output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQuartoExporter:
|
||||
def setup_method(self):
|
||||
self.exporter = QuartoExporter()
|
||||
|
||||
def test_yaml_header_present(self):
|
||||
content = "# My Report\nSome text [1]\n## Sources\n[1] Ref"
|
||||
result = self.exporter.export_to_quarto(content)
|
||||
assert result.startswith("---")
|
||||
assert "title:" in result
|
||||
assert "bibliography: references.bib" in result
|
||||
|
||||
def test_citation_converted_to_quarto_format(self):
|
||||
content = "See [1] for details.\n## Sources\n[1] A paper"
|
||||
result = self.exporter.export_to_quarto(content)
|
||||
assert "[@ref1]" in result
|
||||
|
||||
def test_comma_citations_converted(self):
|
||||
content = "See [1, 2] here.\n## Sources\n[1] A\n[2] B"
|
||||
result = self.exporter.export_to_quarto(content)
|
||||
assert "@ref1" in result
|
||||
assert "@ref2" in result
|
||||
|
||||
def test_custom_title(self):
|
||||
content = "Some body text"
|
||||
result = self.exporter.export_to_quarto(content, title="Custom Title")
|
||||
assert "Custom Title" in result
|
||||
|
||||
def test_title_extracted_from_heading(self):
|
||||
content = "# Extracted Title\nBody"
|
||||
result = self.exporter.export_to_quarto(content)
|
||||
assert "Extracted Title" in result
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Integration tests for text optimization with the research service."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from local_deep_research.web.services.research_service import (
|
||||
get_citation_formatter,
|
||||
export_report_to_memory,
|
||||
)
|
||||
from local_deep_research.text_optimization import (
|
||||
CitationFormatter,
|
||||
CitationMode,
|
||||
)
|
||||
|
||||
|
||||
class TestResearchServiceIntegration:
|
||||
"""Test integration of text optimization with research service."""
|
||||
|
||||
@patch("local_deep_research.config.search_config.get_setting_from_snapshot")
|
||||
def test_get_citation_formatter_number_mode(self, mock_get_setting):
|
||||
"""Test getting formatter with number hyperlinks mode."""
|
||||
mock_get_setting.return_value = "number_hyperlinks"
|
||||
|
||||
formatter = get_citation_formatter()
|
||||
assert isinstance(formatter, CitationFormatter)
|
||||
assert formatter.mode == CitationMode.NUMBER_HYPERLINKS
|
||||
|
||||
@patch("local_deep_research.config.search_config.get_setting_from_snapshot")
|
||||
def test_get_citation_formatter_domain_mode(self, mock_get_setting):
|
||||
"""Test getting formatter with domain hyperlinks mode."""
|
||||
mock_get_setting.return_value = "domain_hyperlinks"
|
||||
|
||||
formatter = get_citation_formatter()
|
||||
assert isinstance(formatter, CitationFormatter)
|
||||
assert formatter.mode == CitationMode.DOMAIN_HYPERLINKS
|
||||
|
||||
@patch("local_deep_research.config.search_config.get_setting_from_snapshot")
|
||||
def test_get_citation_formatter_no_hyperlinks_mode(self, mock_get_setting):
|
||||
"""Test getting formatter with no hyperlinks mode."""
|
||||
mock_get_setting.return_value = "no_hyperlinks"
|
||||
|
||||
formatter = get_citation_formatter()
|
||||
assert isinstance(formatter, CitationFormatter)
|
||||
assert formatter.mode == CitationMode.NO_HYPERLINKS
|
||||
|
||||
@patch("local_deep_research.config.search_config.get_setting_from_snapshot")
|
||||
def test_get_citation_formatter_invalid_mode(self, mock_get_setting):
|
||||
"""Test getting formatter with invalid mode falls back to default."""
|
||||
mock_get_setting.return_value = "invalid_mode"
|
||||
|
||||
formatter = get_citation_formatter()
|
||||
assert isinstance(formatter, CitationFormatter)
|
||||
assert formatter.mode == CitationMode.NUMBER_HYPERLINKS # Default
|
||||
|
||||
def test_export_report_to_latex(self):
|
||||
"""Test LaTeX export functionality."""
|
||||
markdown_content = """# Test Report
|
||||
|
||||
This is a test with citation [1].
|
||||
|
||||
## Sources
|
||||
|
||||
[1] Test Source
|
||||
URL: https://example.com
|
||||
"""
|
||||
# Export to LaTeX
|
||||
latex_bytes, filename, mimetype = export_report_to_memory(
|
||||
markdown_content, "latex"
|
||||
)
|
||||
|
||||
# Check export results
|
||||
assert filename.endswith(".tex")
|
||||
assert mimetype == "text/plain"
|
||||
|
||||
# Check LaTeX content
|
||||
latex_content = latex_bytes.decode("utf-8")
|
||||
|
||||
assert r"\documentclass[12pt]{article}" in latex_content
|
||||
assert r"\section{Test Report}" in latex_content
|
||||
assert r"\cite{1}" in latex_content
|
||||
assert r"\bibitem{1}" in latex_content
|
||||
|
||||
@patch("local_deep_research.config.search_config.get_setting_from_snapshot")
|
||||
def test_real_world_citation_formatting(self, mock_get_setting):
|
||||
"""Test citation formatting with real-world example."""
|
||||
mock_get_setting.return_value = "number_hyperlinks"
|
||||
|
||||
# Simulate research report content
|
||||
content = """# Deep Learning Research Summary
|
||||
|
||||
Query: What are the latest advances in transformer architectures?
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Recent advances in transformer architectures have focused on efficiency improvements [1],
|
||||
novel attention mechanisms [2], and scaling laws [3]. The field has seen rapid progress
|
||||
with models like GPT-4 [4] and Claude [5] demonstrating impressive capabilities.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### Efficiency Improvements
|
||||
Multiple research groups have proposed methods to reduce computational complexity [1, 2, 3]:
|
||||
- Flash Attention reduces memory usage significantly [1]
|
||||
- Sparse transformers achieve O(n√n) complexity [2]
|
||||
- Linear attention approximations show promise [3]
|
||||
|
||||
### Novel Architectures
|
||||
Recent architectural innovations include:
|
||||
- Mixture of Experts (MoE) models [4][5]
|
||||
- Retrieval-augmented generation [6]
|
||||
- Tool-use capabilities [7][8][9]
|
||||
|
||||
## Sources
|
||||
|
||||
[1] FlashAttention: Fast and Memory-Efficient Exact Attention
|
||||
URL: https://arxiv.org/abs/2205.14135
|
||||
|
||||
[2] Efficient Transformers: A Survey
|
||||
URL: https://arxiv.org/abs/2009.06732
|
||||
|
||||
[3] Linformer: Self-Attention with Linear Complexity
|
||||
URL: https://arxiv.org/abs/2006.04768
|
||||
|
||||
[4] Mixtral of Experts
|
||||
URL: https://arxiv.org/abs/2401.04088
|
||||
|
||||
[5] Switch Transformers
|
||||
URL: https://arxiv.org/abs/2101.03961
|
||||
|
||||
[6] Retrieval-Augmented Generation
|
||||
URL: https://arxiv.org/abs/2005.11401
|
||||
|
||||
[7] Toolformer: Language Models Can Teach Themselves
|
||||
URL: https://arxiv.org/abs/2302.04761
|
||||
|
||||
[8] WebGPT: Browser-assisted question-answering
|
||||
URL: https://arxiv.org/abs/2112.09332
|
||||
|
||||
[9] Constitutional AI: Harmlessness from AI Feedback
|
||||
URL: https://arxiv.org/abs/2212.08073
|
||||
"""
|
||||
|
||||
formatter = get_citation_formatter()
|
||||
result = formatter.format_document(content)
|
||||
|
||||
# Verify all citation formats are handled correctly
|
||||
assert "[[1]](https://arxiv.org/abs/2205.14135)" in result
|
||||
assert (
|
||||
"[[1]](https://arxiv.org/abs/2205.14135)[[2]](https://arxiv.org/abs/2009.06732)[[3]](https://arxiv.org/abs/2006.04768)"
|
||||
in result
|
||||
)
|
||||
assert (
|
||||
"[[4]](https://arxiv.org/abs/2401.04088)[[5]](https://arxiv.org/abs/2101.03961)"
|
||||
in result
|
||||
)
|
||||
assert (
|
||||
"[[7]](https://arxiv.org/abs/2302.04761)[[8]](https://arxiv.org/abs/2112.09332)[[9]](https://arxiv.org/abs/2212.08073)"
|
||||
in result
|
||||
)
|
||||
|
||||
# Ensure sources section is preserved
|
||||
assert "## Sources" in result
|
||||
assert "[1] FlashAttention" in result
|
||||
|
||||
def test_export_report_to_quarto(self):
|
||||
"""Test Quarto export functionality."""
|
||||
markdown_content = """# AI Research Report
|
||||
|
||||
This report discusses recent advances [1] and challenges [2].
|
||||
|
||||
## Sources
|
||||
|
||||
[1] Advances in AI
|
||||
URL: https://arxiv.org/abs/2024.1234
|
||||
[2] AI Challenges
|
||||
URL: https://example.com/challenges
|
||||
"""
|
||||
# Export to Quarto
|
||||
zip_bytes, filename, mimetype = export_report_to_memory(
|
||||
markdown_content, "quarto", "AI Research"
|
||||
)
|
||||
|
||||
# Check export results
|
||||
assert filename.endswith(".zip")
|
||||
assert mimetype == "application/zip"
|
||||
|
||||
# Extract and check content from zip
|
||||
import zipfile
|
||||
import io
|
||||
|
||||
zip_buffer = io.BytesIO(zip_bytes)
|
||||
with zipfile.ZipFile(zip_buffer, "r") as zipf:
|
||||
# Check files in zip
|
||||
file_list = zipf.namelist()
|
||||
qmd_files = [f for f in file_list if f.endswith(".qmd")]
|
||||
bib_files = [f for f in file_list if f.endswith(".bib")]
|
||||
|
||||
assert len(qmd_files) == 1, "Should have one .qmd file"
|
||||
assert len(bib_files) == 1, "Should have one .bib file"
|
||||
|
||||
# Check Quarto content
|
||||
quarto_content = zipf.read(qmd_files[0]).decode("utf-8")
|
||||
|
||||
# Verify YAML header
|
||||
assert 'title: "AI Research"' in quarto_content
|
||||
assert "bibliography: references.bib" in quarto_content
|
||||
|
||||
# Verify citation conversion
|
||||
assert "[@ref1]" in quarto_content
|
||||
assert "[@ref2]" in quarto_content
|
||||
|
||||
# Check bibliography content
|
||||
bib_content = zipf.read(bib_files[0]).decode("utf-8")
|
||||
|
||||
assert "@misc{ref1," in bib_content
|
||||
assert 'title = "{Advances in AI}"' in bib_content
|
||||
|
||||
def test_export_with_invalid_format(self):
|
||||
"""Test export with invalid format raises error."""
|
||||
markdown_content = "# Test"
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported export format"):
|
||||
export_report_to_memory(markdown_content, "invalid_format")
|
||||
|
||||
@patch("local_deep_research.config.search_config.get_setting_from_snapshot")
|
||||
def test_automatic_export_formats(self, mock_get_setting):
|
||||
"""Test automatic export to multiple formats based on settings."""
|
||||
# This would be called in the actual research service when saving reports
|
||||
mock_get_setting.return_value = ["markdown", "latex", "quarto"]
|
||||
|
||||
# Simulate the export logic
|
||||
export_formats = mock_get_setting("report.export_formats", ["markdown"])
|
||||
|
||||
assert "markdown" in export_formats
|
||||
assert "latex" in export_formats
|
||||
assert "quarto" in export_formats
|
||||
@@ -0,0 +1,490 @@
|
||||
"""
|
||||
Tests for RISExporter._create_ris_entry() edge cases.
|
||||
|
||||
Existing tests cover: basic ID, year extraction, DOI from metadata,
|
||||
GitHub publisher, and arXiv publisher.
|
||||
|
||||
This file adds coverage for the many remaining branches:
|
||||
- Author parsing (split by 'and', '&', commas)
|
||||
- DOI extraction from text (not just metadata)
|
||||
- Publisher detection for Reddit, YouTube, Medium, PyPI, generic domains
|
||||
- www. prefix stripping
|
||||
- Title cleaning (DOI, "Published in", "Volume", "Pages" patterns)
|
||||
- Empty/minimal inputs
|
||||
- Author match overlapping with title cleaning
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from freezegun import freeze_time
|
||||
|
||||
from local_deep_research.text_optimization.citation_formatter import RISExporter
|
||||
|
||||
|
||||
class TestRISEntryAuthorParsing:
|
||||
"""Tests for author extraction from 'by Author1, Author2' patterns."""
|
||||
|
||||
def _make(self):
|
||||
return RISExporter()
|
||||
|
||||
def test_single_author(self):
|
||||
"""Extracts a single author."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Some Title\nWritten by Jane Doe."
|
||||
)
|
||||
assert "AU - Jane Doe" in result
|
||||
|
||||
def test_authors_separated_by_and(self):
|
||||
"""Authors separated by 'and' are split correctly."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title\nby Alice Smith and Bob Jones."
|
||||
)
|
||||
assert "AU - Alice Smith" in result
|
||||
assert "AU - Bob Jones" in result
|
||||
|
||||
def test_authors_separated_by_ampersand(self):
|
||||
"""Authors separated by '&' are split correctly."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title\nby Alice Smith & Bob Jones."
|
||||
)
|
||||
assert "AU - Alice Smith" in result
|
||||
assert "AU - Bob Jones" in result
|
||||
|
||||
def test_authors_separated_by_comma(self):
|
||||
"""Authors separated by commas are split correctly."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title\nby Alice Smith, Bob Jones, Carol White."
|
||||
)
|
||||
assert "AU - Alice Smith" in result
|
||||
assert "AU - Bob Jones" in result
|
||||
assert "AU - Carol White" in result
|
||||
|
||||
def test_authors_mixed_separators(self):
|
||||
"""Authors with mixed 'and' and comma separators."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title\nby Alice, Bob and Carol."
|
||||
)
|
||||
assert "AU - Alice" in result
|
||||
assert "AU - Bob" in result
|
||||
assert "AU - Carol" in result
|
||||
|
||||
def test_no_authors(self):
|
||||
"""No author line produces no AU entries."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("1", "Title with no authors")
|
||||
assert "AU -" not in result
|
||||
|
||||
def test_by_keyword_case_insensitive(self):
|
||||
"""The 'by' keyword match is case-insensitive."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("1", "Title\nBY John Smith.")
|
||||
assert "AU - John Smith" in result
|
||||
|
||||
def test_empty_author_parts_filtered(self):
|
||||
"""Empty parts from splitting are filtered out."""
|
||||
exporter = self._make()
|
||||
# Trailing comma leaves an empty part
|
||||
result = exporter._create_ris_entry("1", "Title\nby Alice, , Bob.")
|
||||
lines = [
|
||||
line for line in result.split("\n") if line.startswith("AU -")
|
||||
]
|
||||
# Should only have real authors, not empty strings
|
||||
for line in lines:
|
||||
assert line.strip() != "AU -"
|
||||
|
||||
|
||||
class TestRISEntryDOIExtraction:
|
||||
"""Tests for DOI extraction from metadata and text."""
|
||||
|
||||
def _make(self):
|
||||
return RISExporter()
|
||||
|
||||
def test_doi_from_metadata_takes_precedence(self):
|
||||
"""DOI from metadata dict is used over DOI in text."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1",
|
||||
"Title DOI: 10.9999/text-doi",
|
||||
metadata={"doi": "10.1234/meta-doi"},
|
||||
)
|
||||
assert "DO - 10.1234/meta-doi" in result
|
||||
assert (
|
||||
"10.9999/text-doi" not in result.split("DO - ")[1].split("\n")[0]
|
||||
)
|
||||
|
||||
def test_doi_from_text(self):
|
||||
"""DOI extracted from text when not in metadata."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title\nDOI: 10.1234/test.5678"
|
||||
)
|
||||
assert "DO - 10.1234/test.5678" in result
|
||||
|
||||
def test_doi_from_text_case_insensitive(self):
|
||||
"""DOI extraction from text is case-insensitive."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title\ndoi: 10.1234/lowercase"
|
||||
)
|
||||
assert "DO - 10.1234/lowercase" in result
|
||||
|
||||
def test_no_doi(self):
|
||||
"""No DOI produces no DO entry."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("1", "Title with no DOI")
|
||||
assert "DO -" not in result
|
||||
|
||||
def test_metadata_none_falls_back_to_text(self):
|
||||
"""When metadata is None, DOI is extracted from text."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("1", "Title\nDOI: 10.5555/fallback")
|
||||
assert "DO - 10.5555/fallback" in result
|
||||
|
||||
def test_metadata_without_doi_key(self):
|
||||
"""Metadata dict without 'doi' key falls back to text."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1",
|
||||
"Title\nDOI: 10.7777/textdoi",
|
||||
metadata={"other": "value"},
|
||||
)
|
||||
assert "DO - 10.7777/textdoi" in result
|
||||
|
||||
|
||||
class TestRISEntryPublisherDetection:
|
||||
"""Tests for publisher extraction from URL domains."""
|
||||
|
||||
def _make(self):
|
||||
return RISExporter()
|
||||
|
||||
def test_reddit_publisher(self):
|
||||
"""Reddit URL produces Reddit publisher."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title", url="https://www.reddit.com/r/python/post"
|
||||
)
|
||||
assert "PB - Reddit" in result
|
||||
|
||||
def test_reddit_subdomain(self):
|
||||
"""Reddit subdomain also detected."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title", url="https://old.reddit.com/r/science"
|
||||
)
|
||||
assert "PB - Reddit" in result
|
||||
|
||||
def test_youtube_publisher(self):
|
||||
"""YouTube URL produces YouTube publisher."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title", url="https://www.youtube.com/watch?v=abc123"
|
||||
)
|
||||
assert "PB - YouTube" in result
|
||||
|
||||
def test_youtube_mobile(self):
|
||||
"""Mobile YouTube URL produces YouTube publisher."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title", url="https://m.youtube.com/watch?v=abc123"
|
||||
)
|
||||
assert "PB - YouTube" in result
|
||||
|
||||
def test_medium_publisher(self):
|
||||
"""Medium URL produces Medium publisher."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title", url="https://medium.com/@author/article"
|
||||
)
|
||||
assert "PB - Medium" in result
|
||||
|
||||
def test_medium_subdomain(self):
|
||||
"""Medium subdomain (custom publication) detected."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title", url="https://blog.medium.com/post"
|
||||
)
|
||||
assert "PB - Medium" in result
|
||||
|
||||
def test_pypi_publisher(self):
|
||||
"""PyPI URL produces PyPI publisher."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title", url="https://pypi.org/project/requests/"
|
||||
)
|
||||
assert "PB - Python Package Index (PyPI)" in result
|
||||
|
||||
def test_generic_domain_as_publisher(self):
|
||||
"""Unknown domain used as publisher name."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title", url="https://example.com/article"
|
||||
)
|
||||
assert "PB - example.com" in result
|
||||
|
||||
def test_www_stripped_from_domain(self):
|
||||
"""www. prefix is stripped from domain."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title", url="https://www.example.org/page"
|
||||
)
|
||||
assert "PB - example.org" in result
|
||||
|
||||
def test_no_url_no_publisher(self):
|
||||
"""No URL produces no PB or UR entries."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("1", "Title")
|
||||
assert "PB -" not in result
|
||||
assert "UR -" not in result
|
||||
|
||||
def test_empty_url_no_publisher(self):
|
||||
"""Empty string URL produces no PB or UR entries."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("1", "Title", url="")
|
||||
assert "PB -" not in result
|
||||
assert "UR -" not in result
|
||||
|
||||
def test_github_subdomain(self):
|
||||
"""GitHub subdomain detected as GitHub."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title", url="https://pages.github.com/project"
|
||||
)
|
||||
assert "PB - GitHub" in result
|
||||
|
||||
def test_arxiv_subdomain(self):
|
||||
"""arXiv subdomain detected as arXiv."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title", url="https://export.arxiv.org/abs/1234"
|
||||
)
|
||||
assert "PB - arXiv" in result
|
||||
|
||||
|
||||
class TestRISEntryTitleCleaning:
|
||||
"""Tests for title cleaning patterns."""
|
||||
|
||||
def _make(self):
|
||||
return RISExporter()
|
||||
|
||||
def test_doi_removed_from_title(self):
|
||||
"""DOI text is removed from the title."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "My Paper DOI: 10.1234/test\nMore content"
|
||||
)
|
||||
# The title line should not contain DOI
|
||||
ti_line = [
|
||||
line for line in result.split("\n") if line.startswith("TI -")
|
||||
][0]
|
||||
assert "DOI:" not in ti_line
|
||||
|
||||
def test_published_in_removed_from_title(self):
|
||||
"""'Published in ...' is removed from the title."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "My Paper Published in Nature 2023\nContent"
|
||||
)
|
||||
ti_line = [
|
||||
line for line in result.split("\n") if line.startswith("TI -")
|
||||
][0]
|
||||
assert "Published in" not in ti_line
|
||||
|
||||
def test_volume_removed_from_title(self):
|
||||
"""'Volume ...' is removed from the title."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "My Paper Volume 42 Issue 3\nContent"
|
||||
)
|
||||
ti_line = [
|
||||
line for line in result.split("\n") if line.startswith("TI -")
|
||||
][0]
|
||||
assert "Volume" not in ti_line
|
||||
|
||||
def test_pages_removed_from_title(self):
|
||||
"""'Pages ...' is removed from the title."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "My Paper Pages 100-120\nContent"
|
||||
)
|
||||
ti_line = [
|
||||
line for line in result.split("\n") if line.startswith("TI -")
|
||||
][0]
|
||||
assert "Pages" not in ti_line
|
||||
|
||||
def test_fallback_to_original_title_when_clean_empty(self):
|
||||
"""If cleaning empties the title, original title is used."""
|
||||
exporter = self._make()
|
||||
# Title that's entirely "by Author" where author match starts at 0
|
||||
result = exporter._create_ris_entry("1", "by John Smith\nContent")
|
||||
# Should still have a TI entry (either cleaned or fallback)
|
||||
ti_line = [
|
||||
line for line in result.split("\n") if line.startswith("TI -")
|
||||
][0]
|
||||
assert "TI - " in ti_line
|
||||
|
||||
|
||||
class TestRISEntryStructure:
|
||||
"""Tests for overall RIS entry structure."""
|
||||
|
||||
def _make(self):
|
||||
return RISExporter()
|
||||
|
||||
def test_contains_ty_elec(self):
|
||||
"""Entry contains TY - ELEC type marker."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("1", "Title")
|
||||
assert "TY - ELEC" in result
|
||||
|
||||
def test_ends_with_er(self):
|
||||
"""Entry ends with ER marker."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("1", "Title")
|
||||
lines = result.split("\n")
|
||||
assert lines[-1] == "ER - "
|
||||
|
||||
def test_contains_language(self):
|
||||
"""Entry contains language tag."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("1", "Title")
|
||||
assert "LA - en" in result
|
||||
|
||||
@freeze_time("2025-06-15")
|
||||
def test_contains_access_year(self):
|
||||
"""Entry contains current access year."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("1", "Title")
|
||||
assert "Y1 - 2025" in result
|
||||
|
||||
@freeze_time("2025-06-15")
|
||||
def test_contains_access_date(self):
|
||||
"""Entry contains formatted access date."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("1", "Title")
|
||||
assert "DA - 2025/06/15" in result
|
||||
|
||||
def test_ref_id_format(self):
|
||||
"""Reference ID formatted as 'refN'."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("42", "Title")
|
||||
assert "ID - ref42" in result
|
||||
|
||||
|
||||
class TestRISEntryYearExtraction:
|
||||
"""Tests for year extraction from text."""
|
||||
|
||||
def _make(self):
|
||||
return RISExporter()
|
||||
|
||||
def test_year_1990s(self):
|
||||
"""Extracts 1990s year."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("1", "Published in 1995")
|
||||
assert "PY - 1995" in result
|
||||
|
||||
def test_year_2000s(self):
|
||||
"""Extracts 2000s year."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("1", "Released 2003")
|
||||
assert "PY - 2003" in result
|
||||
|
||||
def test_no_year(self):
|
||||
"""No year in text produces no PY entry."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("1", "Title with no date")
|
||||
assert "PY -" not in result
|
||||
|
||||
def test_first_year_extracted(self):
|
||||
"""First valid year in text is extracted."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Written in 2020, revised in 2023"
|
||||
)
|
||||
assert "PY - 2020" in result
|
||||
|
||||
def test_year_boundary_1900(self):
|
||||
"""Year 1900 is valid (19xx pattern)."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry("1", "Historical text from 1900")
|
||||
assert "PY - 1900" in result
|
||||
|
||||
def test_non_year_four_digit_ignored(self):
|
||||
"""Four-digit numbers outside 19xx-20xx range are not matched."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Article number 3456 in series"
|
||||
)
|
||||
assert "PY -" not in result
|
||||
|
||||
|
||||
class TestRISEntryURLHandling:
|
||||
"""Tests for URL handling in RIS entries."""
|
||||
|
||||
def _make(self):
|
||||
return RISExporter()
|
||||
|
||||
def test_url_included(self):
|
||||
"""URL is included as UR entry."""
|
||||
exporter = self._make()
|
||||
result = exporter._create_ris_entry(
|
||||
"1", "Title", url="https://example.com"
|
||||
)
|
||||
assert "UR - https://example.com" in result
|
||||
|
||||
def test_url_with_path(self):
|
||||
"""Full URL with path is preserved."""
|
||||
exporter = self._make()
|
||||
url = "https://example.com/path/to/article?id=123"
|
||||
result = exporter._create_ris_entry("1", "Title", url=url)
|
||||
assert f"UR - {url}" in result
|
||||
|
||||
|
||||
class TestRISEntryStructuralValidity:
|
||||
"""Regression guard for the accumulator-clobber bug.
|
||||
|
||||
``_create_ris_entry`` initialized ``lines = []`` as the RIS-output
|
||||
accumulator, then immediately reassigned ``lines = full_text.split("\\n")``
|
||||
to read the title — overwriting the accumulator with the raw source body.
|
||||
Every RIS field was then appended *after* those source lines, so each entry
|
||||
emitted the source text before the mandatory leading ``TY - `` tag and
|
||||
reference managers (Zotero/Mendeley/EndNote) rejected the record. The
|
||||
pre-existing tests only used ``"<tag>" in result`` substring checks, so they
|
||||
never caught the leaked prefix.
|
||||
"""
|
||||
|
||||
# RIS records are newline-separated ``XX - value`` lines where the tag is
|
||||
# two uppercase-or-digit chars (TY, ID, TI, AU, DO, PY, UR, PB, Y1, DA, LA,
|
||||
# ER), two spaces, a hyphen, then a space.
|
||||
_RIS_LINE = re.compile(r"^[A-Z][A-Z0-9] - ")
|
||||
|
||||
def test_entry_starts_with_ty_tag(self):
|
||||
"""RIS requires ``TY`` to be the first tag of the record."""
|
||||
exporter = RISExporter()
|
||||
source = (
|
||||
"Understanding Widgets\n"
|
||||
"URL: https://example.com/widgets\n"
|
||||
"Collection: Gadgets"
|
||||
)
|
||||
result = exporter._create_ris_entry(
|
||||
"7", source, url="https://example.com/widgets"
|
||||
)
|
||||
assert result.startswith("TY - "), result
|
||||
|
||||
def test_no_raw_source_lines_leak(self):
|
||||
"""Every non-blank output line is a RIS tag, never raw source body."""
|
||||
exporter = RISExporter()
|
||||
source = (
|
||||
"My Title\n"
|
||||
"BODYLEAKMARKER this descriptive line must never be emitted\n"
|
||||
"Collection: Internal"
|
||||
)
|
||||
result = exporter._create_ris_entry("1", source)
|
||||
assert "BODYLEAKMARKER" not in result
|
||||
for line in result.split("\n"):
|
||||
if not line.strip():
|
||||
continue
|
||||
assert self._RIS_LINE.match(line), f"non-RIS line leaked: {line!r}"
|
||||
Reference in New Issue
Block a user