""" 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 ``"" 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}"