|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Regression tests for commands shown in the JOSS paper. |
| 4 | +
|
| 5 | +These tests exist to ensure that the API demonstrated in the paper |
| 6 | +continues to work as documented. |
| 7 | +""" |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +import json |
| 11 | + |
| 12 | +import pytest |
| 13 | + |
| 14 | +# flake8: noqa: E501 |
| 15 | + |
| 16 | +# --- helpers --------------------------------------------------------------- |
| 17 | + |
| 18 | + |
| 19 | +def _assert_parse_raises_with_codes( |
| 20 | + *, query: str, platform: str, codes: list[str] |
| 21 | +) -> None: |
| 22 | + """ |
| 23 | + Helper: assert parse() fails and reports expected error code(s) via |
| 24 | + excinfo.value.linter.messages (as used in the paper examples). |
| 25 | + """ |
| 26 | + from search_query.parser import parse |
| 27 | + |
| 28 | + with pytest.raises(Exception) as excinfo: |
| 29 | + parse(query, platform=platform) |
| 30 | + |
| 31 | + raised_codes = [m["code"] for m in excinfo.value.linter.messages] |
| 32 | + |
| 33 | + missing = [c for c in codes if c not in raised_codes] |
| 34 | + assert not missing, ( |
| 35 | + f"Expected code(s) {missing} not found.\n" |
| 36 | + f"Raised codes: {raised_codes}\n" |
| 37 | + f"Exception: {excinfo.value!r}" |
| 38 | + ) |
| 39 | + |
| 40 | + |
| 41 | +def _assert_parse_warns_with_codes_stdout( |
| 42 | + *, query: str, platform: str, codes: list[str], capsys: pytest.CaptureFixture[str] |
| 43 | +) -> None: |
| 44 | + from search_query.parser import parse |
| 45 | + |
| 46 | + result = parse(query, platform=platform) |
| 47 | + out = capsys.readouterr().out |
| 48 | + |
| 49 | + for code in codes: |
| 50 | + assert code in out, f"Expected {code} in stdout:\n{out}" |
| 51 | + |
| 52 | + assert result is not None |
| 53 | + assert hasattr(result, "to_string") |
| 54 | + |
| 55 | + |
| 56 | +# --- load options -------------------------------------------------------- |
| 57 | + |
| 58 | + |
| 59 | +def test_paper_option_1_parse_query_file(tmp_path: pytest.TempPathFactory) -> None: |
| 60 | + from search_query.search_file import load_search_file |
| 61 | + from search_query.parser import parse |
| 62 | + |
| 63 | + # create a minimal search-file.json (paper-style) |
| 64 | + payload = { |
| 65 | + "search_string": "digital AND work", |
| 66 | + "platform": "wos", |
| 67 | + # TODO: version is not an explicit parameter of SearchFile |
| 68 | + "version": "1", |
| 69 | + "authors": [{"name": "Gerit Wagner"}], |
| 70 | + "record_info": {}, |
| 71 | + "date": {}, |
| 72 | + } |
| 73 | + p = tmp_path / "search-file.json" |
| 74 | + p.write_text(json.dumps(payload, indent=2), encoding="utf-8") |
| 75 | + |
| 76 | + search_file = load_search_file(str(p)) |
| 77 | + wos_query = parse(search_file.search_string, platform=search_file.platform) |
| 78 | + |
| 79 | + assert wos_query is not None |
| 80 | + assert wos_query.to_string() == "digital AND work" |
| 81 | + |
| 82 | + |
| 83 | +def test_paper_option_2_parse_query_string() -> None: |
| 84 | + from search_query.parser import parse |
| 85 | + |
| 86 | + wos_query = parse("digital AND work", platform="wos") |
| 87 | + assert wos_query is not None |
| 88 | + assert wos_query.to_string() == "digital AND work" |
| 89 | + |
| 90 | + |
| 91 | +def test_paper_option_3_construct_programmatically() -> None: |
| 92 | + from search_query import OrQuery, AndQuery |
| 93 | + |
| 94 | + digital_synonyms = OrQuery(["digital", "virtual", "online"]) |
| 95 | + work_synonyms = OrQuery(["work", "labor", "service"]) |
| 96 | + query = AndQuery([digital_synonyms, work_synonyms], field="title") |
| 97 | + |
| 98 | + assert query is not None |
| 99 | + assert hasattr(query, "to_string") |
| 100 | + |
| 101 | + |
| 102 | +def test_paper_option_4_load_query_from_database() -> None: |
| 103 | + from search_query.database import load_query |
| 104 | + |
| 105 | + ft50 = load_query("journals_FT50") |
| 106 | + assert ( |
| 107 | + ft50.to_string() |
| 108 | + == 'SO=("Academy of Management Journal" OR "Academy of Management Review" OR "Accounting, Organizations and Society" OR "Administrative Science Quarterly" OR "American Economic Review" OR "Contemporary Accounting Research" OR "Econometrica" OR "Entrepreneurship Theory and Practice" OR "Harvard Business Review" OR "Human Relations" OR "Human Resource Management" OR "Information Systems Research" OR "Journal of Accounting and Economics" OR "Journal of Accounting Research" OR "Journal of Applied Psychology" OR "Journal of Business Ethics" OR "Journal of Business Venturing" OR "Journal of Consumer Psychology" OR "Journal of Consumer Research" OR "Journal of Finance" OR "Journal of Financial and Quantitative Analysis" OR "Journal of Financial Economics" OR "Journal of International Business Studies" OR "Journal of Management" OR "Journal of Management Information Systems" OR "Journal of Management Studies" OR "Journal of Marketing" OR "Journal of Marketing Research" OR "Journal of Operations Management" OR "Journal of Political Economy" OR "Journal of the Academy of Marketing Science" OR "Management Science" OR "Manufacturing and Service Operations Management" OR "Marketing Science" OR "MIS Quarterly" OR "Operations Research" OR "Organization Science" OR "Organization Studies" OR "Organizational Behavior and Human Decision Processes" OR "Production and Operations Management" OR "Quarterly Journal of Economics" OR "Research Policy" OR "Review of Accounting Studies" OR "Review of Economic Studies" OR "Review of Finance" OR "Review of Financial Studies" OR "Sloan Management Review" OR "Strategic Entrepreneurship Journal" OR "Strategic Management Journal" OR "The Accounting Review") OR IS=(0001-4273 OR 0363-7425 OR 0361-3682 OR 0001-8392 OR 0002-8282 OR 0823-9150 OR 0012-9682 OR 1042-2587 OR 0017-8012 OR 0018-7267 OR 0090-4848 OR 1047-7047 OR 0165-4101 OR 0021-8456 OR 0021-9010 OR 0167-4544 OR 0883-9026 OR 1057-7408 OR 0093-5301 OR 0022-1082 OR 0022-1090 OR 0304-405X OR 0047-2506 OR 0149-2063 OR 0742-1222 OR 0022-2429 OR 0022-2437 OR 0272-6963 OR 0022-3808 OR 0092-0703 OR 0025-1909 OR 1523-4614 OR 0732-2399 OR 0276-7783 OR 0030-364X OR 1047-7039 OR 0170-8406 OR 0749-5978 OR 1059-1478 OR 0033-5533 OR 0048-7333 OR 1380-6653 OR 0034-6527 OR 1572-3097 OR 0893-9454 OR 0036-8075 OR 1932-4391 OR 0143-2095 OR 0001-4826)' |
| 109 | + ) |
| 110 | + assert ft50 is not None |
| 111 | + |
| 112 | + |
| 113 | +# --- save SearchFile with load roundtrip -------------------------------- |
| 114 | + |
| 115 | + |
| 116 | +def test_paper_searchfile_save_roundtrip(tmp_path: pytest.TempPathFactory) -> None: |
| 117 | + from search_query import SearchFile |
| 118 | + from search_query.parser import parse |
| 119 | + from search_query.search_file import load_search_file |
| 120 | + |
| 121 | + pubmed_query = parse( |
| 122 | + '("dHealth"[Title/Abstract]) AND ("privacy"[Title/Abstract])', |
| 123 | + platform="pubmed", |
| 124 | + ) |
| 125 | + |
| 126 | + # TODO: Should we offer (prefer) a constructor that takes a Query object? |
| 127 | + # e.g., SearchFile(query=pubmed_query, ...) |
| 128 | + # this could prevent mismatches related to platform and version. |
| 129 | + search_file = SearchFile( |
| 130 | + search_string=pubmed_query.to_string(), |
| 131 | + platform="pubmed", |
| 132 | + # TODO: version is not an explicit parameter of SearchFile |
| 133 | + version="1", |
| 134 | + authors=[{"name": "Gerit Wagner"}], |
| 135 | + record_info={}, |
| 136 | + date={}, |
| 137 | + ) |
| 138 | + |
| 139 | + p = tmp_path / "search-file.json" |
| 140 | + search_file.save(str(p)) |
| 141 | + |
| 142 | + loaded = load_search_file(str(p)) |
| 143 | + assert loaded.platform == "pubmed" |
| 144 | + assert isinstance(loaded.search_string, str) |
| 145 | + assert loaded.search_string |
| 146 | + |
| 147 | + |
| 148 | +# --- linter messages ------------------------------------------ |
| 149 | + |
| 150 | + |
| 151 | +def test_paper_fatal_invalid_token_sequence_and_unbalanced_parentheses() -> None: |
| 152 | + # TODO: invalid-token-sequence evolution before JOSS publication? |
| 153 | + _assert_parse_raises_with_codes( |
| 154 | + query="((digital[ti] OR virtual[ti]) AND AND work[ti]", |
| 155 | + platform="pubmed", |
| 156 | + codes=["PARSE_0004", "PARSE_0002"], |
| 157 | + ) |
| 158 | + |
| 159 | + |
| 160 | +def test_paper_warns_operator_precedence_pubmed( |
| 161 | + capsys: pytest.CaptureFixture[str], |
| 162 | +) -> None: |
| 163 | + _assert_parse_warns_with_codes_stdout( |
| 164 | + query="crowdwork[ti] or digital[ti] and work[ti]", |
| 165 | + platform="pubmed", |
| 166 | + codes=["STRUCT_0001", "STRUCT_0002"], |
| 167 | + capsys=capsys, |
| 168 | + ) |
| 169 | + |
| 170 | + |
| 171 | +def test_paper_fatal_invalid_year_token() -> None: |
| 172 | + _assert_parse_raises_with_codes( |
| 173 | + query="”crowdwork”[ti] AND 20122[pdat]", |
| 174 | + platform="pubmed", |
| 175 | + codes=["TERM_0001", "TERM_0002"], |
| 176 | + ) |
| 177 | + |
| 178 | + |
| 179 | +def test_paper_fatal_unsupported_field_ab() -> None: |
| 180 | + _assert_parse_raises_with_codes( |
| 181 | + query="crowdwork[ab]", |
| 182 | + platform="pubmed", |
| 183 | + codes=["FIELD_0001"], |
| 184 | + ) |
| 185 | + |
| 186 | + |
| 187 | +def test_paper_warns_wildcards_and_phrase_pubmed( |
| 188 | + capsys: pytest.CaptureFixture[str], |
| 189 | +) -> None: |
| 190 | + _assert_parse_warns_with_codes_stdout( |
| 191 | + query='AI*[tiab] OR "industry 4.0"[tiab]', |
| 192 | + platform="pubmed", |
| 193 | + codes=["PUBMED_0003", "PUBMED_0002"], |
| 194 | + capsys=capsys, |
| 195 | + ) |
| 196 | + |
| 197 | + |
| 198 | +def test_paper_warns_unbalanced_quote_or_bracket_pubmed( |
| 199 | + capsys: pytest.CaptureFixture[str], |
| 200 | +) -> None: |
| 201 | + _assert_parse_warns_with_codes_stdout( |
| 202 | + query='(digital[ti] OR online[ti]) OR "digital work"[ti]', |
| 203 | + platform="pubmed", |
| 204 | + codes=["QUALITY_0004", "QUALITY_0005"], |
| 205 | + capsys=capsys, |
| 206 | + ) |
| 207 | + |
| 208 | + |
| 209 | +# --- lint_file ------------------------------------------------------ |
| 210 | + |
| 211 | + |
| 212 | +def test_paper_lint_file_returns_messages() -> None: |
| 213 | + from search_query.linter import lint_file |
| 214 | + from search_query import SearchFile |
| 215 | + |
| 216 | + search_file = SearchFile( |
| 217 | + search_string="digital AND work", |
| 218 | + platform="wos", |
| 219 | + version="1", |
| 220 | + authors=[{"name": "Gerit Wagner"}], |
| 221 | + record_info={}, |
| 222 | + date={}, |
| 223 | + ) |
| 224 | + messages = lint_file(search_file) |
| 225 | + assert messages is not None |
| 226 | + assert isinstance(messages, list) |
| 227 | + |
| 228 | + |
| 229 | +# --- translate pubmed -> wos --------------------------------------- |
| 230 | + |
| 231 | + |
| 232 | +def test_paper_translate_pubmed_to_wos_exact_string() -> None: |
| 233 | + from search_query.parser import parse |
| 234 | + |
| 235 | + query_string = '("dHealth"[Title/Abstract]) AND ("privacy"[Title/Abstract])' |
| 236 | + pubmed_query = parse(query_string, platform="pubmed") |
| 237 | + wos_query = pubmed_query.translate(target_syntax="wos") |
| 238 | + assert ( |
| 239 | + wos_query.to_string() |
| 240 | + == '(AB="dHealth" OR TI="dHealth") AND (AB="privacy" OR TI="privacy")' |
| 241 | + ) |
0 commit comments