"""
Tests for citation verification functionality.
"""
import os
import tempfile
import pytest
from verify_citations.parser import parse_bibtex_file, format_entry_summary
from verify_citations.verifier import CitationVerifier
def test_parse_bibtex_file():
"""Test parsing a BibTeX file."""
# Create a temporary BibTeX file
with tempfile.NamedTemporaryFile(mode='w', suffix='.bib', delete=False) as f:
f.write("""
@article{test2023,
title={Test Paper},
author={Smith, John},
year={2023},
journal={Test Journal}
}
""")
temp_path = f.name
try:
entries = parse_bibtex_file(temp_path)
assert len(entries) == 1
assert entries[0]['ID'] == 'test2023'
assert 'Test Paper' in entries[0]['title']
assert entries[0]['year'] == '2023'
finally:
os.unlink(temp_path)
def test_format_entry_summary():
"""Test formatting a BibTeX entry."""
entry = {
'ID': 'smith2023',
'title': 'A Great Paper',
'author': 'Smith, John',
'year': '2023'
}
summary = format_entry_summary(entry)
assert 'smith2023' in summary
assert 'A Great Paper' in summary
assert 'Smith, John' in summary
assert '2023' in summary
def test_citation_verifier_init():
"""Test CitationVerifier initialization."""
verifier = CitationVerifier(timeout=5)
assert verifier.timeout == 5
assert verifier.session is not None
def test_verify_citation_basic():
"""Test basic citation verification."""
verifier = CitationVerifier()
entry = {
'ID': 'test2023',
'title': 'Test Paper',
'author': 'Smith, John',
'year': '2023'
}
result = verifier.verify_citation(entry)
assert result['id'] == 'test2023'
assert result['title'] == 'Test Paper'
assert 'checks' in result
assert 'findable_online' in result['checks']
assert 'url_valid' in result['checks']
assert 'metadata_correct' in result['checks']
assert 'version_info' in result['checks']
assert 'messages' in result
assert 'status' in result
def test_extract_arxiv_id():
"""Test arXiv ID extraction."""
verifier = CitationVerifier()
# Test various arXiv URL formats
assert verifier._extract_arxiv_id('https://arxiv.org/abs/1706.03762') == '1706.03762'
assert verifier._extract_arxiv_id('https://arxiv.org/pdf/1810.04805.pdf') == '1810.04805'
assert verifier._extract_arxiv_id('1706.03762') == '1706.03762'
assert verifier._extract_arxiv_id('') is None
assert verifier._extract_arxiv_id('https://example.com') is None
def test_version_info_extraction():
"""Test version information extraction."""
verifier = CitationVerifier()
# Test with arXiv
entry = {
'ID': 'test1',
'eprint': '1706.03762',
'journal': 'Nature'
}
version_info = verifier._check_version_info(entry)
assert 'arXiv:1706.03762' in version_info
assert 'Journal: Nature' in version_info
# Test with conference
entry = {
'ID': 'test2',
'booktitle': 'NeurIPS 2023',
'doi': '10.1234/test'
}
version_info = verifier._check_version_info(entry)
assert 'Conference: NeurIPS 2023' in version_info
assert 'DOI: 10.1234/test' in version_info
def test_extract_author_names():
"""Test author name extraction."""
verifier = CitationVerifier()
# Test "Last, First and Last, First" format
authors = "Smith, John and Doe, Jane"
names = verifier._extract_author_names(authors)
assert 'smith' in names
assert 'doe' in names
# Test "First Last" format
authors = "John Smith and Jane Doe"
names = verifier._extract_author_names(authors)
assert 'smith' in names
assert 'doe' in names
# Test mixed format
authors = "Smith, John and Jane Doe and Brown, Alice"
names = verifier._extract_author_names(authors)
assert 'smith' in names
assert 'doe' in names
assert 'brown' in names
# Test with "and others"
authors = "Smith, John and Doe, Jane and others"
names = verifier._extract_author_names(authors)
assert 'smith' in names
assert 'doe' in names
assert 'others' not in names
# Test LaTeX special characters
authors = r"Kaiser, {\L}ukasz"
names = verifier._extract_author_names(authors)
assert 'kaiser' in names
def test_fuzzy_author_matching():
"""Test fuzzy matching for author names."""
verifier = CitationVerifier()
# Test exact match
assert verifier._fuzzy_match('smith', 'smith') is True
# Test small typo (1 character difference)
assert verifier._fuzzy_match('smith', 'smoth') is True
# Test 2 character difference
assert verifier._fuzzy_match('johnson', 'jonson') is True
# Test too different
assert verifier._fuzzy_match('smith', 'jones') is False
# Test length difference
assert verifier._fuzzy_match('ab', 'abcde') is False
def test_author_similarity_with_format_differences():
"""Test that author similarity handles name format differences."""
verifier = CitationVerifier()
# Same authors, different format
# BibTeX: "Last, First" format
bibtex_authors = "Vaswani, Ashish and Shazeer, Noam"
# Online: "First Last" format
online_authors = "Ashish Vaswani and Noam Shazeer"
bibtex_names = verifier._extract_author_names(bibtex_authors)
online_names = verifier._extract_author_names(online_authors)
similarity = verifier._calculate_author_similarity(bibtex_names, online_names)
# Should match since it's the same authors
assert similarity >= 0.5, f"Similarity {similarity} should be >= 0.5 for same authors"
def test_author_extraction_comma_separated():
"""Test author extraction with comma-separated format."""
verifier = CitationVerifier()
# Online format: comma-separated "First Last, First Last"
online_authors = "Jacob Devlin, Ming-Wei Chang, Kenton Lee, Kristina Toutanova"
online_names = verifier._extract_author_names(online_authors)
assert 'devlin' in online_names
assert 'chang' in online_names
assert 'lee' in online_names
assert 'toutanova' in online_names
assert len(online_names) == 4
# BibTeX format: "Last, First and Last, First"
bibtex_authors = "Devlin, Jacob and Chang, Ming-Wei and Lee, Kenton and Toutanova, Kristina"
bibtex_names = verifier._extract_author_names(bibtex_authors)
assert bibtex_names == online_names, "Should extract same names from both formats"
def test_remove_curly_braces():
"""Test removal of curly braces from titles."""
verifier = CitationVerifier()
# Test with curly braces in the middle
assert verifier._remove_curly_braces("Monitoring Human Dependence On {AI} Systems") == "Monitoring Human Dependence On AI Systems"
# Test with multiple curly braces
assert verifier._remove_curly_braces("{Deep} Learning with {GPU}s") == "Deep Learning with GPUs"
# Test with nested curly braces
assert verifier._remove_curly_braces("Title with {{nested}} braces") == "Title with nested braces"
# Test with no curly braces
assert verifier._remove_curly_braces("Normal Title") == "Normal Title"
# Test empty string
assert verifier._remove_curly_braces("") == ""
def test_title_similarity_with_curly_braces():
"""Test that title similarity handles curly braces correctly."""
verifier = CitationVerifier()
# Titles should be identical after removing curly braces
title1 = "Monitoring Human Dependence On {AI} Systems With Reliance Drills"
title2 = "Monitoring Human Dependence On AI Systems With Reliance Drills"
similarity = verifier._calculate_title_similarity(title1, title2)
# Should be 100% match after removing curly braces
assert similarity == 1.0, f"Similarity should be 1.0 but got {similarity}"
def test_title_similarity_case_insensitive():
"""Test that title similarity is case insensitive."""
verifier = CitationVerifier()
# Same titles with different cases
title1 = "Deep Learning for Natural Language Processing"
title2 = "DEEP LEARNING FOR NATURAL LANGUAGE PROCESSING"
similarity = verifier._calculate_title_similarity(title1, title2)
# Should be 100% match
assert similarity == 1.0, f"Similarity should be 1.0 but got {similarity}"
def test_title_similarity_with_difflib():
"""Test that title similarity uses difflib for partial matches."""
verifier = CitationVerifier()
# Similar but not identical titles
title1 = "Deep Learning for NLP"
title2 = "Deep Learning for Natural Language Processing"
similarity = verifier._calculate_title_similarity(title1, title2)
# Should have some similarity (not 0, not 1)
assert 0 < similarity < 1, f"Similarity should be between 0 and 1 but got {similarity}"
def test_exact_issue_scenario():
"""Test the exact scenario from the GitHub issue."""
verifier = CitationVerifier()
# Exact titles from the issue
bibtex_title = "Monitoring Human Dependence On {AI} Systems With Reliance Drills"
online_title = "Monitoring Human Dependence On AI Systems With Reliance Drills"
similarity = verifier._calculate_title_similarity(bibtex_title, online_title)
# After curly brace removal and lowercase, they should be 100% match
assert similarity == 1.0, f"Expected 100% similarity but got {similarity:.2%}"
# Also verify that the threshold check passes
assert verifier._titles_similar(bibtex_title, online_title), "Titles should be considered similar"
def test_parse_google_scholar_first_result_basic():
"""Test parsing Google Scholar HTML with basic structure."""
verifier = CitationVerifier()
# Minimal Google Scholar HTML with first result
html = """
A Vaswani, N Shazeer, N Parmar - Conference, 2017 - arxiv.org
"""
title, authors = verifier._parse_google_scholar_first_result(html)
assert title == "Attention is All You Need"
assert authors is not None
assert len(authors) == 3
assert "A Vaswani" in authors
assert "N Shazeer" in authors
assert "N Parmar" in authors
def test_parse_google_scholar_first_result_with_prefix():
"""Test parsing Google Scholar HTML with [PDF] or [HTML] prefix."""
verifier = CitationVerifier()
# Google Scholar HTML with [PDF] prefix
html = """
A Vaswani, N Shazeer - Conference, 2017 - arxiv.org
"""
title, authors = verifier._parse_google_scholar_first_result(html)
# Title should have [PDF] prefix removed
assert title == "Attention is All You Need"
assert authors is not None
assert len(authors) == 2
def test_parse_google_scholar_first_result_with_ellipsis():
"""Test parsing Google Scholar HTML with ellipsis in authors."""
verifier = CitationVerifier()
# Google Scholar HTML with ellipsis (indicating more authors)
html = """
J Devlin, MW Chang, K Lee, K Toutanova… - arXiv preprint, 2018 - arxiv.org
"""
title, authors = verifier._parse_google_scholar_first_result(html)
assert title == "BERT: Pre-training of Deep Bidirectional Transformers"
assert authors is not None
# Ellipsis should be removed
assert len(authors) == 4
assert "K Toutanova" in authors
# Ellipsis character should not be in last author name
assert not any('…' in author for author in authors)
def test_parse_google_scholar_first_result_with_ascii_ellipsis():
"""Test parsing Google Scholar HTML with ASCII ellipsis (...) in authors."""
verifier = CitationVerifier()
# Google Scholar HTML with ASCII ellipsis
html = """
J Smith, A Johnson, B Williams... - Conference, 2021 - example.com
"""
title, authors = verifier._parse_google_scholar_first_result(html)
assert title == "Test Paper"
assert authors is not None
# ASCII ellipsis should be removed
assert len(authors) == 3
assert "B Williams" in authors
# ASCII ellipsis should not be in last author name
assert not any('...' in author for author in authors)
assert not any('.' in authors[-1] for char in '.') # No trailing periods
def test_parse_google_scholar_first_result_no_anchor():
"""Test parsing Google Scholar HTML when title has no anchor tag."""
verifier = CitationVerifier()
# Some results may not have clickable links
html = """
[CITATION] Some Non-Clickable Paper
J Smith, A Jones - Journal, 2020 - publisher.com
"""
title, authors = verifier._parse_google_scholar_first_result(html)
# Should still extract title even without anchor, and remove [CITATION] prefix
assert title == "Some Non-Clickable Paper"
assert authors is not None
assert len(authors) == 2
def test_parse_google_scholar_first_result_fallback_to_gs_r():
"""Test parsing Google Scholar HTML with fallback to gs_r class."""
verifier = CitationVerifier()
# Use gs_r instead of gs_r gs_or
html = """
A Author, B Writer - Conference, 2021 - example.com
"""
title, authors = verifier._parse_google_scholar_first_result(html)
assert title == "Test Paper Title"
assert authors is not None
assert len(authors) == 2
def test_parse_google_scholar_first_result_no_container():
"""Test parsing Google Scholar HTML when container is missing."""
verifier = CitationVerifier()
# Missing gs_res_ccl_mid container
html = """
"""
title, authors = verifier._parse_google_scholar_first_result(html)
# Should return None when container is missing
assert title is None
assert authors is None
def test_parse_google_scholar_first_result_no_result():
"""Test parsing Google Scholar HTML when no results are present."""
verifier = CitationVerifier()
# Container exists but no results
html = """
"""
title, authors = verifier._parse_google_scholar_first_result(html)
# Should return None when no results found
assert title is None
assert authors is None
def test_parse_google_scholar_first_result_malformed_html():
"""Test parsing Google Scholar HTML when HTML is malformed."""
verifier = CitationVerifier()
# Malformed or incomplete HTML
html = """
"""
title, authors = verifier._parse_google_scholar_first_result(html)
# Should handle gracefully and return None or empty string
assert title is None or title == ""
assert authors is None
@pytest.mark.integration
def test_parse_google_scholar_tomasello_book():
"""Test parsing Google Scholar HTML for the Tomasello book entry with real HTTP request."""
verifier = CitationVerifier()
# Make actual HTTP request to Google Scholar
title_query = "Becoming human: A theory of ontogeny"
from urllib.parse import quote_plus
search_url = f"https://scholar.google.com/scholar?q={quote_plus(title_query)}"
try:
response = verifier._make_request_with_retry('get', search_url, timeout=verifier.timeout)
if response.status_code == 200:
html = response.text
title, authors = verifier._parse_google_scholar_first_result(html)
# Verify title extraction - should find the Tomasello book
assert title is not None, "Should extract a title from Google Scholar"
# Title should be similar to the query
bibtex_title = "Becoming human: A theory of ontogeny"
similarity = verifier._calculate_title_similarity(bibtex_title.lower(), title.lower())
# Be more lenient since Google Scholar results may vary
assert similarity >= 0.3, f"Title similarity {similarity:.2%} should be >= 30% for '{title}'"
# Verify author extraction if authors are found
if authors:
author_str = ', '.join(authors).lower()
# Check if Tomasello appears in the author list
assert 'tomasello' in author_str, f"Expected 'tomasello' in authors: {authors}"
elif response.status_code == 429:
pytest.skip("Google Scholar rate limited (429) - skipping test")
else:
pytest.skip(f"Google Scholar returned status {response.status_code} - skipping test")
except Exception as e:
pytest.skip(f"Network error accessing Google Scholar: {str(e)}")
def test_retry_on_429():
"""Test that 429 errors trigger retry with exponential backoff."""
from unittest.mock import Mock, patch
import time
verifier = CitationVerifier()
# Mock response that returns 429 first time, then 200
mock_response_429 = Mock()
mock_response_429.status_code = 429
mock_response_200 = Mock()
mock_response_200.status_code = 200
with patch.object(verifier.session, 'get', side_effect=[mock_response_429, mock_response_200]) as mock_get:
with patch('time.sleep') as mock_sleep:
response = verifier._make_request_with_retry('get', 'https://example.com')
# Should have called get twice
assert mock_get.call_count == 2
# Should have slept once (after first 429)
assert mock_sleep.call_count == 1
# Should have slept for initial delay
mock_sleep.assert_called_with(verifier.INITIAL_RETRY_DELAY)
# Final response should be 200
assert response.status_code == 200
def test_max_retries_on_429():
"""Test that max retries are respected for 429 errors."""
from unittest.mock import Mock, patch
verifier = CitationVerifier()
# Mock response that always returns 429
mock_response_429 = Mock()
mock_response_429.status_code = 429
with patch.object(verifier.session, 'get', return_value=mock_response_429) as mock_get:
with patch('time.sleep') as mock_sleep:
response = verifier._make_request_with_retry('get', 'https://example.com')
# Should have called get max_retries + 1 times (initial + retries)
assert mock_get.call_count == verifier.max_retries + 1
# Should have slept max_retries times
assert mock_sleep.call_count == verifier.max_retries
# Final response should still be 429
assert response.status_code == 429
def test_exponential_backoff_on_429():
"""Test that exponential backoff is used for 429 retries."""
from unittest.mock import Mock, patch
verifier = CitationVerifier()
# Mock response that always returns 429
mock_response_429 = Mock()
mock_response_429.status_code = 429
with patch.object(verifier.session, 'get', return_value=mock_response_429) as mock_get:
with patch('time.sleep') as mock_sleep:
response = verifier._make_request_with_retry('get', 'https://example.com')
# Check that sleep was called with increasing delays
sleep_calls = [call[0][0] for call in mock_sleep.call_args_list]
assert len(sleep_calls) == verifier.max_retries
# Verify exponential backoff
expected_delays = []
delay = verifier.INITIAL_RETRY_DELAY
for _ in range(verifier.max_retries):
expected_delays.append(delay)
delay = min(delay * 2, verifier.MAX_RETRY_DELAY)
assert sleep_calls == expected_delays
def test_url_valid_handles_429():
"""Test that _check_url_valid properly handles 429 errors."""
from unittest.mock import Mock, patch
verifier = CitationVerifier()
# Mock response that returns 429 after retries
mock_response_429 = Mock()
mock_response_429.status_code = 429
with patch.object(verifier, '_make_request_with_retry', return_value=mock_response_429):
result, message = verifier._check_url_valid('https://example.com/paper.pdf')
# Should return None (warning) for 429
assert result is None
assert '429' in message
assert 'Rate limited' in message
def test_search_continues_after_429():
"""Test that search continues to next source after 429."""
from unittest.mock import Mock, patch
verifier = CitationVerifier()
entry = {
'ID': 'test2023',
'title': 'Test Paper About Machine Learning',
'author': 'Smith, John',
'year': '2023'
}
# Mock arxiv to return 429, but Semantic Scholar to succeed
mock_response_429 = Mock()
mock_response_429.status_code = 429
mock_response_200 = Mock()
mock_response_200.status_code = 200
mock_response_200.json.return_value = {
'data': [{
'title': 'Test Paper About Machine Learning',
'paperId': 'abc123'
}]
}
# First two calls (arXiv ID and search) return 429, third call (Semantic Scholar) succeeds
with patch.object(verifier, '_make_request_with_retry',
side_effect=[mock_response_429, mock_response_429, mock_response_200]):
findable, search_url, logs = verifier._check_findable_online(entry)
# Should find the paper via alternative source
assert findable is True
assert 'semanticscholar.org' in search_url
# Logs should mention rate limiting
log_text = ' '.join(logs)
assert '429' in log_text or 'rate limited' in log_text.lower()
def test_verbose_logging_for_retries():
"""Test that verbose logs are generated for retry attempts."""
from unittest.mock import Mock, patch
verifier = CitationVerifier()
# Mock response that returns 429 twice, then 200
mock_response_429 = Mock()
mock_response_429.status_code = 429
mock_response_200 = Mock()
mock_response_200.status_code = 200
with patch.object(verifier.session, 'get',
side_effect=[mock_response_429, mock_response_429, mock_response_200]):
with patch('time.sleep'):
verbose_logs = []
response = verifier._make_request_with_retry('get', 'https://example.com', verbose_logs)
# Should have logged retry attempts
assert len(verbose_logs) > 0
log_text = ' '.join(verbose_logs)
# Should mention 429 and waiting
assert '429' in log_text
assert 'Rate Limited' in log_text
assert 'Waiting' in log_text
# Should show attempt numbers
assert 'attempt' in log_text.lower()
# Final response should be 200
assert response.status_code == 200
def test_verbose_logging_for_semantic_scholar_authors():
"""Test that verbose logs are generated for author verification in Semantic Scholar."""
from unittest.mock import Mock, patch
verifier = CitationVerifier()
entry = {
'ID': 'test2023',
'title': 'Test Paper About Machine Learning',
'author': 'Smith, John and Doe, Jane',
'year': '2023'
}
search_url = 'https://www.semanticscholar.org/paper/abc123'
# Mock the Semantic Scholar API response
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
'title': 'Test Paper About Machine Learning',
'authors': [
{'name': 'John Smith'},
{'name': 'Jane Doe'}
]
}
with patch.object(verifier, '_make_request_with_retry', return_value=mock_response):
correct, message, details, logs = verifier._check_metadata(entry, search_url)
# Should have verbose logs
assert len(logs) > 0
log_text = ' '.join(logs)
# Should mention author comparison
assert 'Comparing authors' in log_text or 'authors' in log_text.lower()
assert 'BibTeX' in log_text
assert 'Online' in log_text
assert 'Extracted' in log_text
# Should show similarity or match result
assert 'Similarity' in log_text or 'Match' in log_text or 'Result' in log_text
@pytest.mark.integration
def test_sotopia_pi_paper_google_scholar():
"""Test parsing Google Scholar HTML for SOTOPIA-π paper with real HTTP request."""
verifier = CitationVerifier()
# Make actual HTTP request to Google Scholar for SOTOPIA-π paper
title_query = "SOTOPIA-π: Interactive Learning of Socially Intelligent Language Agents"
from urllib.parse import quote_plus
search_url = f"https://scholar.google.com/scholar?q={quote_plus(title_query)}"
try:
response = verifier._make_request_with_retry('get', search_url, timeout=verifier.timeout)
if response.status_code == 200:
html = response.text
title, authors = verifier._parse_google_scholar_first_result(html)
# Verify title extraction
assert title is not None, "Should extract a title from Google Scholar"
# Title should contain key parts (be lenient as Google Scholar may format differently)
assert "SOTOPIA" in title or "sotopia" in title.lower(), f"Expected 'SOTOPIA' in title: '{title}'"
# BibTeX title has LaTeX notation: SOTOPIA-$\pi$
bibtex_title = "SOTOPIA-$\\pi$: Interactive Learning of Socially Intelligent Language Agents"
cleaned_bibtex_title = verifier._remove_curly_braces(bibtex_title).lower()
# Calculate similarity
similarity = verifier._calculate_title_similarity(cleaned_bibtex_title, title.lower())
# Be more lenient since Google Scholar may format titles differently
assert similarity >= 0.3, \
f"Title similarity {similarity:.2%} should be >= 30% for '{title}'"
# Verify author extraction
if authors:
author_str = ', '.join(authors).lower()
# Check that at least some key authors are present
# Google Scholar may abbreviate names differently
key_authors_found = 0
for key_author in ['wang', 'neubig', 'zhu', 'yu', 'sap', 'bisk']:
if key_author in author_str:
key_authors_found += 1
assert key_authors_found >= 3, \
f"Expected at least 3 key authors in {authors}, found {key_authors_found}"
# Verify author matching would work
bibtex_authors = "Wang, Ruiyi and Yu, Haofei and Zhang, Wenxin and Qi, Zhengyang and Sap, Maarten and Bisk, Yonatan and Neubig, Graham and Zhu, Hao"
bibtex_author_names = verifier._extract_author_names(bibtex_authors)
online_author_names = verifier._extract_author_names(', '.join(authors))
# Calculate author similarity
author_similarity = verifier._calculate_author_similarity(bibtex_author_names, online_author_names)
# Be lenient as Google Scholar may format authors differently
assert author_similarity >= 0.25, \
f"Author similarity {author_similarity:.2%} should be >= 25%"
elif response.status_code == 429:
pytest.skip("Google Scholar rate limited (429) - skipping test")
else:
pytest.skip(f"Google Scholar returned status {response.status_code} - skipping test")
except Exception as e:
pytest.skip(f"Network error accessing Google Scholar: {str(e)}")
def test_custom_max_retries():
"""Test that max_retries parameter can be customized."""
from unittest.mock import Mock, patch
# Create verifier with custom max_retries
verifier = CitationVerifier(max_retries=5)
# Verify the value is set correctly
assert verifier.max_retries == 5
# Mock response that always returns 429
mock_response_429 = Mock()
mock_response_429.status_code = 429
with patch.object(verifier.session, 'get', return_value=mock_response_429) as mock_get:
with patch('time.sleep') as mock_sleep:
response = verifier._make_request_with_retry('get', 'https://example.com')
# Should have called get 6 times (initial + 5 retries)
assert mock_get.call_count == 6
# Should have slept 5 times
assert mock_sleep.call_count == 5
# Final response should still be 429
assert response.status_code == 429
if __name__ == '__main__':
pytest.main([__file__, '-v'])