263 lines
12 KiB
Python
263 lines
12 KiB
Python
"""Tests for the deterministic parts of bingham (no network, no models)."""
|
|
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
from art import store
|
|
from art.feeds import fetch
|
|
from art.profile import profiles as prof
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
|
|
|
|
def test_arxiv_rss_parse():
|
|
data = (FIXTURES / "arxiv_rss_cond-mat.soft_2024-05-02.xml").read_bytes()
|
|
with mock.patch.object(fetch, "http_get", return_value=data):
|
|
items = fetch.fetch_arxiv_rss("cond-mat.soft")
|
|
assert len(items) == 12
|
|
first = items[0]
|
|
assert first["id"] == "2405.00487" and first["version"] == "v1"
|
|
assert first["doi"] == "10.48550/arXiv.2405.00487"
|
|
assert first["authors"] == ["Henry A. Lockwood", "Suzanne M. Fielding"]
|
|
assert first["date"] == "2024-05-02"
|
|
assert first["abstract"].startswith("Recoverable strain")
|
|
assert "Announce Type" not in first["abstract"]
|
|
assert {it["announce_type"] for it in items} == {"new", "cross", "replace", "replace-cross"}
|
|
# LaTeX is kept as written
|
|
assert any("$S(q)$" in it["abstract"] for it in items)
|
|
|
|
|
|
@pytest.fixture
|
|
def conn(tmp_path, monkeypatch):
|
|
monkeypatch.setattr(store.env, "path", lambda key: tmp_path / "art.sqlite")
|
|
return store.connect()
|
|
|
|
|
|
def _item(**kw):
|
|
base = {"id": "10.1122/x", "doi": "10.1122/x", "title": "T", "authors": [],
|
|
"date": "2026-09-22", "abstract": "", "source": "J. Rheol.", "categories": []}
|
|
return {**base, **kw}
|
|
|
|
|
|
def test_upsert_fills_only_empty_fields(conn):
|
|
assert store.upsert_items(conn, [_item(abstract="From the feed.")], "daily") == 1
|
|
assert store.upsert_items(conn, [_item(abstract="From Crossref.", authors=["A. B"])], "daily") == 0
|
|
row = store.item_dict(conn.execute("SELECT * FROM items").fetchone())
|
|
assert row["abstract"] == "From the feed."
|
|
assert row["authors"] == ["A. B"]
|
|
|
|
|
|
def test_cross_list_then_new_is_stored_once_as_new(conn):
|
|
store.upsert_items(conn, [_item(id="2609.1", announce_type="cross")], "daily")
|
|
store.upsert_items(conn, [_item(id="2609.1", announce_type="new")], "daily")
|
|
rows = conn.execute("SELECT announce_type FROM items").fetchall()
|
|
assert [r[0] for r in rows] == ["new"]
|
|
|
|
|
|
def test_tracked_author_match_is_full_name():
|
|
tracked = [prof.TrackedAuthor(name="Priya Raman", match=["Priya Raman"]),
|
|
prof.TrackedAuthor(name="Samuel T. Okafor", match=["Samuel T. Okafor"])]
|
|
assert prof.tracked_matches(["samuel t. okafor"], tracked) == ["Samuel T. Okafor"]
|
|
assert prof.tracked_matches(["P. Raman", "Anil Raman"], tracked) == []
|
|
|
|
|
|
def test_profiles_file_loads_and_versions_are_stable():
|
|
data = prof.load()
|
|
assert len(data.profiles) >= 1
|
|
assert data.profiles[0].version == prof.load().profiles[0].version
|
|
|
|
|
|
def test_legacy_entry_is_screened_once_filled(conn):
|
|
from art.screen import screen
|
|
store.upsert_items(conn, [_item(abstract="")], "legacy")
|
|
assert screen.load_items(conn) == []
|
|
store.upsert_items(conn, [_item(abstract="From Crossref.", authors=["A. B"])], "backfill")
|
|
assert [it["id"] for it in screen.load_items(conn)] == ["10.1122/x"]
|
|
|
|
|
|
def _arxiv(id_, day, cats=("cond-mat.soft",)):
|
|
return fetch._arxiv_item(id_, "v1", f"Paper {id_}", ["A. B"], day, "Abstract.",
|
|
list(cats), "new")
|
|
|
|
|
|
def test_weekdays_between():
|
|
from datetime import date
|
|
from art.feeds.collect import _weekdays_between
|
|
fri, mon, thu = date(2026, 9, 25), date(2026, 9, 28), date(2026, 10, 1)
|
|
assert _weekdays_between(fri, mon) == []
|
|
assert _weekdays_between(mon, thu) == [date(2026, 9, 29), date(2026, 9, 30)]
|
|
|
|
|
|
def test_daily_fetch_catches_up_skipped_days(conn, monkeypatch):
|
|
from art.feeds import collect
|
|
monkeypatch.setattr(collect.cache, "load_raw", lambda name: ([], None))
|
|
monkeypatch.setattr(collect.cache, "save_raw", lambda name, items: None)
|
|
cfg = {"categories": ["cond-mat.soft"], "include_cross_lists": True,
|
|
"request_delay_s": 0, "max_catchup_days": 14}
|
|
monkeypatch.setattr(collect.env, "config", lambda: {"arxiv": cfg})
|
|
oai_calls = []
|
|
|
|
def fake_oai(oai_set, since, **kw):
|
|
oai_calls.append((oai_set, since))
|
|
return [_arxiv("2609.2", "2026-09-29"), _arxiv("2609.3", "2026-09-30", ("physics.flu-dyn", "cond-mat.soft"))]
|
|
|
|
monkeypatch.setattr(collect.fetch, "fetch_arxiv_oai", fake_oai)
|
|
|
|
# Monday: first daily fetch records coverage and does not catch up.
|
|
monkeypatch.setattr(collect.fetch, "fetch_arxiv_rss", lambda cat: [_arxiv("2609.1", "2026-09-28")])
|
|
collect._fetch_arxiv(conn, None, 0, {"fetched": 0, "new": 0})
|
|
assert oai_calls == []
|
|
|
|
# Thursday: Tuesday and Wednesday were skipped.
|
|
monkeypatch.setattr(collect.fetch, "fetch_arxiv_rss", lambda cat: [_arxiv("2610.1", "2026-10-01")])
|
|
collect._fetch_arxiv(conn, None, 0, {"fetched": 0, "new": 0})
|
|
assert oai_calls == [("physics:cond-mat", "2026-09-29")]
|
|
rows = {r["id"]: (r["origin"], r["announce_type"]) for r in conn.execute("SELECT * FROM items")}
|
|
assert rows["2609.2"] == ("daily", "new")
|
|
assert rows["2609.3"] == ("daily", "cross")
|
|
assert store.last_fetch(conn, "arxiv:catchup") is not None
|
|
covered = conn.execute("SELECT MAX(detail) FROM fetches WHERE source = 'arxiv:covered'").fetchone()[0]
|
|
assert covered == "2026-10-01"
|
|
|
|
# Thursday again: nothing missing.
|
|
collect._fetch_arxiv(conn, None, 0, {"fetched": 0, "new": 0})
|
|
assert len(oai_calls) == 1
|
|
|
|
|
|
def test_failed_rss_feed_does_not_advance_coverage(conn, monkeypatch):
|
|
from art.feeds import collect
|
|
monkeypatch.setattr(collect.cache, "load_raw", lambda name: ([], None))
|
|
monkeypatch.setattr(collect.cache, "save_raw", lambda name, items: None)
|
|
cfg = {"categories": ["cond-mat.soft", "cs.CL"], "include_cross_lists": True,
|
|
"request_delay_s": 0, "max_catchup_days": 14}
|
|
monkeypatch.setattr(collect.env, "config", lambda: {"arxiv": cfg})
|
|
store.log_fetch(conn, "arxiv:covered", 0, 0, True, "2026-09-28")
|
|
|
|
def rss(cat):
|
|
if cat == "cs.CL":
|
|
raise RuntimeError("HTTP 503")
|
|
return [_arxiv("2609.9", "2026-09-29")]
|
|
|
|
monkeypatch.setattr(collect.fetch, "fetch_arxiv_rss", rss)
|
|
collect._fetch_arxiv(conn, None, 0, {"fetched": 0, "new": 0})
|
|
covered = conn.execute("SELECT MAX(detail) FROM fetches WHERE source = 'arxiv:covered'").fetchone()[0]
|
|
assert covered == "2026-09-28"
|
|
|
|
|
|
def test_cluster_average_linkage():
|
|
import numpy as np
|
|
from art.cluster import cluster
|
|
S = np.array([[1.0, 0.9, 0.85, 0.1],
|
|
[0.9, 1.0, 0.82, 0.2],
|
|
[0.85, 0.82, 1.0, 0.1],
|
|
[0.1, 0.2, 0.1, 1.0]])
|
|
assert sorted(cluster(S, 0.8)) == [[0, 1, 2], [3]]
|
|
assert sorted(cluster(S, 0.95)) == [[0], [1], [2], [3]]
|
|
|
|
|
|
def test_user_config_is_merged_over_base(tmp_path, monkeypatch):
|
|
from art import env
|
|
(tmp_path / "config.yaml").write_text(
|
|
"contact: {email: a@b.org}\narxiv: {categories: [cs.CL]}\nscreen: {thresholds: {x: 0.5}}\n")
|
|
monkeypatch.setattr(env, "USER_DIR", tmp_path)
|
|
env.config.cache_clear()
|
|
try:
|
|
cfg = env.config()
|
|
assert env.contact_email() == "a@b.org"
|
|
assert cfg["arxiv"]["categories"] == ["cs.CL"] # lists replace
|
|
assert cfg["arxiv"]["request_delay_s"] == 3 # other keys kept
|
|
assert cfg["screen"]["thresholds"] == {"default": 0.2, "x": 0.5} # dicts merge
|
|
finally:
|
|
env.config.cache_clear()
|
|
|
|
|
|
def test_data_file_prefers_user_then_data_then_example(tmp_path, monkeypatch):
|
|
from art import env
|
|
monkeypatch.setattr(env, "USER_DIR", tmp_path)
|
|
assert env.data_file("profiles.yaml").parent.name == "examples"
|
|
assert env.data_file("feeds.yaml").parent.name == "data"
|
|
(tmp_path / "profiles.yaml").write_text("profiles: []\n")
|
|
assert env.data_file("profiles.yaml") == tmp_path / "profiles.yaml"
|
|
|
|
|
|
def test_cluster_stops_at_n_clusters():
|
|
import numpy as np
|
|
from art.cluster import cluster, medoids
|
|
S = np.array([[1.0, 0.9, 0.3, 0.2],
|
|
[0.9, 1.0, 0.25, 0.2],
|
|
[0.3, 0.25, 1.0, 0.7],
|
|
[0.2, 0.2, 0.7, 1.0]])
|
|
assert cluster(S, n_clusters=2) == [[0, 1], [2, 3]]
|
|
assert medoids(S, [0, 1, 2], k=1) == [0]
|
|
|
|
|
|
# -- profile toolbox ----------------------------------------------------------
|
|
|
|
def test_ref_from_text():
|
|
from art.toolbox.seeds import ref_from_text
|
|
assert ref_from_text("https://doi.org/10.1122/8.0001213") == "doi:10.1122/8.0001213"
|
|
assert ref_from_text("10.1039/D3SM00479A.") == "doi:10.1039/d3sm00479a"
|
|
assert ref_from_text("arXiv:2603.04657v2") == "arxiv:2603.04657"
|
|
assert ref_from_text("https://arxiv.org/abs/2609.21066") == "arxiv:2609.21066"
|
|
assert ref_from_text("10.48550/arXiv.2609.21066") == "arxiv:2609.21066"
|
|
assert ref_from_text("2609.21066") == "arxiv:2609.21066"
|
|
assert ref_from_text("no identifier here") is None
|
|
|
|
|
|
def test_orcid_works_use_self_identifiers_only():
|
|
import json
|
|
from art.toolbox.seeds import parse_orcid_works
|
|
works = json.loads((FIXTURES / "orcid_works_0000-0002-1825-0097.json").read_text())
|
|
seeds = parse_orcid_works(works)
|
|
assert len(seeds) == 6 and all(s["ref"].startswith("doi:10.") for s in seeds)
|
|
assert not any("0264-3561" in s["ref"] for s in seeds) # ISSNs are part-of, not self
|
|
assert any(s["title"] == "The Memory Bus Considered Harmful" and s["year"] == 2012 for s in seeds)
|
|
|
|
|
|
def test_id_list_bibtex_and_csl_readers():
|
|
from art.toolbox.seeds import from_bibtex, from_csl_json, from_id_list
|
|
ids = from_id_list("# my papers\n10.1122/8.0001213\n\narXiv:2609.21066 # rolling friction\n10.1122/8.0001213\n")
|
|
assert [s["ref"] for s in ids] == ["doi:10.1122/8.0001213", "arxiv:2609.21066"]
|
|
bib = from_bibtex("""@article{a,
|
|
title = {Rheology of {Non-Brownian} Suspensions},
|
|
author = {Denn, Morton M. and Morris, J. F.},
|
|
year = {2014}, doi = {10.1146/annurev-chembioeng-060713-040221},
|
|
abstract = {A review.}
|
|
}
|
|
@misc{b, title = "A preprint", eprint = {2609.21066}, archivePrefix = {arXiv}, author = {Q. Doe}}
|
|
@book{c, title = {No identifier}}
|
|
""")
|
|
assert [s["ref"] for s in bib] == ["doi:10.1146/annurev-chembioeng-060713-040221", "arxiv:2609.21066"]
|
|
assert bib[0]["title"] == "Rheology of Non-Brownian Suspensions"
|
|
assert bib[0]["authors"] == ["Morton M. Denn", "J. F. Morris"] and bib[0]["year"] == 2014
|
|
csl = from_csl_json('[{"DOI": "10.1039/D3SM00479A", "title": "Sticky friction", "abstract": "We show.",'
|
|
' "author": [{"given": "Hojin", "family": "Kim"}], "issued": {"date-parts": [[2023]]}},'
|
|
' {"URL": "http://arxiv.org/abs/2603.04657", "title": "Stan"}, {"title": "none"}]')
|
|
assert [s["ref"] for s in csl] == ["doi:10.1039/d3sm00479a", "arxiv:2603.04657"]
|
|
assert csl[0]["authors"] == ["Hojin Kim"] and csl[0]["year"] == 2023
|
|
|
|
|
|
def test_abbreviations_and_coauthors():
|
|
from art.toolbox.draft import abbreviations, coauthor_suggestions
|
|
text = "We use large-amplitude oscillatory shear (LAOS) and small amplitude oscillatory shear (SAOS)."
|
|
assert abbreviations(text) == {"LAOS": "large-amplitude oscillatory shear",
|
|
"SAOS": "small amplitude oscillatory shear"}
|
|
seeds = [{"authors": ["Ada Owner", "Bo Co"]}, {"authors": ["Ada Owner", "Bo Co", "Cy Once"]},
|
|
{"authors": ["Ada Owner"]}]
|
|
assert coauthor_suggestions(seeds, None) == [("Bo Co", 2)] # owner dropped automatically
|
|
assert coauthor_suggestions(seeds, "Ada Owner") == [("Bo Co", 2)]
|
|
|
|
|
|
def test_draft_yaml_loads_as_profiles():
|
|
from art.toolbox.draft import profiles_yaml
|
|
d = {"n_used": 4, "sources": ["ids"], "llm": None, "tracked": [("Bo Co", 2)],
|
|
"profiles": [{"id": "dense-suspensions", "name": "Dense suspensions", "n_papers": 4,
|
|
"description": "Friction and lubrication in dense suspensions: " * 5,
|
|
"keywords": ["DST", "shear thickening", "friction"], "drafted_by": "placeholder",
|
|
"exemplars": [("doi:10.1039/d3sm00479a", "Sticky friction", 2023),
|
|
("arxiv:2609.21066", "Rolling friction", None)]}]}
|
|
data = prof.Profiles.model_validate(__import__("yaml").safe_load(profiles_yaml(d)))
|
|
assert data.profiles[0].exemplars[1].arxiv == "2609.21066"
|
|
assert data.tracked_authors == [] # suggestions stay commented out
|