diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 79f788c..44a461d 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -7,10 +7,10 @@ on: branches: [ main, dev ] workflow_dispatch: # Enables manual triggering schedule: - - cron: '0 0 2 * *' # Runs at 00:00 UTC on the 2nd day of every month + - cron: '0 0 * * 0' # Runs at 00:00 UTC every Sunday (weekly data-health audit) # One live run per branch. Every run drives the full correctness suite against -# the production VFB backend (Neo4j / SOLR / Owlery), so a series of quick +# the production data sources (VFB's Neo4j / SOLR / Owlery and FlyBase Chado), so a series of quick # pushes to a PR would otherwise stack several full suites against production # simultaneously. Superseded runs are cancelled — only the newest commit's # result is meaningful anyway. @@ -75,9 +75,19 @@ jobs: # on today's GitHub-hosted ubuntu runner, so this is not a slowdown — # it just stops the load on VFB infra changing silently if the hosted # runner spec grows. + # + # data_health: the '*_live' term_info variants assert on the external + # indexer's built vfb_json content, not this repo's render code, so + # an incomplete/stale production document fails them. They must NOT gate + # code PRs — the PR-blocking versions are fixture-based and deterministic. + # So they run ONLY on the schedule (a data-health audit); on push / + # pull_request they are deselected. See TESTING.md. + MARK=(-m "not data_health") + [ "${{ github.event_name }}" = "schedule" ] && MARK=() pytest -v -ra -n 4 --dist loadscope \ --ignore=src/test/test_query_performance.py \ --ignore=src/test/test_example_queries.py \ + "${MARK[@]}" \ src/test tests 2>&1 | tee pytest_output.log - name: Flag skipped tests (backend unavailable) if: always() @@ -105,7 +115,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" fi if [ -n "$summary" ]; then - echo "::warning title=Tests skipped — VFB backend unreachable::${summary}. ${digest:+First failure: ${digest}. }These are NOT test failures and not a problem with this branch: the VFB backend (Neo4j / SOLR / Owlery) did not answer, so those queries went unverified this run. Treat a green check with skips as an incomplete run — re-run once the backend is healthy before relying on it. Full detail (every skipped test with its reason; failing URLs and probe verdicts for backend failures): the job summary and the PR comment." + echo "::warning title=Tests skipped — upstream data source unreachable::${summary}. ${digest:+First failure: ${digest}. }These are NOT test failures and not a problem with this branch: an upstream data source (VFB's Neo4j / SOLR / Owlery, or FlyBase's Chado) did not answer, so those queries went unverified this run. Treat a green check with skips as an incomplete run — re-run once the data sources are healthy before relying on it. Full detail (every skipped test with its reason; failing URLs and probe verdicts for backend failures): the job summary and the PR comment." else echo "No tests skipped." fi @@ -151,7 +161,7 @@ jobs: // re-running it would do nothing. const runUrl = `${process.env.GITHUB_SERVER_URL}/${owner}/${repo}/actions/runs/${context.runId}`; const rerun = `To re-run: open [this workflow run](${runUrl}) and click ` + - `**Re-run all jobs** once the backend is healthy (re-running the ` + + `**Re-run all jobs** once the data sources are healthy (re-running the ` + `“Run completeness” check itself does nothing — it has no job behind it).`; // A neutral (grey) status check so the PR's checks box stops reading @@ -168,16 +178,16 @@ jobs: details_url: runUrl, output: { title: skipped > 0 - ? `${skipped} test(s) skipped — backend unreachable (incomplete run)` + ? `${skipped} test(s) skipped — upstream data source unreachable (incomplete run)` : 'All backend tests ran', summary: skipped > 0 - ? (`**${skipped}** test(s) were skipped because the VFB backend ` + - `(Neo4j / SOLR / Owlery) did not answer, so those queries went ` + + ? (`**${skipped}** test(s) were skipped because an upstream data source ` + + `(VFB's Neo4j / SOLR / Owlery, or FlyBase's Chado) did not answer, so those queries went ` + `unverified. This is not a branch failure — but the run is ` + `incomplete.\n\n${rerun}` + (summary ? '\n\n```\n' + summary + '\n```' : '') + (outage ? '\n\n' + outage : '')) - : 'Every backend-dependent test reached the VFB backend and ran.', + : 'Every backend-dependent test reached its data source and ran.', }, }); const comments = await github.paginate(github.rest.issues.listComments, @@ -187,10 +197,10 @@ jobs: if (skipped > 0) { const body = [ marker, - `### ⚠️ ${skipped} test(s) skipped — VFB backend was unreachable`, + `### ⚠️ ${skipped} test(s) skipped — an upstream data source was unreachable`, '', - `The full suite ran, but **${skipped}** test(s) were **skipped** because the ` + - `VFB backend (Neo4j / SOLR / Owlery) did not answer during this run.`, + `The full suite ran, but **${skipped}** test(s) were **skipped** because ` + + `an upstream data source (VFB's Neo4j / SOLR / Owlery, or FlyBase's Chado) did not answer during this run.`, '', 'These are **not failures** and **not a problem with this branch** — but those ' + 'queries went **unverified**, so a green check here is an *incomplete* run.', diff --git a/TESTING.md b/TESTING.md index 6e1af09..1f479ec 100644 --- a/TESTING.md +++ b/TESTING.md @@ -4,8 +4,7 @@ Most VFBquery tests run **live queries against the production VFB backend** (SOLR, Neo4j, Owlery, FlyBase Chado). That makes them powerful — they catch real regressions in query results — but also easy to write badly: a test that never checks its query returned anything passes forever while the query is silently -broken. A whole class of such tests was found and fixed in Aug 2026; this doc -exists so they don't come back. +broken. This doc exists so that class of test does not creep back in. Read this before adding or changing a test. @@ -69,6 +68,50 @@ So an empty result is **never** an acceptable outcome for a known-populated term — it is a bug. A backend outage is handled *for you*; you do not need (and must not add) your own try/except to survive it. +## Fixture vs live (`data_health`): what an empty result actually means + +The "empty ⇒ bug" rule assumes empty means **our** code is wrong. That holds +when a test reads the **source of truth (pdb / Neo4j) through this repo's own +query code** — connectivity, the neurotransmitter queries, and the like. There, +an empty result *is* a defect here, so the test asserts real content and is +**PR-blocking**. + +It does **not** hold for **term_info** tests. `get_term_info` / `process` / +`term_info_parse_object` *render* a pre-built SOLR `vfb_json` document; that +document is built by a **separate repository** (`VFB_json_schema_indexer`), not +here. If that built document is stale or incomplete (e.g. a pub with no title, a +split class missing its `Expression_pattern` type), our render code faithfully +produces an empty/partial result — which is a problem with the **build**, not +with this repo. Gating a code PR on it is a false signal. + +**The rule of thumb:** *if the build code is in this repo, test it here against +the source (empty = fail, PR-blocking); if the build is in another repo, use a +fixture here and test the build at its source.* Connectivity/NT queries build +from pdb here → tested here. term_info documents are built by +`VFB_json_schema_indexer` → **fixtures here, build tested there.** + +So term_info tests come in two variants: + +| Variant | Reads | Runs | A failure means | +|---|---|---|---| +| **fixture** (plain name) | a committed complete `vfb_json` doc in `src/test/fixtures/term_info/` | **every PR** (blocking) | this repo's **render code** regressed | +| **live** (`_live`, `@pytest.mark.data_health`) | the live SOLR `vfb_json` document | **schedule only** | the **built document / pipeline** (`VFB_json_schema_indexer`) is wrong — a data-health signal, not a code gate | + +Mechanics: + +* Fixtures are captured from the PDB (the source of truth) — i.e. what the + indexer *should* produce — and verified to render the asserted output. They + make the code assertion **deterministic and independent of the live SOLR + `vfb_json` document and the caches**, so code improvements are never blocked by + stale data. +* The live variant carries `@pytest.mark.data_health`. PRs run + `-m 'not data_health'`; the weekly `schedule` in `python-test.yml` runs the + full set. A red scheduled `data_health` run means production `vfb_json` (or the + indexer that builds it) needs attention — it does **not** block merges. +* Do **not** paper over an empty term_info doc with a skip guard — that violates + rule 2. Use the fixture for the code assertion; let the `_live` variant fail on + the schedule, where empty is exactly the signal you want. + ## The rules ### 1. Assert content, not just shape diff --git a/pyproject.toml b/pyproject.toml index 3b61cd5..f83ebb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ build-backend = "setuptools.build_meta" # blocks in performance.md — pure noise. markers = [ "integration: integration tests that exercise the live VFB upstream (Neo4j, SOLR, owlery). Skip via -m 'not integration'.", + "data_health: live SOLR content-completeness checks (the '*_live' variants of the term_info tests). These assert on the external indexer's built 'vfb_json' documents, not this repo's render code, so an empty/incomplete production document fails them. Run on the schedule; deselected on PRs via -m 'not data_health'. See TESTING.md.", ] # Ceiling on any single test. The upstream Neo4j intermittently stops answering diff --git a/src/test/fixtures/term_info/pub_FBrf0242477.json b/src/test/fixtures/term_info/pub_FBrf0242477.json new file mode 100644 index 0000000..10fb671 --- /dev/null +++ b/src/test/fixtures/term_info/pub_FBrf0242477.json @@ -0,0 +1,162 @@ +{ + "term": { + "core": { + "iri": "http://flybase.org/reports/FBrf0242477", + "symbol": "", + "types": [ + "Entity", + "Individual", + "pub" + ], + "short_form": "FBrf0242477", + "unique_facets": [ + "pub" + ], + "label": "Dolan et al., 2019, eLife 8: e43079" + }, + "description": [], + "comment": [] + }, + "query": "Get JSON for pub", + "version": "0dcd736", + "dataset_license": [ + { + "license": { + "core": { + "iri": "http://virtualflybrain.org/reports/VFBlicense_CC_BY_4_0", + "symbol": "CC_BY", + "types": [ + "Entity", + "Individual", + "License" + ], + "short_form": "VFBlicense_CC_BY_4_0", + "unique_facets": [], + "label": "CC-BY_4.0" + }, + "icon": "http://mirrors.creativecommons.org/presskit/buttons/88x31/png/by.png", + "link": "https://creativecommons.org/licenses/by/4.0/legalcode" + }, + "dataset": { + "core": { + "iri": "http://virtualflybrain.org/reports/LateralHorn2019", + "symbol": "", + "types": [ + "Entity", + "Individual", + "DataSet" + ], + "short_form": "LateralHorn2019", + "unique_facets": [], + "label": "FlyLight split-GAL4 lines for Lateral Horn" + }, + "link": "" + } + }, + { + "license": { + "core": { + "iri": "http://virtualflybrain.org/reports/VFBlicense_CC_BY_4_0", + "symbol": "CC_BY", + "types": [ + "Entity", + "Individual", + "License" + ], + "short_form": "VFBlicense_CC_BY_4_0", + "unique_facets": [], + "label": "CC-BY_4.0" + }, + "icon": "http://mirrors.creativecommons.org/presskit/buttons/88x31/png/by.png", + "link": "https://creativecommons.org/licenses/by/4.0/legalcode" + }, + "dataset": { + "core": { + "iri": "http://virtualflybrain.org/reports/Dolan2019", + "symbol": "", + "types": [ + "Entity", + "Individual", + "DataSet" + ], + "short_form": "Dolan2019", + "unique_facets": [], + "label": "GAL4 Split expression patterns from Dolan et al. 2019" + }, + "link": "" + } + }, + { + "license": { + "core": { + "iri": "http://virtualflybrain.org/reports/VFBlicense_CC_BY_4_0", + "symbol": "CC_BY", + "types": [ + "Entity", + "Individual", + "License" + ], + "short_form": "VFBlicense_CC_BY_4_0", + "unique_facets": [], + "label": "CC-BY_4.0" + }, + "icon": "http://mirrors.creativecommons.org/presskit/buttons/88x31/png/by.png", + "link": "https://creativecommons.org/licenses/by/4.0/legalcode" + }, + "dataset": { + "core": { + "iri": "http://virtualflybrain.org/reports/FlyLight2019LateralHorn2019", + "symbol": "", + "types": [ + "Entity", + "Individual", + "DataSet" + ], + "short_form": "FlyLight2019LateralHorn2019", + "unique_facets": [], + "label": "FlyLight split-GAL4 lines for Lateral Horn" + }, + "link": "" + } + }, + { + "license": { + "core": { + "iri": "http://virtualflybrain.org/reports/VFBlicense_CC_BY_4_0", + "symbol": "CC_BY", + "types": [ + "Entity", + "Individual", + "License" + ], + "short_form": "VFBlicense_CC_BY_4_0", + "unique_facets": [], + "label": "CC-BY_4.0" + }, + "icon": "http://mirrors.creativecommons.org/presskit/buttons/88x31/png/by.png", + "link": "https://creativecommons.org/licenses/by/4.0/legalcode" + }, + "dataset": { + "core": { + "iri": "http://virtualflybrain.org/reports/FafbDolan2019", + "symbol": "", + "types": [ + "Entity", + "Individual", + "DataSet" + ], + "short_form": "FafbDolan2019", + "unique_facets": [], + "label": "EM FAFB Dolan et al. 2019" + }, + "link": "https://fafb.catmaid.virtualflybrain.org" + } + } + ], + "pub_specific_content": { + "title": "Neurogenetic dissection of the Drosophila lateral horn reveals major outputs, diverse behavioural functions, and interactions with the mushroom body.", + "FlyBase": "FBrf0242477", + "PubMed": "31112130", + "DOI": "10.7554/eLife.43079" + } +} \ No newline at end of file diff --git a/src/test/fixtures/term_info/pub_FBrf0243986.json b/src/test/fixtures/term_info/pub_FBrf0243986.json new file mode 100644 index 0000000..0eee6d7 --- /dev/null +++ b/src/test/fixtures/term_info/pub_FBrf0243986.json @@ -0,0 +1,63 @@ +{ + "term": { + "core": { + "iri": "http://flybase.org/reports/FBrf0243986", + "symbol": "", + "types": [ + "Entity", + "Individual", + "pub" + ], + "short_form": "FBrf0243986", + "unique_facets": [ + "pub" + ], + "label": "Sayin et al., 2019, Neuron 104(3): 544--558.e6" + }, + "description": [], + "comment": [] + }, + "query": "Get JSON for pub", + "version": "0dcd736", + "dataset_license": [ + { + "license": { + "core": { + "iri": "http://virtualflybrain.org/reports/VFBlicense_CC_BY_4_0", + "symbol": "CC_BY", + "types": [ + "Entity", + "Individual", + "License" + ], + "short_form": "VFBlicense_CC_BY_4_0", + "unique_facets": [], + "label": "CC-BY_4.0" + }, + "icon": "http://mirrors.creativecommons.org/presskit/buttons/88x31/png/by.png", + "link": "https://creativecommons.org/licenses/by/4.0/legalcode" + }, + "dataset": { + "core": { + "iri": "http://virtualflybrain.org/reports/Sayin2019", + "symbol": "", + "types": [ + "Entity", + "Individual", + "DataSet" + ], + "short_form": "Sayin2019", + "unique_facets": [], + "label": "EM FAFB Sayin et al 2019" + }, + "link": "https://fafb.catmaid.virtualflybrain.org" + } + } + ], + "pub_specific_content": { + "title": "A Neural Circuit Arbitrates between Persistence and Withdrawal in Hungry Drosophila.", + "FlyBase": "FBrf0243986", + "PubMed": "31471123", + "DOI": "10.1016/j.neuron.2019.07.028" + } +} \ No newline at end of file diff --git a/src/test/fixtures/term_info/split_VFBexp_FBtp0124468FBtp0133404.json b/src/test/fixtures/term_info/split_VFBexp_FBtp0124468FBtp0133404.json new file mode 100644 index 0000000..9ce42b0 --- /dev/null +++ b/src/test/fixtures/term_info/split_VFBexp_FBtp0124468FBtp0133404.json @@ -0,0 +1,387 @@ +{ + "term": { + "core": { + "iri": "http://virtualflybrain.org/reports/VFBexp_FBtp0124468FBtp0133404", + "symbol": "SS50574", + "types": [ + "Entity", + "Class", + "Anatomy", + "Expression_pattern", + "Split" + ], + "short_form": "VFBexp_FBtp0124468FBtp0133404", + "unique_facets": [ + "Split" + ], + "label": "P{VT043927-GAL4.DBD} ∩ P{VT017491-p65.AD} expression pattern" + }, + "description": [ + "The sum of all cells at the intersection between the expression patterns of P{VT043927-GAL4.DBD} and P{VT017491-p65.AD}." + ], + "comment": [] + }, + "query": "Get JSON for Split Class", + "version": "0dcd736", + "parents": [ + { + "iri": "http://purl.obolibrary.org/obo/fbbt/vfb/VFBext_0000010", + "symbol": "", + "types": [ + "Entity", + "Class", + "Anatomy", + "Split", + "has_subClass" + ], + "short_form": "VFBext_0000010", + "unique_facets": [ + "Anatomy" + ], + "label": "intersectional expression pattern" + } + ], + "relationships": [ + { + "relation": { + "iri": "http://purl.obolibrary.org/obo/fbbt/vfb/VFBext_0000008", + "database_cross_reference": [], + "label": "has hemidriver", + "type": "has_hemidriver", + "confidence_value": "" + }, + "object": { + "symbol": "", + "iri": "http://flybase.org/reports/FBtp0133404", + "types": [ + "Entity", + "Class", + "Feature" + ], + "short_form": "FBtp0133404", + "unique_facets": [], + "label": "P{VT017491-p65.AD}" + } + }, + { + "relation": { + "iri": "http://purl.obolibrary.org/obo/fbbt/vfb/VFBext_0000008", + "database_cross_reference": [], + "label": "has hemidriver", + "type": "has_hemidriver", + "confidence_value": "" + }, + "object": { + "symbol": "", + "iri": "http://flybase.org/reports/FBtp0124468", + "types": [ + "Entity", + "Class", + "Feature" + ], + "short_form": "FBtp0124468", + "unique_facets": [], + "label": "P{VT043927-GAL4.DBD}" + } + } + ], + "related_individuals": [], + "xrefs": [ + { + "link_base": "https://neuronbridge.janelia.org/search?q=", + "site": { + "symbol": "", + "iri": "http://virtualflybrain.org/reports/neuronbridge", + "types": [ + "Entity", + "Individual", + "Site" + ], + "short_form": "neuronbridge", + "unique_facets": [], + "label": "neuronbridge" + }, + "is_data_source": false, + "link_postfix": "", + "icon": "http://www.virtualflybrain.org/data/VFB/logos/neuronbridge.png", + "accession": "SS50574", + "link_text": "P{VT043927-GAL4.DBD} ∩ P{VT017491-p65.AD} expression pattern on neuronbridge", + "homepage": "https://neuronbridge.janelia.org/" + }, + { + "link_base": "http://splitgal4.janelia.org/cgi-bin/view_splitgal4_imagery.cgi?line=", + "site": { + "symbol": "", + "iri": "http://virtualflybrain.org/reports/FlyLightSplit", + "types": [ + "Entity", + "Individual", + "Site" + ], + "short_form": "FlyLightSplit", + "unique_facets": [], + "label": "Driver Line on the FlyLight Split-GAL4 Site" + }, + "is_data_source": true, + "link_postfix": "", + "icon": "http://www.virtualflybrain.org/data/VFB/logos/fly_light_color.png", + "accession": "SS50574", + "link_text": "P{VT043927-GAL4.DBD} ∩ P{VT017491-p65.AD} expression pattern on Driver Line on the FlyLight Split-GAL4 Site", + "homepage": "http://splitgal4.janelia.org/cgi-bin/splitgal4.cgi" + } + ], + "anatomy_channel_image": [ + { + "anatomy": { + "symbol": "SS50574", + "iri": "http://virtualflybrain.org/reports/VFB_001013p8", + "types": [ + "Entity", + "Individual", + "VFB", + "Adult", + "Anatomy", + "Nervous_system", + "Split", + "has_image" + ], + "short_form": "VFB_001013p8", + "unique_facets": [ + "Adult", + "Nervous_system", + "Split" + ], + "label": "JRC_SS50574_VNC_2018_20180511_41_G2_20x" + }, + "channel_image": { + "image": { + "image_nrrd": "http://www.virtualflybrain.org/data/VFB/i/0010/13p8/VFB_00200000/volume.nrrd", + "image_swc": "", + "template_channel": { + "symbol": "", + "iri": "http://virtualflybrain.org/reports/VFBc_00200000", + "types": [ + "Entity", + "Individual", + "VFB", + "Channel", + "Template" + ], + "short_form": "VFBc_00200000", + "unique_facets": [ + "Channel" + ], + "label": "JRC2018UnisexVNC_c" + }, + "index": [], + "template_anatomy": { + "symbol": "JRCVNC2018U", + "iri": "http://virtualflybrain.org/reports/VFB_00200000", + "types": [ + "Entity", + "Individual", + "VFB", + "Adult", + "Anatomy", + "Ganglion", + "Nervous_system", + "Template", + "has_image" + ], + "short_form": "VFB_00200000", + "unique_facets": [ + "Adult", + "Ganglion", + "Nervous_system" + ], + "label": "JRC2018UnisexVNC" + }, + "image_wlz": "http://www.virtualflybrain.org/data/VFB/i/0010/13p8/VFB_00200000/volume.wlz", + "image_obj": "http://www.virtualflybrain.org/data/VFB/i/0010/13p8/VFB_00200000/volume.obj", + "image_thumbnail": "http://www.virtualflybrain.org/data/VFB/i/0010/13p8/VFB_00200000/thumbnail.png", + "image_folder": "http://www.virtualflybrain.org/data/VFB/i/0010/13p8/VFB_00200000/" + }, + "channel": { + "symbol": "", + "iri": "http://virtualflybrain.org/reports/VFBc_001013p8", + "types": [ + "Entity", + "Individual", + "VFB", + "Channel" + ], + "short_form": "VFBc_001013p8", + "unique_facets": [ + "Channel" + ], + "label": "JRC_SS50574_VNC_2018_20180511_41_G2_20x_c" + }, + "imaging_technique": { + "symbol": "Confocal", + "iri": "http://purl.obolibrary.org/obo/FBbi_00000251", + "types": [ + "Entity", + "Class", + "has_subClass" + ], + "short_form": "FBbi_00000251", + "unique_facets": [], + "label": "confocal microscopy" + } + } + }, + { + "anatomy": { + "symbol": "SS50574", + "iri": "http://virtualflybrain.org/reports/VFB_001013mm", + "types": [ + "Entity", + "Individual", + "VFB", + "Adult", + "Anatomy", + "Nervous_system", + "Split", + "has_image" + ], + "short_form": "VFB_001013mm", + "unique_facets": [ + "Adult", + "Nervous_system", + "Split" + ], + "label": "JRC_SS50574_Brain_2018_20180511_41_G2_20x" + }, + "channel_image": { + "image": { + "image_nrrd": "http://www.virtualflybrain.org/data/VFB/i/0010/13mm/VFB_00101567/volume.nrrd", + "image_swc": "", + "template_channel": { + "symbol": "", + "iri": "http://virtualflybrain.org/reports/VFBc_00101567", + "types": [ + "Entity", + "Individual", + "VFB", + "Channel", + "Template" + ], + "short_form": "VFBc_00101567", + "unique_facets": [ + "Channel" + ], + "label": "JRC2018Unisex_c" + }, + "index": [], + "template_anatomy": { + "symbol": "JRC2018U", + "iri": "http://virtualflybrain.org/reports/VFB_00101567", + "types": [ + "Entity", + "Individual", + "VFB", + "Adult", + "Anatomy", + "Nervous_system", + "Template", + "has_image" + ], + "short_form": "VFB_00101567", + "unique_facets": [ + "Adult", + "Nervous_system" + ], + "label": "JRC2018Unisex" + }, + "image_wlz": "http://www.virtualflybrain.org/data/VFB/i/0010/13mm/VFB_00101567/volume.wlz", + "image_obj": "http://www.virtualflybrain.org/data/VFB/i/0010/13mm/VFB_00101567/volume.obj", + "image_thumbnail": "http://www.virtualflybrain.org/data/VFB/i/0010/13mm/VFB_00101567/thumbnail.png", + "image_folder": "http://www.virtualflybrain.org/data/VFB/i/0010/13mm/VFB_00101567/" + }, + "channel": { + "symbol": "", + "iri": "http://virtualflybrain.org/reports/VFBc_001013mm", + "types": [ + "Entity", + "Individual", + "VFB", + "Channel" + ], + "short_form": "VFBc_001013mm", + "unique_facets": [ + "Channel" + ], + "label": "JRC_SS50574_Brain_2018_20180511_41_G2_20x_c" + }, + "imaging_technique": { + "symbol": "Confocal", + "iri": "http://purl.obolibrary.org/obo/FBbi_00000251", + "types": [ + "Entity", + "Class", + "has_subClass" + ], + "short_form": "FBbi_00000251", + "unique_facets": [], + "label": "confocal microscopy" + } + } + } + ], + "pub_syn": [ + { + "synonym": { + "scope": "has_exact_synonym", + "label": "SS50574", + "type": "" + }, + "pub": { + "core": { + "symbol": "", + "iri": "http://flybase.org/reports/Unattributed", + "types": [ + "Entity", + "Individual", + "pub" + ], + "short_form": "Unattributed", + "unique_facets": [ + "pub" + ], + "label": "" + }, + "FlyBase": "", + "PubMed": "", + "DOI": "" + } + }, + { + "synonym": { + "scope": "has_exact_synonym", + "label": "VT017491-x-VT043927", + "type": "" + }, + "pub": { + "core": { + "symbol": "", + "iri": "http://flybase.org/reports/Unattributed", + "types": [ + "Entity", + "Individual", + "pub" + ], + "short_form": "Unattributed", + "unique_facets": [ + "pub" + ], + "label": "" + }, + "FlyBase": "", + "PubMed": "", + "DOI": "" + } + } + ], + "def_pubs": [], + "target_neurons": [] +} \ No newline at end of file diff --git a/src/test/fixtures/term_info/split_VFBexp_FBtp0129935FBtp0129968.json b/src/test/fixtures/term_info/split_VFBexp_FBtp0129935FBtp0129968.json new file mode 100644 index 0000000..4d95969 --- /dev/null +++ b/src/test/fixtures/term_info/split_VFBexp_FBtp0129935FBtp0129968.json @@ -0,0 +1,281 @@ +{ + "term": { + "core": { + "iri": "http://virtualflybrain.org/reports/VFBexp_FBtp0129935FBtp0129968", + "symbol": "", + "types": [ + "Entity", + "Class", + "Anatomy", + "Expression_pattern", + "Split" + ], + "short_form": "VFBexp_FBtp0129935FBtp0129968", + "unique_facets": [ + "Split" + ], + "label": "P{ple-GAL4.DBD.TH-F} ∩ P{ple-p65.AD.TH-D} expression pattern" + }, + "description": [ + "The sum of all cells at the intersection between the expression patterns of P{ple-GAL4.DBD.TH-F} and P{ple-p65.AD.TH-D}." + ], + "comment": [] + }, + "query": "Get JSON for Split Class", + "version": "0dcd736", + "parents": [ + { + "iri": "http://purl.obolibrary.org/obo/fbbt/vfb/VFBext_0000010", + "symbol": "", + "types": [ + "Entity", + "Class", + "Anatomy", + "Split", + "has_subClass" + ], + "short_form": "VFBext_0000010", + "unique_facets": [ + "Anatomy" + ], + "label": "intersectional expression pattern" + } + ], + "relationships": [ + { + "relation": { + "iri": "http://purl.obolibrary.org/obo/fbbt/vfb/VFBext_0000008", + "database_cross_reference": [], + "label": "has hemidriver", + "type": "has_hemidriver", + "confidence_value": "" + }, + "object": { + "symbol": "", + "iri": "http://flybase.org/reports/FBtp0129968", + "types": [ + "Entity", + "Class", + "Feature" + ], + "short_form": "FBtp0129968", + "unique_facets": [], + "label": "P{ple-p65.AD.TH-D}" + } + }, + { + "relation": { + "iri": "http://purl.obolibrary.org/obo/fbbt/vfb/VFBext_0000008", + "database_cross_reference": [], + "label": "has hemidriver", + "type": "has_hemidriver", + "confidence_value": "" + }, + "object": { + "symbol": "", + "iri": "http://flybase.org/reports/FBtp0129935", + "types": [ + "Entity", + "Class", + "Feature" + ], + "short_form": "FBtp0129935", + "unique_facets": [], + "label": "P{ple-GAL4.DBD.TH-F}" + } + } + ], + "related_individuals": [], + "xrefs": [], + "anatomy_channel_image": [ + { + "anatomy": { + "symbol": "", + "iri": "http://virtualflybrain.org/reports/VFB_00070031", + "types": [ + "Entity", + "Individual", + "VFB", + "Anatomy", + "Split", + "has_image" + ], + "short_form": "VFB_00070031", + "unique_facets": [ + "Split" + ], + "label": "P{ple-GAL4.DBD.TH-F} ∩ P{ple-p65.AD.TH-D} expression pattern of Xie2018" + }, + "channel_image": { + "image": { + "image_nrrd": "http://www.virtualflybrain.org/data/VFB/i/0007/0031/VFB_00101567/volume.nrrd", + "image_swc": "", + "template_channel": { + "symbol": "", + "iri": "http://virtualflybrain.org/reports/VFBc_00101567", + "types": [ + "Entity", + "Individual", + "VFB", + "Channel", + "Template" + ], + "short_form": "VFBc_00101567", + "unique_facets": [ + "Channel" + ], + "label": "JRC2018Unisex_c" + }, + "index": [], + "template_anatomy": { + "symbol": "JRC2018U", + "iri": "http://virtualflybrain.org/reports/VFB_00101567", + "types": [ + "Entity", + "Individual", + "VFB", + "Adult", + "Anatomy", + "Nervous_system", + "Template", + "has_image" + ], + "short_form": "VFB_00101567", + "unique_facets": [ + "Adult", + "Nervous_system" + ], + "label": "JRC2018Unisex" + }, + "image_wlz": "http://www.virtualflybrain.org/data/VFB/i/0007/0031/VFB_00101567/volume.wlz", + "image_obj": "http://www.virtualflybrain.org/data/VFB/i/0007/0031/VFB_00101567/volume.obj", + "image_thumbnail": "http://www.virtualflybrain.org/data/VFB/i/0007/0031/VFB_00101567/thumbnail.png", + "image_folder": "http://www.virtualflybrain.org/data/VFB/i/0007/0031/VFB_00101567/" + }, + "channel": { + "symbol": "", + "iri": "http://virtualflybrain.org/reports/VFBc_00070031", + "types": [ + "Entity", + "Individual", + "VFB", + "Channel" + ], + "short_form": "VFBc_00070031", + "unique_facets": [ + "Channel" + ], + "label": "P{ple-GAL4.DBD.TH-F} ∩ P{ple-p65.AD.TH-D} expression pattern of Xie2018_c" + }, + "imaging_technique": { + "symbol": "Confocal", + "iri": "http://purl.obolibrary.org/obo/FBbi_00000251", + "types": [ + "Entity", + "Class", + "has_subClass" + ], + "short_form": "FBbi_00000251", + "unique_facets": [], + "label": "confocal microscopy" + } + } + }, + { + "anatomy": { + "symbol": "", + "iri": "http://virtualflybrain.org/reports/VFB_00070031", + "types": [ + "Entity", + "Individual", + "VFB", + "Anatomy", + "Split", + "has_image" + ], + "short_form": "VFB_00070031", + "unique_facets": [ + "Split" + ], + "label": "P{ple-GAL4.DBD.TH-F} ∩ P{ple-p65.AD.TH-D} expression pattern of Xie2018" + }, + "channel_image": { + "image": { + "image_nrrd": "http://www.virtualflybrain.org/data/VFB/i/0007/0031/VFB_00017894/volume.nrrd", + "image_swc": "", + "template_channel": { + "symbol": "", + "iri": "http://virtualflybrain.org/reports/VFBc_00017894", + "types": [ + "Entity", + "Individual", + "VFB", + "Channel", + "Template" + ], + "short_form": "VFBc_00017894", + "unique_facets": [ + "Channel" + ], + "label": "JFRC2_template_c" + }, + "index": [], + "template_anatomy": { + "symbol": "JFRC2", + "iri": "http://virtualflybrain.org/reports/VFB_00017894", + "types": [ + "Entity", + "Individual", + "VFB", + "Adult", + "Anatomy", + "Nervous_system", + "Template", + "has_image" + ], + "short_form": "VFB_00017894", + "unique_facets": [ + "Adult", + "Nervous_system" + ], + "label": "adult brain template JFRC2" + }, + "image_wlz": "http://www.virtualflybrain.org/data/VFB/i/0007/0031/VFB_00017894/volume.wlz", + "image_obj": "http://www.virtualflybrain.org/data/VFB/i/0007/0031/VFB_00017894/volume.obj", + "image_thumbnail": "http://www.virtualflybrain.org/data/VFB/i/0007/0031/VFB_00017894/thumbnail.png", + "image_folder": "http://www.virtualflybrain.org/data/VFB/i/0007/0031/VFB_00017894/" + }, + "channel": { + "symbol": "", + "iri": "http://virtualflybrain.org/reports/VFBc_00070031", + "types": [ + "Entity", + "Individual", + "VFB", + "Channel" + ], + "short_form": "VFBc_00070031", + "unique_facets": [ + "Channel" + ], + "label": "P{ple-GAL4.DBD.TH-F} ∩ P{ple-p65.AD.TH-D} expression pattern of Xie2018_c" + }, + "imaging_technique": { + "symbol": "Confocal", + "iri": "http://purl.obolibrary.org/obo/FBbi_00000251", + "types": [ + "Entity", + "Class", + "has_subClass" + ], + "short_form": "FBbi_00000251", + "unique_facets": [], + "label": "confocal microscopy" + } + } + } + ], + "pub_syn": [], + "def_pubs": [], + "target_neurons": [] +} \ No newline at end of file diff --git a/src/test/term_info_queries_test.py b/src/test/term_info_queries_test.py index 1f16bed..8ffc070 100644 --- a/src/test/term_info_queries_test.py +++ b/src/test/term_info_queries_test.py @@ -1,10 +1,26 @@ import os import unittest import time +import json +import pytest from vfbquery.term_info_queries import deserialize_term_info, deserialize_term_info_from_dict, process from vfbquery.solr_fetcher import SolrTermInfoFetcher +def _load_fixture(name): + """Load a committed, complete term_info document (a render-code fixture). + + These are captured from the PDB (the source of truth) and are what the term + INDEXER should produce; they let the serialization tests run deterministically + without depending on live SOLR ``vfb_json`` content. See TESTING.md, + 'Fixture vs live (data_health)'. + """ + path = os.path.join(os.path.dirname(__file__), + "fixtures", "term_info", name + ".json") + with open(path, encoding="utf-8") as f: + return json.load(f) + + class TermInfoQueriesTest(unittest.TestCase): def setUp(self): @@ -346,12 +362,8 @@ def test_term_info_serialization_neuron_class2(self): self.assertFalse("filemeta" in serialized) self.assertFalse("template" in serialized) - def test_term_info_serialization_split_class(self): - term_info_dict = self.get_term_info_or_skip('VFBexp_FBtp0124468FBtp0133404') - print(term_info_dict) - start_time = time.time() + def _check_split_class_serialization(self, term_info_dict): serialized = process(term_info_dict, self.variable) - print("--- %s seconds ---" % (time.time() - start_time)) self.assertEqual("P{VT043927-GAL4.DBD} ∩ P{VT017491-p65.AD} expression pattern [VFBexp_FBtp0124468FBtp0133404]", serialized["label"]) self.assertFalse("title" in serialized) @@ -380,6 +392,21 @@ def test_term_info_serialization_split_class(self): self.assertIn(expected_rel_2, serialized["relationships"]) self.assertFalse("related_individuals" in serialized) + + def test_term_info_serialization_split_class(self): + """Render code (PR-blocking): serialize a complete split-class doc from a + committed fixture. Deterministic — isolates a serialization regression + from live-data state. See TESTING.md 'Fixture vs live (data_health)'.""" + self._check_split_class_serialization( + _load_fixture("split_VFBexp_FBtp0124468FBtp0133404")) + + @pytest.mark.data_health + def test_term_info_serialization_split_class_live(self): + """Data-health (scheduled only): the same assertions against the live SOLR document, so an + incomplete production document (missing the Expression_pattern type, the + synonyms, ...) is caught. Deselected on PRs via ``-m 'not data_health'``.""" + self._check_split_class_serialization( + self.get_term_info_or_skip('VFBexp_FBtp0124468FBtp0133404')) self.assertTrue("xrefs" in serialized) self.assertEqual(2, len(serialized["xrefs"])) expected_xref = {'icon': 'https://www.virtualflybrain.org/data/VFB/logos/fly_light_color.png', @@ -536,13 +563,8 @@ def test_term_info_serialization_template(self): self.assertTrue("template" in serialized) self.assertEqual("[JRC2018UnisexVNC](VFB_00200000)", serialized["template"]) - def test_term_info_serialization_pub(self): - term_info_dict = self.get_term_info_or_skip('FBrf0243986') - print(term_info_dict) - start_time = time.time() + def _check_pub_serialization(self, term_info_dict): serialized = process(term_info_dict, self.variable) - print("--- %s seconds ---" % (time.time() - start_time)) - self.assertEqual("Sayin et al., 2019, Neuron 104(3): 544--558.e6 [FBrf0243986]", serialized["label"]) self.assertTrue("title" in serialized) self.assertEqual("A Neural Circuit Arbitrates between Persistence and Withdrawal in Hungry Drosophila.", serialized["title"]) @@ -556,6 +578,19 @@ def test_term_info_serialization_pub(self): self.assertFalse("license" in serialized) self.assertFalse("Classification" in serialized) self.assertFalse("relationships" in serialized) + + def test_term_info_serialization_pub(self): + """Render code (PR-blocking): serialize a complete pub doc from a committed + fixture. Deterministic — independent of live SOLR content and caches, so it isolates a + code regression. See TESTING.md 'Fixture vs live (data_health)'.""" + self._check_pub_serialization(_load_fixture("pub_FBrf0243986")) + + @pytest.mark.data_health + def test_term_info_serialization_pub_live(self): + """Data-health (scheduled only): the same assertions against the live SOLR document, so a + stale/incomplete production document is caught. Deselected on PRs via + ``-m 'not data_health'``.""" + self._check_pub_serialization(self.get_term_info_or_skip('FBrf0243986')) self.assertFalse("related_individuals" in serialized) self.assertTrue("xrefs" in serialized) diff --git a/src/test/test_expression_pattern_individual_queries.py b/src/test/test_expression_pattern_individual_queries.py index 288dea0..c553721 100644 --- a/src/test/test_expression_pattern_individual_queries.py +++ b/src/test/test_expression_pattern_individual_queries.py @@ -18,10 +18,31 @@ import unittest import sys import os +import json +import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) -from vfbquery.vfb_queries import get_term_info +from vfbquery.vfb_queries import get_term_info, term_info_parse_object + + +class _FixtureResults: + """Minimal SOLR-result stand-in wrapping a committed term_info fixture.""" + def __init__(self, docs): + self.docs = docs + self.hits = len(docs) + + +def _fixture_term_info(name): + """Parse a committed complete term_info fixture through the render code, so the + FindStocks generation can be tested without live SOLR content. See TESTING.md + 'Fixture vs live (data_health)'.""" + path = os.path.join(os.path.dirname(__file__), + "fixtures", "term_info", name + ".json") + with open(path, encoding="utf-8") as f: + doc = json.load(f) + sf = doc["term"]["core"]["short_form"] + return term_info_parse_object(_FixtureResults([{"term_info": [json.dumps(doc)]}]), sf) def _menu(term_info): @@ -67,6 +88,19 @@ def test_instance_stock_query_matches_its_driver(self): self.assertEqual(self._anchors_or_skip(self.EP_INDIVIDUAL), ["FBtp0060056"]) def test_split_class_offers_a_stock_query_per_hemidriver(self): + """Render code (PR-blocking): FindStocks is generated per hemidriver from a + complete split fixture (its ``has_hemidriver`` relationships). Deterministic + — isolates the generation code from live-data state. See TESTING.md + 'Fixture vs live (data_health)'.""" + self.assertEqual( + _stock_anchors(_fixture_term_info("split_VFBexp_FBtp0129935FBtp0129968")), + ["FBtp0129935", "FBtp0129968"]) + + @pytest.mark.data_health + def test_split_class_offers_a_stock_query_per_hemidriver_live(self): + """Data-health (scheduled only): the same against the live SOLR document, so an incomplete + production split document (no ``Expression_pattern`` type / no + ``has_hemidriver``) is caught. Deselected on PRs via ``-m 'not data_health'``.""" self.assertEqual(self._anchors_or_skip(self.SPLIT_CLASS), ["FBtp0129935", "FBtp0129968"]) diff --git a/src/test/test_term_info_parity.py b/src/test/test_term_info_parity.py index ce705f4..9d8293b 100644 --- a/src/test/test_term_info_parity.py +++ b/src/test/test_term_info_parity.py @@ -25,6 +25,8 @@ import unittest import sys +import json +import pytest sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) @@ -44,6 +46,16 @@ def _raw(short_form): return _Results(q.vfb_solr.search('id:' + short_form).docs) +def _fixture_raw(name): + """Wrap a committed complete term_info fixture as a SOLR-style result, so the + render code can be tested without live SOLR content. See TESTING.md + 'Fixture vs live (data_health)'.""" + path = os.path.join(os.path.dirname(__file__), + "fixtures", "term_info", name + ".json") + with open(path, encoding="utf-8") as f: + return _Results([{"term_info": [json.dumps(json.load(f))]}]) + + class TermInfoParityTest(unittest.TestCase): def _parse(self, short_form): @@ -81,7 +93,17 @@ def test_class_synonyms_not_regressed(self): # --- Gap C: publication external content (pub_specific_content) --------- def test_publication_external_content_present(self): - ti = self._parse("FBrf0242477") # Dolan et al., 2019 + self._check_pub_external_content( + q.term_info_parse_object(_fixture_raw("pub_FBrf0242477"), "FBrf0242477")) + + @pytest.mark.data_health + def test_publication_external_content_present_live(self): + """Data-health (scheduled only): the same parity check against the live SOLR document, so + an incomplete production pub document is caught. Deselected on PRs via + ``-m 'not data_health'``.""" + self._check_pub_external_content(self._parse("FBrf0242477")) + + def _check_pub_external_content(self, ti): pubs = ti.get("Publications", []) self.assertTrue(pubs, "pub_specific_content dropped: Publications empty") pub = pubs[0] diff --git a/src/vfbquery/term_info_fallback.py b/src/vfbquery/term_info_fallback.py index e5a2bfb..7b4acdf 100644 --- a/src/vfbquery/term_info_fallback.py +++ b/src/vfbquery/term_info_fallback.py @@ -6,12 +6,11 @@ driven by the ``precompute live query results`` Jenkins job. A record that reaches the PDB after the last successful run of that job therefore has no document at all, and every VFBquery call for it returns ``None`` until the -job next completes — which is not a matter of hours: build #71 started on -2026-09-04, #70 (2026-09-02) was aborted, and the last success before that -was #66 on 2026-06-22. +job next completes — which can be weeks, not hours, when runs are aborted or +spaced far apart. -``VFB_00107fob`` ("ME_R on JRC2018Unisex") is the case that surfaced this. -It is a perfectly good painted domain — its ``in_register_with`` edge, its +A painted domain such as ``VFB_00107fob`` ("ME_R on JRC2018Unisex") is the kind +of case that surfaces this. It is a perfectly good term — its ``in_register_with`` edge, its images and its parent class are all in the PDB — but with no SOLR document it is invisible to term info, and it silently vanished from the medulla class page as well. @@ -32,9 +31,9 @@ class page as well. (``anat_query``, ``anat_2_ep_query``, ``ep_2_anat_query``) are untouched either way; * never when the SOLR cache is disabled, so a test run against live data - cannot write into the shared production collection. That has bitten VFB - before: a fixture written into the production cache namespace poisoned - JRC2018U term info for five days. + cannot write into the shared production collection — a fixture written into + the production cache namespace can corrupt a term's info until the next + reindex. If the indexer is not importable — a plain ``pip install vfbquery`` has no reason to carry it — every entry point here degrades to "no fallback" and @@ -408,11 +407,12 @@ def write_term_info(solr_doc): def backfill_term_info(short_form): - """Build a missing document, write it back, and return the payload. + """Build a document, write it back, and return the payload. - Called by ``get_term_info`` when SOLR has no document for the id. The - caller has already established the miss, so the write cannot overwrite - anything. + Called by ``get_term_info`` when SOLR has no term_info for the id, or when a + ``pub`` document is present but hollow (a title-less skeleton). In the miss + case the write cannot overwrite anything; in the hollow-pub case it + deliberately replaces the empty document with the rebuilt one. :return: the ``term_info`` payload as a JSON string, or None """ diff --git a/src/vfbquery/vfb_queries.py b/src/vfbquery/vfb_queries.py index fc1f5dc..1482fc1 100644 --- a/src/vfbquery/vfb_queries.py +++ b/src/vfbquery/vfb_queries.py @@ -2581,9 +2581,8 @@ def _has_term_info_document(results): several indexers with atomic updates, each setting only its own field, so a record that one of the query-result indexers (``all_datasets_query``, ``anat_image_query``, ...) reaches before the term_info indexer does has - a document -- ``hits == 1`` -- but nothing to read. Berg2025a and - Bates2026 were exactly that on 2026-09-04: listed under All Datasets, - blank in term info. Test the field, not the hit count. + a document -- ``hits == 1`` -- but nothing to read: it is listed under (say) + All Datasets while its term info is blank. Test the field, not the hit count. """ if not getattr(results, "hits", 0): return False @@ -2593,6 +2592,38 @@ def _has_term_info_document(results): return bool(docs[0].get("term_info")) +def _pub_term_info_is_empty(results): + """True when a ``pub`` document's term_info is present but carries no + external content -- an empty ``title`` and a ``label`` that is just the id. + + This is a document that *has* a term_info field (so :func:`_has_term_info_document` + is satisfied) but was written without the publication metadata the PDB + holds -- e.g. a FlyBase fetch that failed at index time. Served as-is it + renders as a bare ``FBrf... [FBrf...]`` with no title; it should instead be + rebuilt from the PDB, exactly as a wholly missing document is. Scoped to + pubs so a legitimately sparse document of another type is never mistaken for + a skeleton and needlessly rebuilt. + """ + docs = getattr(results, "docs", None) or [] + if not docs: + return False + ti = docs[0].get("term_info") + if not ti: + return False + raw = ti[0] if isinstance(ti, (list, tuple)) else ti + try: + doc = raw if isinstance(raw, dict) else json.loads(raw) + except (TypeError, ValueError): + return False + core = (doc.get("term") or {}).get("core") or {} + if "pub" not in (core.get("types") or []): + return False + psc = doc.get("pub_specific_content") or {} + title = (psc.get("title") or "").strip() + label = (core.get("label") or "").strip() + return not title and (not label or label == (core.get("short_form") or "")) + + # term_info SOLR loaders: fetch one term's term_info doc by short_form and # return it either as a deserialized object (attribute access) or as the raw # JSON dict, whichever the caller works with. @@ -2912,7 +2943,12 @@ def get_term_info(short_form: str, preview: bool = True, force_refresh: bool = F # And tested on the field, not the hit count: a document written by # one of the other indexers (all_datasets_query et al.) exists with # no term_info at all, and the parser turns that into a bare None. - if not _has_term_info_document(results): + # Rebuild when there is no term_info to read *or* when a pub document is + # present but hollow (title-less skeleton). The latter renders as a bare + # `FBrf... [FBrf...]` — the PDB has the miniref/title, so rebuild from it + # rather than serve the empty doc. force_refresh reaches this path, so a + # refresh heals the SOLR doc instead of re-caching the empty one. + if not _has_term_info_document(results) or _pub_term_info_is_empty(results): fallback_payload = backfill_term_info(short_form) if fallback_payload: results = _FallbackSolrResult(fallback_payload)