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