import os
import tempfile
import threading
import unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from unittest.mock import Mock, patch
import pandas as pd
os.environ.setdefault("APP_STORAGE_DIR", tempfile.mkdtemp(prefix="llm_screener_tests_"))
import app
class ReviewFieldsTests(unittest.TestCase):
def test_user_storage_ids_cannot_escape_the_users_directory(self):
self.assertEqual(app.sanitize_user_id(".."), "default")
self.assertEqual(app.sanitize_user_id("research/group"), "research_group")
def test_concurrent_session_updates_preserve_every_field(self):
user_id = "concurrent-save-user"
app.save_session_meta(user_id, {"df_path": "/data/original.xlsx"})
worker_count = 24
start = threading.Barrier(worker_count)
def save_field(index):
start.wait()
app.save_session_meta(user_id, {f"field_{index}": index})
with ThreadPoolExecutor(max_workers=worker_count) as executor:
list(executor.map(save_field, range(worker_count)))
saved = app.load_session_meta(user_id)
self.assertEqual(saved["df_path"], "/data/original.xlsx")
for index in range(worker_count):
self.assertEqual(saved[f"field_{index}"], index)
def test_invalid_session_metadata_is_not_overwritten(self):
user_id = "invalid-session-user"
session_path = app.get_user_session_meta_path(user_id)
app._ensure_session_dirs(user_id)
invalid_contents = '{"df_path": "unterminated'
session_path.write_text(invalid_contents, encoding="utf-8")
with self.assertRaisesRegex(RuntimeError, "refusing to overwrite"):
app.save_session_meta(user_id, {"position": 2})
self.assertEqual(session_path.read_text(encoding="utf-8"), invalid_contents)
def test_config_editor_updates_target_state_used_by_actions(self):
df = pd.DataFrame(
{
"URL": ["https://example.test"],
"Outcome": [None],
"Population": [None],
}
)
with patch.object(app, "save_session_meta") as save_meta:
result = app.sync_extraction_config_inputs(
"Population",
"URL",
df,
"sync-user",
["Outcome", "Population"],
"Outcome description",
"Population description",
)
self.assertEqual(result[0], ["Population"])
self.assertEqual(result[1], ["Population"])
self.assertEqual(result[2]["label"], "Description: Population")
self.assertEqual(result[2]["value"], "Population description")
saved = save_meta.call_args.args[1]
self.assertEqual(saved["target_columns_text"], "Population")
self.assertEqual(saved["description_values"], ["Population description"])
def test_reserved_review_columns_are_not_extraction_targets(self):
df = pd.DataFrame(
{
"URL": ["https://example.test"],
"Outcome": [None],
app.DECISION_COLUMN: [""],
app.INCLUSION_RATIONALE_COLUMN: [""],
}
)
self.assertEqual(app._parse_target_columns_for_ui("", "URL", df), ["Outcome"])
self.assertEqual(
app._parse_target_columns_for_ui(
"Outcome, Decision, Inclusion Rationale", "URL", df
),
["Outcome"],
)
def test_review_columns_are_added_without_changing_existing_values(self):
df = pd.DataFrame(
{
"URL": ["https://example.test"],
"Outcome": [None],
app.DECISION_COLUMN: ["exclude"],
}
)
result = app.ensure_review_output_columns(df)
self.assertIs(result, df)
self.assertEqual(result.loc[0, "URL"], "https://example.test")
self.assertEqual(result.loc[0, app.DECISION_COLUMN], "exclude")
self.assertIn(app.DECISION_COLUMN, result.columns)
self.assertIn(app.INCLUSION_RATIONALE_COLUMN, result.columns)
def test_accept_saves_edited_fields_decision_and_rationale(self):
df = pd.DataFrame(
{
"URL": ["https://example.test/1", "https://example.test/2"],
"Outcome": [None, "Existing outcome"],
app.DECISION_COLUMN: ["", "include"],
app.INCLUSION_RATIONALE_COLUMN: ["", "Existing rationale"],
}
)
with (
patch.object(app, "persist_downloadable_dataframe", return_value=None),
patch.object(app, "persist_dataframe", return_value="/tmp/working.xlsx"),
patch.object(app, "save_session_meta"),
patch.object(app, "load_session_meta", return_value={}),
):
result = app.accept_extraction(
["Outcome"],
"test-user",
df,
0,
[0],
0,
"URL",
["Outcome"],
"exclude",
"The population does not meet the inclusion criteria.",
"Edited outcome",
)
accepted_df = result[0]
self.assertEqual(accepted_df.at[0, "Outcome"], "Edited outcome")
self.assertEqual(accepted_df.at[0, app.DECISION_COLUMN], "exclude")
self.assertEqual(
accepted_df.at[0, app.INCLUSION_RATIONALE_COLUMN],
"The population does not meet the inclusion criteria.",
)
self.assertEqual(accepted_df.at[1, "Outcome"], "Existing outcome")
self.assertEqual(accepted_df.at[1, app.DECISION_COLUMN], "include")
self.assertEqual(
accepted_df.at[1, app.INCLUSION_RATIONALE_COLUMN], "Existing rationale"
)
def test_accept_saves_to_displayed_all_table_position_when_row_state_is_stale(self):
df = pd.DataFrame(
{
"URL": ["zero", "one", "two", "three"],
"Outcome": ["Original zero", "Original one", None, "Original three"],
}
)
user_id = "accept-position-user"
result = app.accept_extraction(
["Outcome"],
user_id,
df,
0, # Simulate the stale pre-navigation row-index state.
[2],
2, # The displayed all-table row is position 2 (Row 3).
"URL",
["Outcome"],
"include",
"Displayed row rationale",
"Saved to displayed row",
)
accepted_df = result[0]
self.assertEqual(accepted_df.at[0, "Outcome"], "Original zero")
self.assertEqual(accepted_df.at[2, "Outcome"], "Saved to displayed row")
persisted_df = pd.read_excel(app.load_session_meta(user_id)["df_path"])
self.assertEqual(persisted_df.at[0, "Outcome"], "Original zero")
self.assertEqual(persisted_df.at[2, "Outcome"], "Saved to displayed row")
def test_gradio_review_fields_have_expected_interactivity(self):
self.assertFalse(app.evidence_box.interactive)
self.assertTrue(app.decision_box.interactive)
self.assertTrue(app.inclusion_rationale_box.interactive)
self.assertTrue(all(component.interactive for component in app.extracted_inputs))
self.assertFalse(app.row_number_input.show_label)
self.assertFalse(app.row_number_input.container)
self.assertEqual(app.row_number_input.min_width, 70)
self.assertEqual(app.row_number_input.elem_id, "row-navigation-input")
self.assertIn("grid-template-columns", app.ROW_NAVIGATION_CSS)
button_values = {
component.get("props", {}).get("value")
for component in app.demo.config["components"]
if component.get("type") == "button"
}
self.assertNotIn("Start screening", button_values)
load_excel_events = [
dependency
for dependency in app.demo.config["dependencies"]
if dependency.get("api_name") == "load_excel"
]
self.assertTrue(load_excel_events)
self.assertTrue(
any(
event_name == "upload"
for dependency in load_excel_events
for _, event_name in dependency["targets"]
)
)
labels = {
component.get("props", {}).get("label")
for component in app.demo.config["components"]
}
self.assertNotIn("Confidence", labels)
self.assertFalse(any(label and "RAYYAN" in label for label in labels))
def test_table_preview_toggle_switches_full_app_pages(self):
opened = app.toggle_table_preview(False)
self.assertEqual(len(opened), len(app.preview_toggle_outputs))
self.assertTrue(opened[0])
self.assertFalse(opened[1]["visible"])
self.assertFalse(opened[2]["visible"])
self.assertFalse(opened[3]["visible"])
self.assertFalse(opened[4]["visible"])
self.assertTrue(opened[5]["visible"])
self.assertEqual(opened[6]["value"], "Close table preview")
closed = app.toggle_table_preview(True)
self.assertFalse(closed[0])
self.assertTrue(closed[1]["visible"])
self.assertFalse(closed[5]["visible"])
self.assertEqual(closed[6]["value"], "Open table preview")
def test_table_preview_is_editable_and_preloadable(self):
df = pd.DataFrame({"URL": ["one"], "Outcome": [None]})
update, status = app.sync_table_preview(df)
self.assertTrue(app.working_table_preview.interactive)
self.assertEqual(app.working_table_preview.show_search, "filter")
self.assertFalse(app.working_table_preview.show_row_numbers)
self.assertEqual(app.working_table_preview.static_columns, [0])
self.assertEqual(app.working_table_preview.buttons, ["fullscreen", "copy"])
pd.testing.assert_frame_equal(update["value"], df)
self.assertEqual(update["headers"], ["URL", "Outcome"])
self.assertIn("1 rows × 2 columns", status)
def test_large_table_preview_centers_on_current_row(self):
df = pd.DataFrame({"URL": [f"row-{index}" for index in range(50)]})
preview, is_limited = app.build_table_preview_frame(df, 25)
self.assertTrue(is_limited)
self.assertEqual(len(preview), app.TABLE_PREVIEW_MAX_ROWS)
self.assertEqual(preview.index[0], 15)
self.assertEqual(preview.index[-1], 35)
def test_table_preview_selects_metadata_targets_and_keeps_working_row_ids(self):
df = pd.DataFrame(
{
"URL": [f"url-{index}" for index in range(50)],
"Title": [f"Title {index}" for index in range(50)],
"Authors": [f"Author {index}" for index in range(50)],
"Year": list(range(2000, 2050)),
"Outcome": [None] * 50,
"Hidden column": [index for index in range(50)],
}
)
preview, _ = app.build_table_preview_frame(df, 25, ["Outcome"])
self.assertEqual(
list(preview.columns),
[app.TABLE_PREVIEW_ROW_ID_COLUMN, "Title", "Authors", "Year", "Outcome"],
)
self.assertEqual(preview.iloc[0][app.TABLE_PREVIEW_ROW_ID_COLUMN], 16)
self.assertEqual(preview.iloc[-1][app.TABLE_PREVIEW_ROW_ID_COLUMN], 36)
self.assertNotIn("URL", preview.columns)
self.assertNotIn("Hidden column", preview.columns)
def test_large_table_edit_updates_only_displayed_rows(self):
current_df = pd.DataFrame(
{
"URL": [f"row-{index}" for index in range(50)],
"Outcome": [None] * 50,
}
)
edited_table, _ = app.build_table_preview_frame(current_df, 25, ["Outcome"])
edited_table = edited_table.copy()
edited_table.at[15, "Outcome"] = "first displayed edit"
edited_table.at[25, "Outcome"] = "active row edit"
edited_table.at[35, "Outcome"] = "last displayed edit"
with (
patch.object(app, "persist_dataframe", return_value="/tmp/working.xlsx") as persist,
patch.object(app, "save_session_meta"),
):
result = app.apply_table_edits(
edited_table,
current_df,
"large-table-user",
25,
25,
"URL",
["Outcome"],
)
updated_df = result[0]
self.assertEqual(updated_df.at[15, "Outcome"], "first displayed edit")
self.assertEqual(updated_df.at[25, "Outcome"], "active row edit")
self.assertEqual(updated_df.at[35, "Outcome"], "last displayed edit")
self.assertTrue(updated_df.loc[~updated_df.index.isin([15, 25, 35]), "Outcome"].isna().all())
pd.testing.assert_frame_equal(persist.call_args.args[1], updated_df)
def test_table_preview_sync_skips_when_preview_closed(self):
update, status = app.sync_table_preview_if_open(pd.DataFrame({"URL": ["one"]}), False)
skip_type = type(app.gr.skip())
self.assertIsInstance(update, skip_type)
self.assertIsInstance(status, skip_type)
def test_table_cell_edit_saves_and_marks_download_stale(self):
current_df = pd.DataFrame(
{
"URL": ["one", "two"],
"Title": ["Old title", "Second"],
"Outcome": [None, None],
}
)
edited_df = current_df.copy()
edited_df.at[0, "Title"] = "Edited title"
edited_df.at[0, "Outcome"] = "Complete"
with (
patch.object(app, "persist_dataframe", return_value="/tmp/working.xlsx") as persist,
patch.object(app, "save_session_meta") as save_meta,
):
result = app.apply_table_edits(
edited_df,
current_df,
"table-user",
0,
0,
"URL",
["Outcome"],
)
self.assertEqual(len(result), len(app.table_edit_outputs))
pd.testing.assert_frame_equal(result[0], edited_df)
self.assertEqual(result[1], [1])
self.assertIn("Edited title", result[2])
self.assertEqual(result[6], 1)
self.assertEqual(result[7], "of 2 (index: 0)")
self.assertIn("Download is out of date", result[9])
self.assertIn("Working table edit saved", result[10])
pd.testing.assert_frame_equal(persist.call_args.args[1], edited_df)
self.assertEqual(save_meta.call_args.args[1]["df_path"], "/tmp/working.xlsx")
self.assertEqual(save_meta.call_args.args[1]["download_path"], "")
def test_refresh_download_export_generates_file_on_demand(self):
df = pd.DataFrame({"URL": ["one"], "Outcome": ["done"]})
with (
patch.object(app, "persist_downloadable_dataframe", return_value="/tmp/export.xlsx"),
patch.object(app, "build_inline_download_html", return_value="download-html"),
patch.object(app, "save_session_meta") as save_meta,
):
result = app.refresh_download_export(df, "download-user")
self.assertEqual(result, ("download-html", "Download export refreshed.", "Download export refreshed."))
self.assertEqual(save_meta.call_args.args[1]["download_path"], "/tmp/export.xlsx")
def test_table_structural_edit_is_rejected_and_reverted(self):
current_df = pd.DataFrame({"URL": ["one"], "Outcome": [None]})
edited_df = pd.concat(
[current_df, pd.DataFrame({"URL": ["two"], "Outcome": [None]})],
ignore_index=True,
)
with (
patch.object(app, "persist_dataframe") as persist,
patch.object(app, "save_session_meta") as save_meta,
):
result = app.apply_table_edits(
edited_df,
current_df,
"table-user",
0,
0,
"URL",
["Outcome"],
)
self.assertEqual(len(result), len(app.table_edit_outputs))
pd.testing.assert_frame_equal(result[0], current_df)
pd.testing.assert_frame_equal(result[8]["value"], current_df)
self.assertIn("rows cannot be added or removed", result[10])
persist.assert_not_called()
save_meta.assert_not_called()
def test_navigation_opens_exact_completed_row_and_loads_review_fields(self):
df = pd.DataFrame(
{
"URL": ["https://example.test/1", "https://example.test/2"],
"Title": ["First", "Completed row"],
"Outcome": [None, "Existing outcome"],
app.DECISION_COLUMN: ["", "exclude"],
app.INCLUSION_RATIONALE_COLUMN: ["", "Existing rationale"],
}
)
with patch.object(app, "save_session_meta") as save_meta:
result = app.navigate_to_row(
2,
"navigation-user",
df,
0,
0,
"URL",
["Outcome"],
)
self.assertEqual(len(result), len(app.navigation_outputs))
self.assertEqual(result[0], 1)
self.assertEqual(result[1], 1)
self.assertIn("Completed row", result[2])
self.assertEqual(result[6], 2)
self.assertEqual(result[7], "of 2 (index: 1)")
self.assertEqual(result[9], ["Outcome"])
self.assertEqual(result[10]["value"], "Existing outcome")
self.assertEqual(result[-5], "")
self.assertEqual(result[-4], "exclude")
self.assertEqual(result[-3], "Existing rationale")
self.assertEqual(result[-2]["fields"], {"Outcome": "Existing outcome"})
saved = save_meta.call_args.args[1]
self.assertEqual(saved["position"], 1)
self.assertEqual(saved["current_row_index"], 1)
self.assertEqual(saved["extracted_columns"], ["Outcome"])
self.assertEqual(saved["extracted_values"], ["Existing outcome"])
self.assertEqual(saved["decision"], "exclude")
self.assertEqual(saved["inclusion_rationale"], "Existing rationale")
self.assertEqual(saved["pdf_path"], "")
def test_invalid_navigation_keeps_current_row_and_review(self):
df = pd.DataFrame({"URL": ["one", "two"], "Outcome": [None, None]})
for requested in (None, 0, 1.5, 3):
with self.subTest(requested=requested), patch.object(
app, "save_session_meta"
) as save_meta:
result = app.navigate_to_row(
requested,
"navigation-user",
df,
1,
1,
"URL",
["Outcome"],
)
self.assertEqual(result[0], 1)
self.assertEqual(result[1], 1)
self.assertEqual(result[6], 2)
self.assertIn("row number", result[8].lower())
save_meta.assert_not_called()
def test_next_incomplete_row_scans_forward_by_excel_position(self):
df = pd.DataFrame(
{
"Outcome": [None, "complete", None, "complete", None],
}
)
self.assertEqual(app.find_next_incomplete_row(df, 1, ["Outcome"]), (2, 2))
self.assertEqual(app.find_next_incomplete_row(df, 3, ["Outcome"]), (4, 4))
self.assertEqual(app.find_next_incomplete_row(df, 5, ["Outcome"]), (5, None))
def test_skip_advances_to_next_forward_incomplete_excel_row(self):
df = pd.DataFrame(
{
"URL": ["one", "two", "three"],
"Outcome": [None, "complete", None],
}
)
with (
patch.object(app, "persist_downloadable_dataframe", return_value=None),
patch.object(app, "persist_dataframe", return_value="/tmp/working.xlsx"),
patch.object(app, "save_session_meta"),
patch.object(app, "load_session_meta", return_value={}),
):
result = app.skip_row(
"skip-user",
df,
[0, 2],
0,
"URL",
["Outcome"],
)
self.assertEqual(len(result), len(app.skip_outputs))
self.assertEqual(result[1], 2)
self.assertEqual(result[2], 2)
self.assertEqual(result[7], 3)
self.assertEqual(result[8], "of 3 (index: 2)")
def test_successful_load_adds_review_columns_and_matches_output_count(self):
source_path = os.path.join(os.environ["APP_STORAGE_DIR"], "source.xlsx")
pd.DataFrame(
{"URL": ["https://example.test"], "Outcome": [None]}
).to_excel(source_path, index=False)
result = app.load_excel(
source_path,
None,
"load-test-user",
"Outcome",
"URL",
[],
)
self.assertEqual(len(result), len(app.start_outputs))
self.assertIn(app.DECISION_COLUMN, result[0].columns)
self.assertIn(app.INCLUSION_RATIONALE_COLUMN, result[0].columns)
self.assertEqual(result[4], ["Outcome"])
self.assertEqual(result[6]["fields"], {"Outcome": ""})
self.assertEqual(result[8], ["Outcome"])
def test_successful_processing_matches_simplified_output_count(self):
df = app.ensure_review_output_columns(
pd.DataFrame({"URL": ["https://example.test"], "Outcome": [None]})
)
extracted = {
"fields": {"Outcome": "Generated outcome"},
"evidence": "- Outcome: supporting text",
"evidence_snippets": [
{"field": "Outcome", "snippet": "supporting text"}
],
"confidence": 0.9,
"decision": "include",
}
with (
patch.object(app, "parse_pdf", return_value="article text"),
patch.object(app, "extract_with_llm", return_value=extracted),
patch.object(app, "persist_uploaded_file", return_value=None),
patch.object(app, "save_session_meta"),
patch.object(app, "load_session_meta", return_value={}),
):
result = app.process_pdf_and_extract(
"article.pdf",
None,
"process-test-user",
df,
0,
0,
["Outcome"],
["Outcome"],
"Extract the outcome",
progress=lambda *args, **kwargs: None,
)
self.assertEqual(len(result), len(app.process_outputs))
self.assertEqual(result[-4], "- Outcome: supporting text")
self.assertEqual(result[-3], "include")
self.assertEqual(result[-2], "")
def test_processing_uses_displayed_position_when_row_state_is_stale(self):
df = app.ensure_review_output_columns(
pd.DataFrame(
{
"URL": ["zero", "one", "two"],
"Outcome": ["Complete", "Complete", None],
"Population": ["Complete", "Complete", "Complete"],
}
)
)
extracted = {
"fields": {"Outcome": "Generated outcome"},
"evidence": "- Outcome: supporting text",
"evidence_snippets": [],
"confidence": 0.9,
"decision": "include",
}
with (
patch.object(app, "parse_pdf", return_value="article text"),
patch.object(app, "extract_with_llm", return_value=extracted) as extract,
patch.object(app, "persist_uploaded_file", return_value=None),
patch.object(app, "save_session_meta"),
patch.object(app, "load_session_meta", return_value={}),
):
result = app.process_pdf_and_extract(
"article.pdf",
None,
"process-position-user",
df,
0, # Stale row-index state.
2, # Displayed all-table position (Row 3).
["Outcome", "Population"],
["Outcome", "Population"],
"Extract outcome",
"Extract population",
progress=lambda *args, **kwargs: None,
)
self.assertEqual(extract.call_args.args[1], ["Outcome"])
self.assertEqual(result[1], ["Outcome", "Population"])
self.assertEqual(result[2]["value"], "Generated outcome")
self.assertEqual(result[3]["value"], "Complete")
def test_completed_row_reprocesses_all_target_fields(self):
df = app.ensure_review_output_columns(
pd.DataFrame(
{
"URL": ["https://example.test"],
"Outcome": ["Existing outcome"],
"Population": ["Existing population"],
}
)
)
extracted = {
"fields": {
"Outcome": "Regenerated outcome",
"Population": "Regenerated population",
},
"evidence": "- Outcome: supporting text",
"evidence_snippets": [],
"confidence": 0.9,
"decision": "include",
}
with (
patch.object(app, "parse_pdf", return_value="article text"),
patch.object(app, "extract_with_llm", return_value=extracted) as extract,
patch.object(app, "persist_uploaded_file", return_value=None),
patch.object(app, "save_session_meta"),
patch.object(app, "load_session_meta", return_value={}),
):
result = app.process_pdf_and_extract(
"article.pdf",
None,
"process-test-user",
df,
0,
0,
["Outcome", "Population"],
["Outcome", "Population"],
"Extract outcome",
"Extract population",
progress=lambda *args, **kwargs: None,
)
self.assertEqual(
extract.call_args.args[1],
["Outcome", "Population"],
)
self.assertIn("Reprocessing completed row", result[-1])
def test_processing_uses_target_columns_from_latest_config_sync(self):
df = app.ensure_review_output_columns(
pd.DataFrame(
{
"URL": ["https://example.test"],
"Outcome": ["Existing outcome"],
"Population": ["Existing population"],
}
)
)
extracted = {
"fields": {"Population": "Generated population"},
"evidence": "- Population: supporting text",
"evidence_snippets": [],
"confidence": 0.9,
"decision": "include",
}
with (
patch.object(app, "parse_pdf", return_value="article text"),
patch.object(app, "extract_with_llm", return_value=extracted) as extract,
patch.object(app, "persist_uploaded_file", return_value=None),
patch.object(app, "save_session_meta"),
patch.object(app, "load_session_meta", return_value={}),
):
synced = app.sync_extraction_config_inputs(
"Population",
"URL",
df,
"process-test-user",
["Outcome", "Population"],
"Outcome description",
"Population description",
)
app.process_pdf_and_extract(
"article.pdf",
None,
"process-test-user",
df,
0,
0,
synced[0],
synced[1],
*[update.get("value", "") for update in synced[2:]],
progress=lambda *args, **kwargs: None,
)
self.assertEqual(extract.call_args.args[1], ["Population"])
def test_restore_uses_saved_exact_row_and_legacy_queue_position(self):
source_path = os.path.join(os.environ["APP_STORAGE_DIR"], "restore_source.xlsx")
pd.DataFrame(
{
"URL": ["one", "two", "three"],
"Outcome": [None, "complete", None],
}
).to_excel(source_path, index=False)
user_id = "restore-navigation-user"
app.load_excel(source_path, None, user_id, "Outcome", "URL", [])
app.save_session_meta(
user_id,
{
"position": 1,
"position_mode": "all_rows",
"current_row_index": 1,
"extracted_columns": ["Outcome"],
"extracted_values": ["complete"],
},
)
restored = app.restore_saved_session(user_id)
self.assertEqual(len(restored), len(app.demo_load_outputs))
self.assertEqual(restored[8], 1)
self.assertEqual(restored[9], 1)
self.assertEqual(restored[12]["fields"], {"Outcome": "complete"})
self.assertEqual(restored[14], ["Outcome"])
self.assertEqual(restored[20], 2)
self.assertEqual(restored[21], "of 3 (index: 1)")
app.save_session_meta(
user_id,
{
"position": 1,
"position_mode": "",
"current_row_index": None,
"extracted_columns": [],
"extracted_values": [],
},
)
legacy_restored = app.restore_saved_session(user_id)
self.assertEqual(legacy_restored[8], 2)
self.assertEqual(legacy_restored[9], 2)
def test_event_return_counts_match_gradio_outputs(self):
self.assertEqual(
len(app.empty_extracted_state("test")),
2 + len(app.extraction_outputs) + 1,
)
self.assertEqual(len(app.reject_extraction("test-user")), len(app.reject_outputs))
self.assertEqual(
len(
app.accept_extraction(
[], "test-user", None, None, [], 0, "", [], "include", ""
)
),
len(app.accept_outputs),
)
self.assertEqual(
len(app.skip_row("test-user", None, [], 0, "", [])),
len(app.skip_outputs),
)
self.assertEqual(
len(app.load_excel(None, None, "test-user", "", "", [])),
len(app.start_outputs),
)
self.assertEqual(
len(app.navigate_to_row(1, "test-user", None, 0, None, "", [])),
len(app.navigation_outputs),
)
self.assertEqual(
len(app.clear_user_session("clear-output-test")),
len(app.clear_session_outputs),
)
def test_metadata_detection_supports_common_column_names_and_missing_values(self):
df = pd.DataFrame(
{"Article title": ["A study"], "First author": ["Researcher"], "Publication year": [2024]}
)
self.assertEqual(
app.identify_article_metadata_columns(df),
{"title": "Article title", "authors": "First author", "year": "Publication year"},
)
details = app.article_details_markdown(df, 0)
self.assertIn("A study", details)
self.assertIn("Researcher", details)
self.assertIn("2024", details)
def test_metadata_rendering_does_not_require_title_or_author_columns(self):
df = pd.DataFrame({"Record ID": [1], "Outcome": [None]})
details = app.article_details_markdown(df, 0)
self.assertIn("Title:** Not available", details)
self.assertIn("Author(s):** Not available", details)
def test_explicit_unknown_target_columns_are_reported(self):
df = pd.DataFrame({"URL": ["url"], "Outcome": [None]})
selected, unknown = app.target_column_validation("Outcome, Missing", "URL", df)
self.assertEqual(selected, ["Outcome"])
self.assertEqual(unknown, ["Missing"])
def test_review_summary_counts_remaining_rows(self):
df = pd.DataFrame(
{
"Outcome": ["yes", None, "no"],
app.DECISION_COLUMN: ["include", "", "exclude"],
}
)
summary = app.build_review_summary(df, ["Outcome"])
self.assertIn("2/3 rows complete", summary)
self.assertIn("1 included", summary)
self.assertIn("1 excluded", summary)
self.assertIn("1 remaining", summary)
class AutoFetchPdfTests(unittest.TestCase):
def test_doi_is_extracted_from_a_doi_org_url(self):
self.assertEqual(
app.extract_doi_from_text("https://doi.org/10.1234/abc.def-5678"),
"10.1234/abc.def-5678",
)
def test_doi_extraction_returns_none_without_a_doi(self):
self.assertIsNone(app.extract_doi_from_text("https://example.com/articles/42"))
self.assertIsNone(app.extract_doi_from_text(""))
def test_auto_fetch_skips_when_disabled(self):
df = pd.DataFrame({"URL": ["https://doi.org/10.1234/abc"]})
with patch.object(app, "AUTO_FETCH_PDF_ENABLED", False):
path, message = app.auto_fetch_pdf_for_row(df, 0, "URL", "auto-fetch-user")
self.assertIsNone(path)
self.assertEqual(message, "")
def test_auto_fetch_skips_rows_without_a_usable_url(self):
df = pd.DataFrame({"URL": [None]})
path, message = app.auto_fetch_pdf_for_row(df, 0, "URL", "auto-fetch-user")
self.assertIsNone(path)
self.assertEqual(message, "")
def test_auto_fetch_uses_direct_url_when_it_serves_a_pdf(self):
df = pd.DataFrame({"URL": ["https://example.com/article.pdf"]})
user_id = "auto-fetch-direct-user"
with (
patch.object(app, "_download_if_pdf", return_value=b"%PDF-1.4 fake") as download,
patch.object(app, "_fetch_pdf_via_unpaywall") as unpaywall,
):
path, message = app.auto_fetch_pdf_for_row(df, 0, "URL", user_id)
download.assert_called_once_with("https://example.com/article.pdf")
unpaywall.assert_not_called()
self.assertIsNotNone(path)
self.assertTrue(Path(path).exists())
self.assertEqual(Path(path).read_bytes(), b"%PDF-1.4 fake")
self.assertIn("article URL", message)
def test_auto_fetch_falls_back_to_unpaywall_by_doi(self):
df = pd.DataFrame({"URL": ["https://doi.org/10.1234/abc"]})
user_id = "auto-fetch-unpaywall-user"
with (
patch.object(app, "_download_if_pdf", return_value=None) as download,
patch.object(app, "_fetch_pdf_via_unpaywall", return_value=b"%PDF-1.4 oa") as unpaywall,
):
path, message = app.auto_fetch_pdf_for_row(df, 0, "URL", user_id)
download.assert_called_once_with("https://doi.org/10.1234/abc")
unpaywall.assert_called_once_with("10.1234/abc")
self.assertIsNotNone(path)
self.assertEqual(Path(path).read_bytes(), b"%PDF-1.4 oa")
self.assertIn("Unpaywall", message)
def test_unpaywall_lookup_is_skipped_without_a_configured_contact_email(self):
# Unpaywall's API rejects placeholder/example.com addresses with a 422, so
# sending one is worse than useless. Regression test for that bug.
with (
patch.object(app, "UNPAYWALL_EMAIL", ""),
patch("requests.get") as mocked_get,
):
result = app._fetch_pdf_via_unpaywall("10.1234/abc")
self.assertIsNone(result)
mocked_get.assert_not_called()
def test_doi_column_is_found_by_common_header_names(self):
df = pd.DataFrame({"URL": ["https://example.com/x"], "DOI": ["10.1234/abc"]})
self.assertEqual(app.find_doi_column(df), "DOI")
df2 = pd.DataFrame({"URL": ["https://example.com/x"], "Article DOI": ["10.1234/abc"]})
self.assertEqual(app.find_doi_column(df2), "Article DOI")
df3 = pd.DataFrame({"URL": ["https://example.com/x"], "Outcome": [""]})
self.assertIsNone(app.find_doi_column(df3))
def test_resolve_doi_from_columns_extracts_bare_doi_from_dedicated_column(self):
df = pd.DataFrame({"URL": ["https://example.com/x"], "DOI": ["10.1234/abc"]})
self.assertEqual(app.resolve_doi_from_columns(df, 0), "10.1234/abc")
def test_resolve_doi_from_columns_returns_none_without_a_doi_column_or_value(self):
df = pd.DataFrame({"URL": ["https://example.com/x"], "Outcome": [""]})
self.assertIsNone(app.resolve_doi_from_columns(df, 0))
df2 = pd.DataFrame({"URL": ["https://example.com/x"], "DOI": [None]})
self.assertIsNone(app.resolve_doi_from_columns(df2, 0))
def test_auto_fetch_falls_back_to_doi_column_when_url_has_no_doi(self):
df = pd.DataFrame(
{"URL": ["https://example.com/articles/42"], "DOI": ["10.1234/from-column"]}
)
user_id = "auto-fetch-doi-column-user"
with (
patch.object(app, "_download_if_pdf", return_value=None) as download,
patch.object(app, "_fetch_pdf_via_unpaywall", return_value=b"%PDF-1.4 col") as unpaywall,
):
path, message = app.auto_fetch_pdf_for_row(df, 0, "URL", user_id)
download.assert_called_once_with("https://example.com/articles/42")
unpaywall.assert_called_once_with("10.1234/from-column")
self.assertIsNotNone(path)
self.assertEqual(Path(path).read_bytes(), b"%PDF-1.4 col")
self.assertIn("Unpaywall", message)
def test_auto_fetch_prefers_doi_in_url_over_doi_column(self):
df = pd.DataFrame(
{"URL": ["https://doi.org/10.1234/from-url"], "DOI": ["10.1234/from-column"]}
)
with (
patch.object(app, "_download_if_pdf", return_value=None),
patch.object(app, "_fetch_pdf_via_unpaywall", return_value=None) as unpaywall,
patch.object(app, "_resolve_pmcid", return_value=None),
):
app.auto_fetch_pdf_for_row(df, 0, "URL", "auto-fetch-priority-user")
unpaywall.assert_called_once_with("10.1234/from-url")
def test_auto_fetch_returns_nothing_when_no_open_access_copy_exists(self):
df = pd.DataFrame({"URL": ["https://doi.org/10.1234/paywalled"]})
with (
patch.object(app, "_download_if_pdf", return_value=None),
patch.object(app, "_fetch_pdf_via_unpaywall", return_value=None),
patch.object(app, "_resolve_pmcid", return_value=None) as resolve_pmcid,
):
path, message = app.auto_fetch_pdf_for_row(df, 0, "URL", "auto-fetch-fail-user")
resolve_pmcid.assert_called_once_with("10.1234/paywalled")
self.assertIsNone(path)
self.assertEqual(message, "")
def test_auto_fetch_falls_back_to_pmc_when_unpaywall_has_no_copy(self):
# This is the JMIR case: the publisher's PDF endpoint is behind a bot
# challenge (returns 202/403 instead of a PDF), and Unpaywall's OA location
# points at that same blocked URL, so PMC is the only path left.
df = pd.DataFrame({"URL": ["https://www.jmir.org/2021/6/e18167/PDF"], "DOI": ["10.2196/18167"]})
with (
patch.object(app, "_download_if_pdf", return_value=None),
patch.object(app, "_fetch_pdf_via_unpaywall", return_value=None),
patch.object(app, "_resolve_pmcid", return_value="PMC8209530") as resolve_pmcid,
patch.object(app, "_fetch_pdf_via_pmc", return_value=b"%PDF-1.4 pmc") as fetch_pmc,
):
path, message = app.auto_fetch_pdf_for_row(df, 0, "URL", "auto-fetch-pmc-user")
resolve_pmcid.assert_called_once_with("10.2196/18167")
fetch_pmc.assert_called_once_with("PMC8209530")
self.assertIsNotNone(path)
self.assertEqual(Path(path).read_bytes(), b"%PDF-1.4 pmc")
self.assertIn("PubMed Central", message)
def test_auto_fetch_uses_pmcid_column_without_resolving_doi(self):
df = pd.DataFrame({"URL": ["https://example.com/x"], "PMCID": ["PMC8209530"]})
with (
patch.object(app, "_download_if_pdf", return_value=None),
patch.object(app, "_resolve_pmcid") as resolve_pmcid,
patch.object(app, "_fetch_pdf_via_pmc", return_value=b"%PDF-1.4 pmc") as fetch_pmc,
):
path, message = app.auto_fetch_pdf_for_row(df, 0, "URL", "auto-fetch-pmcid-col-user")
resolve_pmcid.assert_not_called()
fetch_pmc.assert_called_once_with("PMC8209530")
self.assertIsNotNone(path)
def test_auto_fetch_pmc_is_skipped_when_disabled(self):
df = pd.DataFrame({"URL": ["https://example.com/x"], "PMCID": ["PMC8209530"]})
with (
patch.object(app, "AUTO_FETCH_PMC_ENABLED", False),
patch.object(app, "_download_if_pdf", return_value=None),
patch.object(app, "_fetch_pdf_via_pmc") as fetch_pmc,
):
path, message = app.auto_fetch_pdf_for_row(df, 0, "URL", "auto-fetch-pmc-off-user")
fetch_pmc.assert_not_called()
self.assertIsNone(path)
def test_resolve_pmcid_from_columns_finds_pmcid_or_pmid_column(self):
df = pd.DataFrame({"URL": ["x"], "PMCID": ["PMC8209530"]})
self.assertEqual(app.resolve_pmcid_from_columns(df, 0), "PMC8209530")
with patch.object(app, "_resolve_pmcid", return_value="PMC999") as resolve_pmcid:
df2 = pd.DataFrame({"URL": ["x"], "PMID": ["34076579"]})
self.assertEqual(app.resolve_pmcid_from_columns(df2, 0), "PMC999")
resolve_pmcid.assert_called_once_with("34076579")
df3 = pd.DataFrame({"URL": ["x"], "Outcome": [""]})
self.assertIsNone(app.resolve_pmcid_from_columns(df3, 0))
def test_extract_text_from_jats_xml_flattens_title_abstract_and_body(self):
xml_bytes = b"""
A Study
Background text.
First paragraph.
Second paragraph.
"""
text = app._extract_text_from_jats_xml(xml_bytes)
self.assertIn("A Study", text)
self.assertIn("Background text.", text)
self.assertIn("First paragraph.", text)
self.assertIn("Second paragraph.", text)
def test_extract_text_from_jats_xml_returns_empty_on_malformed_xml(self):
self.assertEqual(app._extract_text_from_jats_xml(b"Fallback article"
oai_success = Mock(status_code=200, content=oai_xml)
with (
patch("requests.get", side_effect=[europe_error, europe_error, oai_success]) as get,
patch.object(app, "AUTO_FETCH_PMC_RETRIES", 1),
patch.object(app, "AUTO_FETCH_RETRY_BACKOFF_SECONDS", 0),
):
result = app._fetch_pmc_fulltext_xml("PMC5126964")
self.assertEqual(result, oai_xml)
self.assertEqual(get.call_count, 3)
self.assertEqual(
get.call_args_list[-1].kwargs["params"],
{
"verb": "GetRecord",
"identifier": "oai:pubmedcentral.nih.gov:5126964",
"metadataPrefix": "pmc",
},
)
def test_pmc_xml_rejects_invalid_pmcid_before_network_call(self):
with patch("requests.get") as get:
self.assertIsNone(app._fetch_pmc_fulltext_xml("PMC/not-an-id"))
get.assert_not_called()
def test_render_text_as_pdf_bytes_produces_a_valid_multi_page_pdf(self):
text = "\n\n".join([f"Paragraph {i} " + ("word " * 200) for i in range(10)])
pdf_bytes = app._render_text_as_pdf_bytes(text, title="Test Article")
self.assertTrue(pdf_bytes.startswith(b"%PDF-"))
import fitz
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
try:
self.assertGreater(doc.page_count, 1)
self.assertIn("Test Article", doc[0].get_text())
finally:
doc.close()
def test_auto_fetch_update_returns_gradio_update_with_fetched_path(self):
df = pd.DataFrame({"URL": ["https://example.com/article.pdf"]})
with patch.object(
app,
"auto_fetch_pdf_for_row",
return_value=("/tmp/fetched.pdf", "Auto-fetched PDF from the article URL."),
):
update, message = app.auto_fetch_pdf_update(df, 0, "URL", "auto-fetch-update-user")
self.assertEqual(update["value"], "/tmp/fetched.pdf")
self.assertIn("Auto-fetched", message)
if __name__ == "__main__":
unittest.main()