| row-label must NOT be misclassified as a header row.
+ from bmlib.fulltext.jats_parser import _TableBuilder
+
+ b = _TableBuilder()
+ b.start_row()
+ b.start_cell(is_header=True)
+ b.append_cell_text("Gene")
+ b.end_cell()
+ b.start_cell(is_header=False)
+ b.append_cell_text("1.2")
+ b.end_cell()
+ b.end_row()
+
+ assert b.header_rows == []
+ assert len(b.body_rows) == 1
+
+ def test_all_th_row_is_header(self):
+ from bmlib.fulltext.jats_parser import _TableBuilder
+
+ b = _TableBuilder()
+ b.start_row()
+ for text in ("Gene", "Value"):
+ b.start_cell(is_header=True)
+ b.append_cell_text(text)
+ b.end_cell()
+ b.end_row()
+
+ assert len(b.header_rows) == 1
+ assert b.body_rows == []
+
+
class TestJATSParserAbstract:
+ def test_titled_section_without_body_preserved(self):
+ # A structured-abstract subsection that has a title but no body
+ # must still be emitted, not silently dropped.
+ xml = (
+ b""
+ b"BackgroundSome text. "
+ b"Conclusions"
+ b""
+ )
+ article = JATSParser(xml).parse()
+ titles = [s.title for s in article.abstract_sections]
+ assert "Background" in titles
+ assert "Conclusions" in titles
+
def test_structured_abstract(self):
data = _load_fixture("sample_article.xml")
article = JATSParser(data).parse()
diff --git a/tests/test_llm.py b/tests/test_llm.py
index e145fa9..5065606 100644
--- a/tests/test_llm.py
+++ b/tests/test_llm.py
@@ -72,6 +72,7 @@ def test_recent_records(self):
class TestProviderRegistry:
def test_list_providers_includes_builtins(self):
from bmlib.llm.providers import list_providers
+
# Even without the actual packages installed, the registry
# should at least attempt to register them. If neither
# anthropic nor ollama is installed, the list may be empty —
@@ -83,5 +84,61 @@ def test_unknown_provider_raises(self):
import pytest
from bmlib.llm.providers import get_provider
+
with pytest.raises(ValueError, match="Unknown provider"):
get_provider("nonexistent_provider_xyz")
+
+
+class TestOllamaTokenAccounting:
+ """Real token counts of 0 must not be replaced by estimates."""
+
+ class _FakeOllamaClient:
+ def __init__(self, response):
+ self._response = response
+
+ def chat(self, **kwargs):
+ return self._response
+
+ def _make_provider(self, response):
+ from bmlib.llm.providers.ollama import OllamaProvider
+
+ provider = OllamaProvider()
+ provider._client = self._FakeOllamaClient(response)
+ return provider
+
+ def test_zero_counts_preserved(self):
+ response = {
+ "message": {"content": "hi"},
+ "prompt_eval_count": 0,
+ "eval_count": 0,
+ }
+ provider = self._make_provider(response)
+ resp = provider.chat([LLMMessage(role="user", content="hello")])
+ assert resp.input_tokens == 0
+ assert resp.output_tokens == 0
+
+ def test_missing_counts_estimated(self):
+ response = {"message": {"content": "hi"}} # no counts at all
+ provider = self._make_provider(response)
+ resp = provider.chat([LLMMessage(role="user", content="hello world")])
+ # Falls back to an estimate (> 0) when the field is absent.
+ assert resp.input_tokens > 0
+
+
+class TestModelStringParsing:
+ def test_default_provider_normalised_to_lowercase(self):
+ from bmlib.llm.client import LLMClient
+
+ client = LLMClient(default_provider="Anthropic")
+ assert client.default_provider == "anthropic"
+ # A bare model name routes to the (normalised) default provider.
+ provider, _model = client._parse_model_string(None)
+ assert provider == "anthropic"
+
+ def test_colon_form_lowercases_provider(self):
+ from bmlib.llm.client import LLMClient
+
+ client = LLMClient()
+ provider, model = client._parse_model_string("Anthropic:claude-x")
+ assert provider == "anthropic"
+ assert model == "claude-x"
diff --git a/tests/test_llm_tools.py b/tests/test_llm_tools.py
index cf40597..81c3fbd 100644
--- a/tests/test_llm_tools.py
+++ b/tests/test_llm_tools.py
@@ -38,7 +38,6 @@
LLMToolDefinition,
)
-
# ===========================================================================
# 1. DATA TYPE TESTS
# ===========================================================================
@@ -227,9 +226,7 @@ def test_messages_assistant_with_tool_calls_emits_tool_use_blocks(self):
LLMMessage(
role="assistant",
content="Let me add those.",
- tool_calls=[
- LLMToolCall(id="toolu_01", name="add", arguments={"a": 2, "b": 3})
- ],
+ tool_calls=[LLMToolCall(id="toolu_01", name="add", arguments={"a": 2, "b": 3})],
),
]
_, out = _convert_messages_to_anthropic(msgs)
@@ -347,9 +344,7 @@ def test_messages_tool_role_with_id(self):
msgs = [LLMMessage(role="tool", content="5", tool_call_id="call_0")]
out = _convert_messages_to_ollama(msgs)
- assert out == [
- {"role": "tool", "content": "5", "tool_call_id": "call_0"}
- ]
+ assert out == [{"role": "tool", "content": "5", "tool_call_id": "call_0"}]
def test_messages_tool_role_without_id(self):
from bmlib.llm.providers.ollama import _convert_messages_to_ollama
@@ -368,11 +363,7 @@ def test_messages_assistant_with_tool_calls(self):
LLMMessage(
role="assistant",
content="",
- tool_calls=[
- LLMToolCall(
- id="call_0", name="add", arguments={"a": 2, "b": 3}
- )
- ],
+ tool_calls=[LLMToolCall(id="call_0", name="add", arguments={"a": 2, "b": 3})],
),
]
out = _convert_messages_to_ollama(msgs)
@@ -457,9 +448,7 @@ def test_messages_tool_role_requires_tool_call_id(self):
msgs = [LLMMessage(role="tool", content="5", tool_call_id="call_0")]
out = _convert_messages_to_openai(msgs)
- assert out == [
- {"role": "tool", "content": "5", "tool_call_id": "call_0"}
- ]
+ assert out == [{"role": "tool", "content": "5", "tool_call_id": "call_0"}]
def test_messages_assistant_tool_calls_arguments_serialised_to_json_string(self):
from bmlib.llm.providers.openai_compat import _convert_messages_to_openai
@@ -468,11 +457,7 @@ def test_messages_assistant_tool_calls_arguments_serialised_to_json_string(self)
LLMMessage(
role="assistant",
content="",
- tool_calls=[
- LLMToolCall(
- id="call_0", name="add", arguments={"a": 2, "b": 3}
- )
- ],
+ tool_calls=[LLMToolCall(id="call_0", name="add", arguments={"a": 2, "b": 3})],
),
]
out = _convert_messages_to_openai(msgs)
@@ -488,9 +473,7 @@ def test_messages_assistant_only_tool_calls_has_none_content(self):
LLMMessage(
role="assistant",
content="",
- tool_calls=[
- LLMToolCall(id="x", name="ping", arguments={})
- ],
+ tool_calls=[LLMToolCall(id="x", name="ping", arguments={})],
),
]
out = _convert_messages_to_openai(msgs)
@@ -575,9 +558,7 @@ def test_chat_parses_tool_use_blocks_into_tool_calls(self):
tool_name="add", tool_input={"a": 5, "b": 7}
)
- tool = LLMToolDefinition(
- name="add", description="Add", parameters={}
- )
+ tool = LLMToolDefinition(name="add", description="Add", parameters={})
msgs = [LLMMessage(role="user", content="Add 5 and 7")]
result = p.chat(msgs, model="claude-sonnet-4-20250514", tools=[tool])
diff --git a/tests/test_migrations.py b/tests/test_migrations.py
index fe6a945..6b6f4cf 100644
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -21,8 +21,8 @@
from bmlib.db import (
connect_sqlite,
create_tables,
- table_exists,
fetch_scalar,
+ table_exists,
)
from bmlib.db.migrations import Migration, get_applied_versions, run_migrations
@@ -77,6 +77,51 @@ def test_empty_migration_list(self):
assert table_exists(conn, "schema_version")
+class TestSplitSqlStatements:
+ def test_splits_respecting_comments_and_strings(self):
+ from bmlib.db.operations import _split_sql_statements
+
+ script = (
+ "CREATE TABLE a (id INTEGER); -- trailing; comment\n"
+ "CREATE INDEX i ON a(id);\n"
+ "/* block; comment */ INSERT INTO a VALUES (1);\n"
+ "SELECT ';' AS x;"
+ )
+ out = _split_sql_statements(script)
+ assert out == [
+ "CREATE TABLE a (id INTEGER)",
+ "CREATE INDEX i ON a(id)",
+ "INSERT INTO a VALUES (1)",
+ "SELECT ';' AS x",
+ ]
+
+ def test_handles_escaped_quotes(self):
+ from bmlib.db.operations import _split_sql_statements
+
+ out = _split_sql_statements("SELECT 'it''s; ok' AS v; SELECT 2;")
+ assert out == ["SELECT 'it''s; ok' AS v", "SELECT 2"]
+
+
+class TestMigrationAtomicity:
+ def test_failed_migration_rolls_back_ddl(self):
+ """If a migration raises after creating a table, the DDL must roll back
+ and the version must not be recorded (no half-applied migration)."""
+ import pytest
+
+ def _bad(conn):
+ create_tables(conn, "CREATE TABLE t_bad (id INTEGER PRIMARY KEY);")
+ raise RuntimeError("boom after DDL")
+
+ conn = _mem()
+ with pytest.raises(RuntimeError, match="boom after DDL"):
+ run_migrations(conn, [Migration(1, "bad", _bad)])
+
+ # The table created inside the migration must have been rolled back.
+ assert not table_exists(conn, "t_bad")
+ # And the migration must not be marked as applied.
+ assert get_applied_versions(conn) == set()
+
+
class TestGetAppliedVersions:
def test_empty_on_fresh_db(self):
conn = _mem()
@@ -104,7 +149,5 @@ class TestVersionTableTracking:
def test_version_names_recorded(self):
conn = _mem()
run_migrations(conn, SAMPLE_MIGRATIONS)
- name = fetch_scalar(
- conn, "SELECT name FROM schema_version WHERE version = ?", (1,)
- )
+ name = fetch_scalar(conn, "SELECT name FROM schema_version WHERE version = ?", (1,))
assert name == "create_t1"
diff --git a/tests/test_openai_compat.py b/tests/test_openai_compat.py
index cefab6b..f35338e 100644
--- a/tests/test_openai_compat.py
+++ b/tests/test_openai_compat.py
@@ -10,12 +10,12 @@
from __future__ import annotations
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock
import pytest
from bmlib.llm.data_types import LLMMessage
-from bmlib.llm.providers.base import ModelMetadata, ModelPricing, ProviderCapabilities
+from bmlib.llm.providers.base import ModelMetadata, ModelPricing
class _StubProvider:
@@ -97,6 +97,40 @@ def test_chat_routes_to_openai_sdk(self, StubProvider):
call_kwargs = mock_client.chat.completions.create.call_args
assert call_kwargs.kwargs["model"] == "stub-model"
+ def test_reasoning_model_uses_max_completion_tokens(self, StubProvider):
+ from bmlib.llm.providers.openai_compat import OpenAICompatibleProvider
+
+ class _Reasoning(_StubProvider, OpenAICompatibleProvider):
+ def _is_reasoning_model(self, model):
+ return True
+
+ p = _Reasoning(api_key="k")
+ mock_response = MagicMock()
+ mock_response.choices = [MagicMock()]
+ mock_response.choices[0].message.content = "ok"
+ mock_response.choices[0].message.tool_calls = None
+ mock_response.usage.prompt_tokens = 1
+ mock_response.usage.completion_tokens = 1
+ mock_response.choices[0].finish_reason = "stop"
+ mock_client = MagicMock()
+ mock_client.chat.completions.create.return_value = mock_response
+ p._client = mock_client
+
+ p.chat([LLMMessage(role="user", content="hi")], temperature=0.7, max_tokens=99)
+
+ kwargs = mock_client.chat.completions.create.call_args.kwargs
+ assert kwargs["max_completion_tokens"] == 99
+ assert "max_tokens" not in kwargs
+ assert "temperature" not in kwargs
+
+ def test_openai_provider_detects_reasoning_models(self):
+ from bmlib.llm.providers.openai_provider import OpenAIProvider
+
+ p = OpenAIProvider(api_key="k")
+ assert p._is_reasoning_model("o1") is True
+ assert p._is_reasoning_model("o3-mini") is True
+ assert p._is_reasoning_model("gpt-4o") is False
+
def test_chat_separates_system_message(self, StubProvider):
p = StubProvider(api_key="test-key")
diff --git a/tests/test_publications.py b/tests/test_publications.py
index f2f312d..94a4908 100644
--- a/tests/test_publications.py
+++ b/tests/test_publications.py
@@ -28,6 +28,7 @@
from bmlib.publications.fetchers.biorxiv import PAGE_SIZE, _normalize, fetch_biorxiv
from bmlib.publications.models import (
DownloadDay,
+ FetchedRecord,
FetchResult,
FullTextSource,
Publication,
@@ -41,7 +42,6 @@
get_publication_by_pmid,
store_publication,
)
-from bmlib.publications.models import FetchedRecord
from bmlib.publications.sync import _record_to_fulltext_sources
# ---------------------------------------------------------------------------
@@ -628,6 +628,14 @@ def test_normalize_builds_fulltext_sources(self):
assert xml.format == "xml"
assert "source.xml" in xml.url
+ def test_normalize_uses_record_version_for_pdf_url(self):
+ # A revised preprint (v2) must produce a v2 PDF URL, not a stale v1.
+ raw = _sample_record(doi="10.1101/2024.01.01.000001")
+ raw["version"] = 2
+ result = _normalize(raw, "biorxiv")
+ pdf = result.fulltext_sources[0]
+ assert pdf.url == ("https://www.biorxiv.org/content/10.1101/2024.01.01.000001v2.full.pdf")
+
def test_normalize_sets_source(self):
raw = _sample_record()
result = _normalize(raw, "medrxiv")
diff --git a/tests/test_pubmed_fetcher.py b/tests/test_pubmed_fetcher.py
index e7bcc9d..1ac80d3 100644
--- a/tests/test_pubmed_fetcher.py
+++ b/tests/test_pubmed_fetcher.py
@@ -210,6 +210,33 @@ def test_numeric_month(self):
assert isinstance(result, FetchedRecord)
assert result.publication_date == "2024-03-05"
+ def test_season_month_falls_back_to_year(self):
+ """A non-numeric, non-month value (e.g. a season) must not produce an
+ invalid date like '2024-Winter' — fall back to year only."""
+ xml = """\
+
+
+ 22222222
+
+ Season month test
+
+ Test Journal
+
+
+ 2024
+ Winter
+
+
+
+
+
+
+
+ """
+ el = ET.fromstring(xml)
+ result = _parse_article_xml(el)
+ assert result.publication_date == "2024"
+
def test_medline_date_fallback(self):
"""When Year is missing, MedlineDate is used as fallback (first 4 chars)."""
xml = """\
diff --git a/tests/test_quality.py b/tests/test_quality.py
index f3ebc17..f0f4f58 100644
--- a/tests/test_quality.py
+++ b/tests/test_quality.py
@@ -44,8 +44,13 @@ def test_ordering(self):
class TestBiasRisk:
def test_roundtrip(self):
- br = BiasRisk(selection="low", performance="high", detection="unclear",
- attrition="low", reporting="high")
+ br = BiasRisk(
+ selection="low",
+ performance="high",
+ detection="unclear",
+ attrition="low",
+ reporting="high",
+ )
d = br.to_dict()
br2 = BiasRisk.from_dict(d)
assert br2.selection == "low"
@@ -71,7 +76,9 @@ def test_from_metadata(self):
def test_from_classification(self):
a = QualityAssessment.from_classification(
- StudyDesign.COHORT_PROSPECTIVE, confidence=0.75, sample_size=500,
+ StudyDesign.COHORT_PROSPECTIVE,
+ confidence=0.75,
+ sample_size=500,
)
assert a.quality_tier == QualityTier.TIER_3_CONTROLLED
assert a.assessment_tier == 2
@@ -98,9 +105,13 @@ def test_serialisation_roundtrip(self):
quality_tier=QualityTier.TIER_4_EXPERIMENTAL,
quality_score=8.0,
confidence=0.85,
- bias_risk=BiasRisk(selection="low", performance="low",
- detection="unclear", attrition="low",
- reporting="low"),
+ bias_risk=BiasRisk(
+ selection="low",
+ performance="low",
+ detection="unclear",
+ attrition="low",
+ reporting="low",
+ ),
strengths=["Large sample"],
limitations=["Single center"],
)
@@ -144,13 +155,35 @@ def test_hyphenated_type(self):
def test_mixed_case_with_noise(self):
# EuropePMC categories include journal name as noise
- result = classify_from_metadata([
- "European Radiology", "Systematic Review", "Meta-Analysis",
- ])
+ result = classify_from_metadata(
+ [
+ "European Radiology",
+ "Systematic Review",
+ "Meta-Analysis",
+ ]
+ )
assert result.study_design == StudyDesign.SYSTEMATIC_REVIEW
def test_lowercase_from_categories(self):
- result = classify_from_metadata([
- "some journal", "randomized controlled trial",
- ])
+ result = classify_from_metadata(
+ [
+ "some journal",
+ "randomized controlled trial",
+ ]
+ )
assert result.study_design == StudyDesign.RCT
+
+ def test_cohort_outranks_case_control(self):
+ # A paper tagged with both should resolve to the stronger cohort design.
+ result = classify_from_metadata(["Case-Control Study", "Cohort Study"])
+ assert result.study_design == StudyDesign.COHORT_PROSPECTIVE
+
+ def test_multicenter_study_not_classified_as_rct(self):
+ # "Multicenter Study" is an organisational attribute, not a design.
+ result = classify_from_metadata(["Multicenter Study"])
+ assert result.quality_tier == QualityTier.UNCLASSIFIED
+
+ def test_comparative_study_not_classified(self):
+ # "Comparative Study" is a generic tag, not a specific design.
+ result = classify_from_metadata(["Comparative Study"])
+ assert result.quality_tier == QualityTier.UNCLASSIFIED
diff --git a/tests/test_sync.py b/tests/test_sync.py
index d57f5ab..30cebba 100644
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -21,6 +21,7 @@
from datetime import UTC, date, datetime, timedelta
from bmlib.db import connect_sqlite, execute
+from bmlib.fulltext.models import FullTextSourceEntry
from bmlib.publications.fetchers import ALL_SOURCES
from bmlib.publications.models import FetchedRecord, FetchResult
from bmlib.publications.schema import ensure_schema
@@ -90,7 +91,7 @@ def _sample_raw_record(doi="10.1234/test.001", title="Test Paper", source="pubme
license=None,
source=source,
fulltext_sources=[
- {"url": f"https://example.com/{doi}.pdf", "format": "pdf", "source": source},
+ FullTextSourceEntry(url=f"https://example.com/{doi}.pdf", format="pdf", source=source),
],
)
@@ -200,6 +201,19 @@ def test_sync_stores_records(self):
assert pub2 is not None
assert pub2.title == "Sync Paper 2"
+ # Verify the full-text source (a FullTextSourceEntry dataclass) was
+ # extracted and stored, not silently dropped.
+ from bmlib.db import fetch_all
+
+ fts_rows = fetch_all(
+ conn,
+ "SELECT * FROM fulltext_sources WHERE publication_id = ?",
+ (pub1.id,),
+ )
+ assert len(fts_rows) == 1
+ assert fts_rows[0]["url"] == "https://example.com/10.1234/sync.001.pdf"
+ assert fts_rows[0]["format"] == "pdf"
+
# Verify download_days was updated
from bmlib.db import fetch_one
diff --git a/tests/test_templates.py b/tests/test_templates.py
index 8af31ee..d978f76 100644
--- a/tests/test_templates.py
+++ b/tests/test_templates.py
@@ -77,9 +77,7 @@ def test_has_template(tmp_path):
def test_jinja_conditionals(tmp_path):
d = tmp_path / "d"
d.mkdir()
- (d / "cond.txt").write_text(
- "{% if include_methods %}Methods: {{ methods }}{% endif %}"
- )
+ (d / "cond.txt").write_text("{% if include_methods %}Methods: {{ methods }}{% endif %}")
engine = TemplateEngine(default_dir=d)
assert engine.render("cond.txt", include_methods=True, methods="RCT") == "Methods: RCT"
@@ -89,9 +87,7 @@ def test_jinja_conditionals(tmp_path):
def test_jinja_loops(tmp_path):
d = tmp_path / "d"
d.mkdir()
- (d / "loop.txt").write_text(
- "{% for item in items %}- {{ item }}\n{% endfor %}"
- )
+ (d / "loop.txt").write_text("{% for item in items %}- {{ item }}\n{% endfor %}")
engine = TemplateEngine(default_dir=d)
result = engine.render("loop.txt", items=["a", "b", "c"])
diff --git a/tests/test_transparency.py b/tests/test_transparency.py
index e9d2137..124056b 100644
--- a/tests/test_transparency.py
+++ b/tests/test_transparency.py
@@ -18,6 +18,7 @@
from __future__ import annotations
+from bmlib.transparency.analyzer import TransparencyAnalyzer
from bmlib.transparency.models import (
TransparencyResult,
TransparencyRisk,
@@ -26,56 +27,190 @@
)
+class _FakeResponse:
+ def __init__(self, status_code=200, json_data=None, text=""):
+ self.status_code = status_code
+ self._json = json_data or {}
+ self.text = text
+
+ def json(self):
+ return self._json
+
+
+class _FakeFullTextClient:
+ """A fake httpx client that serves a single full-text XML body."""
+
+ def __init__(self, full_text: str | None):
+ self._full_text = full_text
+
+ def get(self, url, **kwargs):
+ if url.endswith("/fullTextXML"):
+ if self._full_text is None:
+ return _FakeResponse(status_code=404, text="")
+ return _FakeResponse(status_code=200, text=self._full_text)
+ return _FakeResponse(status_code=404)
+
+
class TestTransparencyRisk:
def test_high_risk_low_score(self):
settings = TransparencySettings(score_threshold=40)
risk = calculate_risk_level(
- score=20, industry_funding=False, data_availability="full_open",
- coi_disclosed=True, settings=settings,
+ score=20,
+ industry_funding=False,
+ data_availability="full_open",
+ coi_disclosed=True,
+ settings=settings,
)
assert risk == TransparencyRisk.HIGH
def test_high_risk_industry_restricted(self):
settings = TransparencySettings(industry_funding_triggers_downgrade=True)
risk = calculate_risk_level(
- score=60, industry_funding=True, data_availability="restricted",
- coi_disclosed=True, settings=settings,
+ score=60,
+ industry_funding=True,
+ data_availability="restricted",
+ coi_disclosed=True,
+ settings=settings,
)
assert risk == TransparencyRisk.HIGH
def test_high_risk_missing_coi(self):
settings = TransparencySettings(missing_coi_triggers_downgrade=True)
risk = calculate_risk_level(
- score=80, industry_funding=False, data_availability="full_open",
- coi_disclosed=False, settings=settings,
+ score=80,
+ industry_funding=False,
+ data_availability="full_open",
+ coi_disclosed=False,
+ settings=settings,
)
assert risk == TransparencyRisk.HIGH
+ def test_unknown_coi_does_not_trigger_downgrade(self):
+ """coi_disclosed=None (undeterminable) must NOT force HIGH risk."""
+ settings = TransparencySettings(missing_coi_triggers_downgrade=True)
+ risk = calculate_risk_level(
+ score=80,
+ industry_funding=False,
+ data_availability="full_open",
+ coi_disclosed=None,
+ settings=settings,
+ )
+ assert risk == TransparencyRisk.LOW
+
def test_medium_risk_borderline(self):
settings = TransparencySettings()
risk = calculate_risk_level(
- score=60, industry_funding=False, data_availability="full_open",
- coi_disclosed=True, settings=settings,
+ score=60,
+ industry_funding=False,
+ data_availability="full_open",
+ coi_disclosed=True,
+ settings=settings,
)
assert risk == TransparencyRisk.MEDIUM
def test_medium_risk_industry(self):
settings = TransparencySettings()
risk = calculate_risk_level(
- score=80, industry_funding=True, data_availability="full_open",
- coi_disclosed=True, settings=settings,
+ score=80,
+ industry_funding=True,
+ data_availability="full_open",
+ coi_disclosed=True,
+ settings=settings,
)
assert risk == TransparencyRisk.MEDIUM
def test_low_risk(self):
settings = TransparencySettings()
risk = calculate_risk_level(
- score=85, industry_funding=False, data_availability="full_open",
- coi_disclosed=True, settings=settings,
+ score=85,
+ industry_funding=False,
+ data_availability="full_open",
+ coi_disclosed=True,
+ settings=settings,
)
assert risk == TransparencyRisk.LOW
+class TestCheckEuropePMC:
+ """Tests that COI/data-availability are read from full text, not abstract."""
+
+ def _epmc(self, in_epmc="Y", abstract=""):
+ return {
+ "resultList": {
+ "result": [
+ {
+ "abstractText": abstract,
+ "inEPMC": in_epmc,
+ "source": "PMC",
+ "pmcid": "PMC123",
+ }
+ ]
+ }
+ }
+
+ def test_coi_detected_in_full_text(self):
+ analyzer = TransparencyAnalyzer()
+ client = _FakeFullTextClient(
+ "The authors declare no conflict of interest."
+ )
+ coi, _level, score, _ind, ft = analyzer._check_europepmc(
+ client, self._epmc(), score=0, indicators=[]
+ )
+ assert coi is True
+ assert ft is True
+ assert score == 10 # SCORE_COI_DISCLOSED
+
+ def test_coi_absent_in_full_text(self):
+ analyzer = TransparencyAnalyzer()
+ client = _FakeFullTextClient("No disclosure section here.")
+ coi, _level, _score, _ind, ft = analyzer._check_europepmc(
+ client, self._epmc(), score=0, indicators=[]
+ )
+ assert coi is False # full text scanned, explicitly absent
+ assert ft is True
+
+ def test_coi_unknown_when_no_full_text(self):
+ analyzer = TransparencyAnalyzer()
+ # inEPMC == "N" so no full text is fetched, abstract has no COI signal.
+ client = _FakeFullTextClient(None)
+ coi, _level, _score, _ind, ft = analyzer._check_europepmc(
+ client, self._epmc(in_epmc="N"), score=0, indicators=[]
+ )
+ assert coi is None # undeterminable, not "absent"
+ assert ft is False
+
+
+class TestCheckTrialResults:
+ """Tests that posted-results detection reads the correct v2 API field."""
+
+ def test_has_results_true(self):
+ analyzer = TransparencyAnalyzer()
+
+ class _Client:
+ def get(self, url, **kwargs):
+ return _FakeResponse(status_code=200, json_data={"hasResults": True})
+
+ assert analyzer._check_trial_results(_Client(), "NCT12345678") is True
+
+ def test_has_results_false(self):
+ analyzer = TransparencyAnalyzer()
+
+ class _Client:
+ def get(self, url, **kwargs):
+ return _FakeResponse(status_code=200, json_data={"hasResults": False})
+
+ assert analyzer._check_trial_results(_Client(), "NCT12345678") is False
+
+ def test_results_section_fallback(self):
+ analyzer = TransparencyAnalyzer()
+
+ class _Client:
+ def get(self, url, **kwargs):
+ return _FakeResponse(status_code=200, json_data={"resultsSection": {"x": 1}})
+
+ assert analyzer._check_trial_results(_Client(), "NCT12345678") is True
+
+
class TestTransparencyResult:
def test_roundtrip(self):
result = TransparencyResult(
|