init public release
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
"""Comparison tests: batched vs. unbatched runs of ``bundles_to_graph``.
|
||||
|
||||
Batching is driven by ``BATCH_SIZE``. The function accumulates patients into
|
||||
a graph and flushes (process_references -> property_convolution ->
|
||||
run_biocypher) every ``BATCH_SIZE`` patients, and once more at the end via
|
||||
the ``c == n`` guard. "With batching" uses a small size; "without" uses a
|
||||
size >= the patient count so there is a single flush.
|
||||
|
||||
We fake the collaborators: ``get_patient_everything`` returns a trivial
|
||||
bundle, ``add_json_to_networkx`` adds exactly one node per patient (keyed by
|
||||
the bundle name the pipeline builds, e.g. "p0_bundle"), and ``run_biocypher``
|
||||
records a snapshot of the node set it is handed. That snapshot per flush is
|
||||
what we compare.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import import_fhir_to_nx_diGraph as pipeline
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
def _run(ids, batch_size, monkeypatch):
|
||||
"""Run bundles_to_graph and return the node set written at each flush."""
|
||||
monkeypatch.setenv("BATCH_SIZE", str(batch_size))
|
||||
monkeypatch.setattr(
|
||||
pipeline, "get_patient_everything",
|
||||
lambda pid: FakeResponse({"id": pid}),
|
||||
)
|
||||
|
||||
def add_json(_bundle, name, graph):
|
||||
graph.add_node(name) # one node per patient bundle
|
||||
|
||||
monkeypatch.setattr(
|
||||
pipeline, "create_graph",
|
||||
mock.Mock(add_json_to_networkx=add_json),
|
||||
)
|
||||
monkeypatch.setattr(pipeline, "process_references", lambda g: None)
|
||||
monkeypatch.setattr(pipeline, "property_convolution", lambda g: None)
|
||||
|
||||
flushes = []
|
||||
monkeypatch.setattr(
|
||||
pipeline, "run_biocypher",
|
||||
lambda graph: flushes.append(set(graph.nodes())),
|
||||
)
|
||||
|
||||
pipeline.bundles_to_graph(ids, len(ids))
|
||||
return flushes
|
||||
|
||||
|
||||
# A big batch size relative to n => a single flush => "no batching".
|
||||
NO_BATCHING = 10_000
|
||||
|
||||
|
||||
def test_batched_and_unbatched_write_the_same_nodes(monkeypatch):
|
||||
ids = [f"p{i}" for i in range(4)]
|
||||
expected = {f"p{i}_bundle" for i in range(4)}
|
||||
|
||||
batched = _run(ids, batch_size=2, monkeypatch=monkeypatch)
|
||||
unbatched = _run(ids, batch_size=NO_BATCHING, monkeypatch=monkeypatch)
|
||||
|
||||
# Core invariant: total written content is identical either way.
|
||||
assert set().union(*batched) == expected
|
||||
assert unbatched[0] == expected
|
||||
assert set().union(*batched) == unbatched[0]
|
||||
|
||||
|
||||
def test_batching_changes_flush_count_only(monkeypatch):
|
||||
ids = [f"p{i}" for i in range(4)]
|
||||
batched = _run(ids, batch_size=2, monkeypatch=monkeypatch)
|
||||
unbatched = _run(ids, batch_size=NO_BATCHING, monkeypatch=monkeypatch)
|
||||
|
||||
assert len(batched) == 2 # 4 patients / batch of 2
|
||||
assert len(unbatched) == 1 # single flush at the end
|
||||
|
||||
|
||||
def test_batches_partition_patients_without_overlap(monkeypatch):
|
||||
ids = [f"p{i}" for i in range(4)]
|
||||
batched = _run(ids, batch_size=2, monkeypatch=monkeypatch)
|
||||
|
||||
# Each batch is a fresh graph, so flushes must be disjoint and together
|
||||
# cover every patient exactly once.
|
||||
assert batched[0].isdisjoint(batched[1])
|
||||
assert sum(len(f) for f in batched) == 4
|
||||
|
||||
|
||||
def test_uneven_batch_flushes_the_remainder(monkeypatch):
|
||||
# 5 patients, batch of 2 => flushes at c=2, c=4, and c==n (the leftover).
|
||||
ids = [f"p{i}" for i in range(5)]
|
||||
batched = _run(ids, batch_size=2, monkeypatch=monkeypatch)
|
||||
|
||||
assert [len(f) for f in batched] == [2, 2, 1]
|
||||
assert set().union(*batched) == {f"p{i}_bundle" for i in range(5)}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch_size", [1, 2, 3, 5, NO_BATCHING])
|
||||
def test_no_patient_is_dropped_for_any_batch_size(batch_size, monkeypatch):
|
||||
ids = [f"p{i}" for i in range(5)]
|
||||
flushes = _run(ids, batch_size=batch_size, monkeypatch=monkeypatch)
|
||||
|
||||
# Regardless of batch size, the union of all flushes is the full set.
|
||||
assert set().union(*flushes) == {f"p{i}_bundle" for i in range(5)}
|
||||
|
||||
|
||||
def test_reference_resolution_scope_differs(monkeypatch):
|
||||
# Each batched flush works on its own graph, so no single flush ever sees
|
||||
# the whole graph in memory. This does NOT break correctness: cross-batch
|
||||
# references are carried by dummy target nodes and resolved later by
|
||||
# neo4j-admin import via id matching across all CSV part files (see
|
||||
# test_dummy_node_reference_resolution.py). This test just documents the
|
||||
# in-memory scope; the dummy-node tests cover the actual equivalence.
|
||||
ids = [f"p{i}" for i in range(4)]
|
||||
|
||||
batched = _run(ids, batch_size=2, monkeypatch=monkeypatch)
|
||||
unbatched = _run(ids, batch_size=NO_BATCHING, monkeypatch=monkeypatch)
|
||||
|
||||
assert max(len(f) for f in batched) < len(ids) # never the full graph
|
||||
assert len(unbatched[0]) == len(ids) # full graph at once
|
||||
|
||||
|
||||
def test_batch_size_zero_raises(monkeypatch):
|
||||
# SHARP EDGE: BATCH_SIZE=0 makes `c % batch_size` divide by zero.
|
||||
ids = [f"p{i}" for i in range(3)]
|
||||
with pytest.raises(ZeroDivisionError):
|
||||
_run(ids, batch_size=0, monkeypatch=monkeypatch)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Tests for the dummy-node cross-batch reference mechanism.
|
||||
|
||||
The batching design: when a batch references a target that lives in another
|
||||
batch, a *dummy* target node is created carrying the real target's id. On the
|
||||
node side dummies are skipped (``node_generator`` drops ``label == 'dummy'``),
|
||||
but the *edge* is still emitted with the real endpoint id. BioCypher appends
|
||||
node/edge CSV part files after every batch, and a single ``neo4j-admin
|
||||
import`` at the end stitches edges to nodes by id. So as long as the real
|
||||
target lands in *some* batch, the edge resolves at import time.
|
||||
|
||||
These tests model that by emitting nodes/edges per batch and accumulating the
|
||||
result, mimicking the appended CSVs that the final import consumes.
|
||||
"""
|
||||
|
||||
import networkx as nx
|
||||
|
||||
import import_fhir_to_nx_diGraph as pipeline
|
||||
|
||||
|
||||
def _emit_batch(graph):
|
||||
"""Emit one batch the way run_biocypher does: nodes first, then edges."""
|
||||
nodes = {nid: label for (nid, label, _props) in pipeline.node_generator(graph)}
|
||||
edges = [
|
||||
(eid, src, tgt, label)
|
||||
for (eid, src, tgt, label, _attrs) in pipeline.edge_generator(graph)
|
||||
]
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def _accumulate(batches):
|
||||
"""Union of all batches' CSV part files, as the final import would see."""
|
||||
all_nodes = {}
|
||||
all_edges = []
|
||||
for graph in batches:
|
||||
nodes, edges = _emit_batch(graph)
|
||||
all_nodes.update(nodes) # neo4j --skip-duplicate-nodes: union by id
|
||||
all_edges.extend(edges)
|
||||
return all_nodes, all_edges
|
||||
|
||||
|
||||
def _batch_with_dummy_target():
|
||||
"""Batch 1: real Observation O1 referencing P1, which is elsewhere.
|
||||
|
||||
``edge_label`` mirrors what ``process_references.py`` always sets on a
|
||||
real dummy node (the referenced resource type, e.g. "Patient").
|
||||
"""
|
||||
g = nx.DiGraph()
|
||||
g.add_node("obs1", label="observation", unique_id="O1")
|
||||
g.add_node("dummy_p1", label="dummy", unique_id="P1", edge_label="Patient")
|
||||
g.add_edge("obs1", "dummy_p1", id="e1")
|
||||
return g
|
||||
|
||||
|
||||
def _batch_with_real_target():
|
||||
"""Batch 2: the real Patient node P1 that the dummy stood in for."""
|
||||
g = nx.DiGraph()
|
||||
g.add_node("pat1", label="patient", unique_id="P1")
|
||||
return g
|
||||
|
||||
|
||||
def _unbatched_graph():
|
||||
"""Everything in one graph: the reference target is the real node."""
|
||||
g = nx.DiGraph()
|
||||
g.add_node("obs1", label="observation", unique_id="O1")
|
||||
g.add_node("pat1", label="patient", unique_id="P1")
|
||||
g.add_edge("obs1", "pat1", id="e1")
|
||||
return g
|
||||
|
||||
|
||||
def test_dummy_target_skipped_as_node_but_edge_survives():
|
||||
nodes, edges = _emit_batch(_batch_with_dummy_target())
|
||||
assert set(nodes) == {"O1"} # dummy P1 not written as a node
|
||||
assert len(edges) == 1
|
||||
_, src, tgt, _ = edges[0]
|
||||
assert (src, tgt) == ("O1", "P1") # edge keeps the real endpoint id
|
||||
|
||||
|
||||
def test_cross_batch_edge_endpoint_supplied_by_later_batch():
|
||||
nodes, edges = _accumulate(
|
||||
[_batch_with_dummy_target(), _batch_with_real_target()]
|
||||
)
|
||||
# Real P1 arrives in batch 2; dummy was skipped. Every edge endpoint now
|
||||
# corresponds to a real node, so the import connects them.
|
||||
assert set(nodes) == {"O1", "P1"}
|
||||
endpoints = {s for _, s, _, _ in edges} | {t for _, _, t, _ in edges}
|
||||
assert endpoints <= set(nodes)
|
||||
|
||||
|
||||
def test_batched_connectivity_matches_unbatched():
|
||||
batched_nodes, batched_edges = _accumulate(
|
||||
[_batch_with_dummy_target(), _batch_with_real_target()]
|
||||
)
|
||||
full_nodes, full_edges = _emit_batch(_unbatched_graph())
|
||||
|
||||
assert set(batched_nodes) == set(full_nodes)
|
||||
# Connectivity (endpoint pairs) is identical, ignoring relationship type.
|
||||
assert {(s, t) for _, s, t, _ in batched_edges} == {
|
||||
(s, t) for _, s, t, _ in full_edges
|
||||
}
|
||||
|
||||
|
||||
def test_within_batch_edge_has_correct_relationship_label():
|
||||
# When both endpoints are real in the same batch, the type is correct.
|
||||
(_, _, _, label, _), = pipeline.edge_generator(_unbatched_graph())
|
||||
assert label == "Observation_to_Patient"
|
||||
|
||||
|
||||
def test_batched_and_unbatched_produce_identical_edges():
|
||||
# The full equivalence check: batching must not change the OUTCOME, and
|
||||
# that includes relationship *types*, not just connectivity. We compare
|
||||
# the complete edge set (endpoints + relationship label) of a batched run
|
||||
# against the unbatched run. Since the dummy node carries the real
|
||||
# target's resource type via `edge_label`, the relationship label is
|
||||
# derived correctly even while the target is still a dummy, so no
|
||||
# cross-batch '..._to_Dummy' relationship should ever appear.
|
||||
_, batched_edges = _accumulate(
|
||||
[_batch_with_dummy_target(), _batch_with_real_target()]
|
||||
)
|
||||
_, full_edges = _emit_batch(_unbatched_graph())
|
||||
|
||||
def relationships(edges):
|
||||
return {(src, tgt, label) for _eid, src, tgt, label in edges}
|
||||
|
||||
# No dummy-typed relationship should be produced at all.
|
||||
assert not any(
|
||||
label.endswith("_to_Dummy") for _e, _s, _t, label in batched_edges
|
||||
)
|
||||
# And the batched edge set must match the unbatched one exactly.
|
||||
assert relationships(batched_edges) == relationships(full_edges)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Unit tests for ``generate_neo4j_import_script``.
|
||||
|
||||
It scans a directory of BioCypher CSV output, classifies each ``*-header.csv``
|
||||
as a node or relationship, and emits a ``neo4j-admin import`` shell script.
|
||||
We drive it with a temporary directory so no real ``/neo4j_import`` is needed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import stat
|
||||
|
||||
import import_fhir_to_nx_diGraph as pipeline
|
||||
|
||||
|
||||
def _touch(directory, name):
|
||||
(directory / name).write_text("")
|
||||
|
||||
|
||||
def _generate(tmp_path):
|
||||
ret = pipeline.generate_neo4j_import_script(
|
||||
directory_path=str(tmp_path) + os.sep,
|
||||
output_file="neo4j-admin-import-call.sh",
|
||||
)
|
||||
out = tmp_path / "neo4j-admin-import-call.sh"
|
||||
return out.read_text(), out, ret
|
||||
|
||||
|
||||
def test_node_with_parts_is_included_as_nodes(tmp_path):
|
||||
_touch(tmp_path, "Patient-header.csv")
|
||||
_touch(tmp_path, "Patient-part0.csv")
|
||||
script, _, _ = _generate(tmp_path)
|
||||
assert '--nodes="/neo4j_import/Patient-header.csv,/neo4j_import/Patient-part.*"' in script
|
||||
|
||||
|
||||
def test_association_is_classified_as_relationship(tmp_path):
|
||||
_touch(tmp_path, "PatientToObservationAssociation-header.csv")
|
||||
_touch(tmp_path, "PatientToObservationAssociation-part0.csv")
|
||||
script, _, _ = _generate(tmp_path)
|
||||
assert "--relationships=" in script
|
||||
assert "PatientToObservationAssociation" in script
|
||||
|
||||
|
||||
def test_has_pattern_is_classified_as_relationship(tmp_path):
|
||||
_touch(tmp_path, "Encounter_has_Diagnosis-header.csv")
|
||||
_touch(tmp_path, "Encounter_has_Diagnosis-part0.csv")
|
||||
script, _, _ = _generate(tmp_path)
|
||||
assert "--relationships=" in script
|
||||
assert "--nodes=" not in script
|
||||
|
||||
|
||||
def test_header_without_part_files_is_dropped(tmp_path):
|
||||
# SHARP EDGE: entities are only emitted when a matching *-part* file
|
||||
# exists. A lone header silently produces no import command. Pinning so
|
||||
# the behaviour is intentional, not a surprise empty graph.
|
||||
_touch(tmp_path, "Patient-header.csv") # no part file
|
||||
script, _, _ = _generate(tmp_path)
|
||||
assert "--nodes=" not in script
|
||||
assert "--relationships=" not in script
|
||||
|
||||
|
||||
def test_script_is_written_and_executable(tmp_path):
|
||||
_touch(tmp_path, "Patient-header.csv")
|
||||
_touch(tmp_path, "Patient-part0.csv")
|
||||
_, out, ret = _generate(tmp_path)
|
||||
assert out.exists()
|
||||
mode = os.stat(out).st_mode
|
||||
assert mode & stat.S_IXUSR # owner-executable
|
||||
# SHARP EDGE: the docstring promises it returns the script path, but the
|
||||
# function has no `return`, so callers actually get None. Pinning the
|
||||
# real behaviour; fix the function (or the docstring) and flip this.
|
||||
assert ret is None
|
||||
|
||||
|
||||
def test_script_contains_version_branching(tmp_path):
|
||||
_touch(tmp_path, "Patient-header.csv")
|
||||
_touch(tmp_path, "Patient-part0.csv")
|
||||
script, _, _ = _generate(tmp_path)
|
||||
assert "neo4j-admin database import full" in script # v5+ branch
|
||||
assert "neo4j-admin import --database=neo4j" in script # legacy branch
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Unit tests for ``node_generator`` and ``edge_generator``.
|
||||
|
||||
Both take a populated ``networkx.DiGraph`` and yield the tuples BioCypher
|
||||
expects. The label-normalisation logic (capitalize, ``resource`` ->
|
||||
``resourceType``, skipping dummy/search/meta/link) is the interesting part.
|
||||
"""
|
||||
|
||||
import networkx as nx
|
||||
import pytest
|
||||
|
||||
import import_fhir_to_nx_diGraph as pipeline
|
||||
|
||||
|
||||
def _emit(generator):
|
||||
return list(generator)
|
||||
|
||||
|
||||
# --- node_generator --------------------------------------------------------
|
||||
|
||||
def test_resource_node_uses_resource_type_as_label():
|
||||
g = nx.DiGraph()
|
||||
g.add_node("p1", label="resource", resourceType="Patient", unique_id="P-1")
|
||||
(node_id, label, props), = _emit(pipeline.node_generator(g))
|
||||
assert node_id == "P-1"
|
||||
assert label == "Patient"
|
||||
|
||||
|
||||
def test_label_is_capitalized():
|
||||
g = nx.DiGraph()
|
||||
g.add_node("o1", label="observation")
|
||||
(_, label, _), = _emit(pipeline.node_generator(g))
|
||||
assert label == "Observation"
|
||||
|
||||
|
||||
def test_falls_back_to_node_key_without_unique_id():
|
||||
g = nx.DiGraph()
|
||||
g.add_node("node-key", label="observation")
|
||||
(node_id, _, _), = _emit(pipeline.node_generator(g))
|
||||
assert node_id == "node-key"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label", ["dummy", "Dummy"])
|
||||
def test_dummy_nodes_are_skipped(label):
|
||||
g = nx.DiGraph()
|
||||
g.add_node("d", label=label)
|
||||
assert _emit(pipeline.node_generator(g)) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label", ["search", "meta", "link"])
|
||||
def test_metadata_nodes_are_skipped(label):
|
||||
g = nx.DiGraph()
|
||||
g.add_node("m", label=label)
|
||||
assert _emit(pipeline.node_generator(g)) == []
|
||||
|
||||
|
||||
def test_generator_mutates_graph_label_in_place():
|
||||
# Documented side effect: the generator rewrites each node's 'label' to
|
||||
# its normalised form. Worth pinning because anything that iterates the
|
||||
# graph afterwards sees the mutated value.
|
||||
g = nx.DiGraph()
|
||||
g.add_node("o1", label="observation")
|
||||
_emit(pipeline.node_generator(g))
|
||||
assert g.nodes["o1"]["label"] == "Observation"
|
||||
|
||||
|
||||
def test_node_without_label_raises():
|
||||
# KNOWN SHARP EDGE: a node missing both 'label' and 'resourceType' makes
|
||||
# label None, and None.capitalize() raises. Pinning it as expected
|
||||
# behaviour; flip this test if you decide such nodes should be skipped.
|
||||
g = nx.DiGraph()
|
||||
g.add_node("orphan")
|
||||
with pytest.raises(AttributeError):
|
||||
_emit(pipeline.node_generator(g))
|
||||
|
||||
|
||||
# --- edge_generator --------------------------------------------------------
|
||||
|
||||
def test_edge_label_combines_endpoint_labels():
|
||||
g = nx.DiGraph()
|
||||
g.add_node("a", label="patient")
|
||||
g.add_node("b", label="observation")
|
||||
g.add_edge("a", "b", id="e1")
|
||||
(edge_id, source, target, label, _), = _emit(pipeline.edge_generator(g))
|
||||
assert (edge_id, source, target, label) == (
|
||||
"e1", "a", "b", "Patient_to_Observation",
|
||||
)
|
||||
|
||||
|
||||
def test_edge_resource_endpoints_use_resource_type():
|
||||
g = nx.DiGraph()
|
||||
g.add_node("a", label="resource", resourceType="Patient")
|
||||
g.add_node("b", label="resource", resourceType="Encounter")
|
||||
g.add_edge("a", "b", id="e1")
|
||||
(_, _, _, label, _), = _emit(pipeline.edge_generator(g))
|
||||
assert label == "Patient_to_Encounter"
|
||||
|
||||
|
||||
def test_edge_without_id_gets_generated_uuid():
|
||||
g = nx.DiGraph()
|
||||
g.add_node("a", label="patient")
|
||||
g.add_node("b", label="observation")
|
||||
g.add_edge("a", "b")
|
||||
(edge_id, *_), = _emit(pipeline.edge_generator(g))
|
||||
assert isinstance(edge_id, str) and len(edge_id) == 36 # uuid4 string
|
||||
|
||||
|
||||
def test_edge_uses_unique_id_for_endpoints_when_present():
|
||||
g = nx.DiGraph()
|
||||
g.add_node("a", label="patient", unique_id="P-1")
|
||||
g.add_node("b", label="observation", unique_id="O-1")
|
||||
g.add_edge("a", "b", id="e1")
|
||||
(_, source, target, _, _), = _emit(pipeline.edge_generator(g))
|
||||
assert (source, target) == ("P-1", "O-1")
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Unit tests for ``graphCreation.create_graph``.
|
||||
|
||||
This module turns a JSON bundle into a networkx DiGraph:
|
||||
- a dict value becomes a child node ``{parent}.{key}`` (label = key) with an
|
||||
edge carrying ``edge_type = key``;
|
||||
- a list with no dicts is stored as a plain attribute on the parent;
|
||||
- a list containing dicts becomes one child node per dict (indexed when the
|
||||
list has more than one element);
|
||||
- any other scalar becomes an attribute on the parent.
|
||||
|
||||
Pure logic, only networkx -- no stubs or mocks needed. These tests import the
|
||||
real module directly (conftest prefers the real package when present).
|
||||
"""
|
||||
|
||||
import networkx as nx
|
||||
import pytest
|
||||
|
||||
from graphCreation.create_graph import (
|
||||
add_json_to_networkx,
|
||||
add_nodes_from_dict,
|
||||
process_dictionaries,
|
||||
)
|
||||
|
||||
|
||||
def _graph_from(json_data, name="root"):
|
||||
g = nx.DiGraph()
|
||||
add_json_to_networkx(json_data, name, g)
|
||||
return g
|
||||
|
||||
|
||||
# --- entry point / guards --------------------------------------------------
|
||||
|
||||
def test_rejects_non_digraph():
|
||||
with pytest.raises(ValueError):
|
||||
add_json_to_networkx({}, "root", nx.Graph()) # undirected, not DiGraph
|
||||
|
||||
|
||||
def test_root_node_created_with_root_label():
|
||||
g = _graph_from({})
|
||||
assert g.nodes["root_bundle"]["label"] == "root"
|
||||
|
||||
|
||||
def test_bundle_suffix_is_always_appended():
|
||||
# SHARP EDGE: the function appends '_bundle' to whatever name it's given.
|
||||
# bundles_to_graph already passes ``id + '_bundle'``, so the in-graph root
|
||||
# ends up doubly suffixed (e.g. 'patient1_bundle_bundle'). Harmless while
|
||||
# consistent, but surprising if anything looks the root up by id.
|
||||
g = _graph_from({}, name="patient1_bundle")
|
||||
assert "patient1_bundle_bundle" in g.nodes
|
||||
|
||||
|
||||
# --- scalars ---------------------------------------------------------------
|
||||
|
||||
def test_scalar_values_become_parent_attributes():
|
||||
g = _graph_from({"id": "123", "active": True, "count": 5})
|
||||
attrs = g.nodes["root_bundle"]
|
||||
assert attrs["id"] == "123"
|
||||
assert attrs["active"] is True
|
||||
assert attrs["count"] == 5
|
||||
|
||||
|
||||
# --- nested dicts ----------------------------------------------------------
|
||||
|
||||
def test_nested_dict_becomes_child_node_with_edge():
|
||||
g = _graph_from({"name": {"family": "Mueller", "given": "Anna"}})
|
||||
assert "root_bundle.name" in g.nodes
|
||||
assert g.nodes["root_bundle.name"]["label"] == "name"
|
||||
assert g.nodes["root_bundle.name"]["family"] == "Mueller"
|
||||
assert g.edges["root_bundle", "root_bundle.name"]["edge_type"] == "name"
|
||||
|
||||
|
||||
def test_deeply_nested_dicts_chain_through_nodes():
|
||||
g = _graph_from({"a": {"b": {"c": 1}}})
|
||||
assert {"root_bundle", "root_bundle.a", "root_bundle.a.b"} <= set(g.nodes)
|
||||
assert g.nodes["root_bundle.a.b"]["c"] == 1
|
||||
assert g.has_edge("root_bundle.a", "root_bundle.a.b")
|
||||
|
||||
|
||||
# --- lists -----------------------------------------------------------------
|
||||
|
||||
def test_list_of_scalars_stored_as_attribute():
|
||||
g = _graph_from({"tags": ["a", "b", "c"]})
|
||||
assert g.nodes["root_bundle"]["tags"] == ["a", "b", "c"]
|
||||
assert list(g.nodes) == ["root_bundle"] # no child nodes created
|
||||
|
||||
|
||||
def test_empty_list_stored_as_attribute():
|
||||
g = _graph_from({"items": []})
|
||||
assert g.nodes["root_bundle"]["items"] == []
|
||||
|
||||
|
||||
def test_single_dict_list_has_no_index_suffix():
|
||||
g = _graph_from({"contact": [{"phone": "555"}]})
|
||||
assert "root_bundle.contact" in g.nodes
|
||||
assert "root_bundle.contact[0]" not in g.nodes
|
||||
assert g.nodes["root_bundle.contact"]["phone"] == "555"
|
||||
|
||||
|
||||
def test_multi_dict_list_creates_indexed_nodes():
|
||||
g = _graph_from({"entries": [{"x": 1}, {"y": 2}]})
|
||||
assert "root_bundle.entries[0]" in g.nodes
|
||||
assert "root_bundle.entries[1]" in g.nodes
|
||||
assert g.nodes["root_bundle.entries[0]"]["x"] == 1
|
||||
assert g.nodes["root_bundle.entries[1]"]["y"] == 2
|
||||
|
||||
|
||||
def test_mixed_list_silently_drops_non_dict_items():
|
||||
# FINDING: when a list contains at least one dict, process_dictionaries
|
||||
# runs and *skips* every non-dict element -- it is neither stored as an
|
||||
# attribute nor turned into a node. The scalar "lost" below disappears.
|
||||
# Pinned as current behaviour; flip if such data should be retained.
|
||||
g = _graph_from({"mixed": ["lost", {"k": "v"}]})
|
||||
assert "root_bundle.mixed[1]" in g.nodes # the dict survives (indexed)
|
||||
assert g.nodes["root_bundle.mixed[1]"]["k"] == "v"
|
||||
assert "mixed" not in g.nodes["root_bundle"] # not stored as an attribute
|
||||
# The scalar appears nowhere in the graph.
|
||||
all_attr_values = [v for _, data in g.nodes(data=True) for v in data.values()]
|
||||
assert "lost" not in all_attr_values
|
||||
|
||||
|
||||
# --- helpers used directly -------------------------------------------------
|
||||
|
||||
def test_add_nodes_from_dict_attaches_to_given_parent():
|
||||
g = nx.DiGraph()
|
||||
g.add_node("p", label="root")
|
||||
add_nodes_from_dict(g, "p", {"status": "final"})
|
||||
assert g.nodes["p"]["status"] == "final"
|
||||
|
||||
|
||||
def test_process_dictionaries_skips_when_no_dicts_present():
|
||||
# Defensive: passing a dict-free list straight to process_dictionaries
|
||||
# creates nothing (the inner isinstance check is never satisfied).
|
||||
g = nx.DiGraph()
|
||||
g.add_node("p", label="root")
|
||||
process_dictionaries(["a", "b"], "p", "vals", g)
|
||||
assert list(g.nodes) == ["p"]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Unit tests for ``load_multiple_fhir_patients``.
|
||||
|
||||
This is the meatiest piece of logic in the FHIR path: it pulls patient IDs
|
||||
from a file *or* the FHIR server, handles pagination via the bundle's
|
||||
``next`` link, honours TEST_MODE/TEST_DEPTH, and bails on an empty bundle.
|
||||
The live FHIR client (``get_bundle``) is mocked, so no server is needed.
|
||||
|
||||
Note: the module reads TEST_MODE / TEST_DEPTH into module globals at import
|
||||
time, so we override the globals (``pipeline.is_test`` / ``pipeline.test_depth``)
|
||||
directly rather than via env vars. ``bundles_to_graph`` is stubbed so these
|
||||
tests isolate the ID-collection logic.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
import import_fhir_to_nx_diGraph as pipeline
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
"""Stand-in for the requests.Response that get_bundle returns."""
|
||||
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
def _bundle(ids, next_url=None):
|
||||
payload = {
|
||||
"entry": [{"resource": {"id": i}} for i in ids],
|
||||
"link": [],
|
||||
}
|
||||
if next_url:
|
||||
payload["link"].append({"relation": "next", "url": next_url})
|
||||
return FakeResponse(payload)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def captured(monkeypatch):
|
||||
"""Isolate the function: capture get_bundle calls and the final hand-off."""
|
||||
calls = {"get_bundle": [], "bundles_to_graph": []}
|
||||
|
||||
def fake_get_bundle(link, query):
|
||||
calls["get_bundle"].append((link, query))
|
||||
return calls["get_bundle_returns"].pop(0)
|
||||
|
||||
def fake_bundles_to_graph(ids, n):
|
||||
calls["bundles_to_graph"].append((list(ids), n))
|
||||
|
||||
monkeypatch.setattr(pipeline, "get_bundle", fake_get_bundle)
|
||||
monkeypatch.setattr(pipeline, "bundles_to_graph", fake_bundles_to_graph)
|
||||
# Default: not in test mode (override per-test as needed).
|
||||
monkeypatch.setattr(pipeline, "is_test", None)
|
||||
monkeypatch.setattr(pipeline, "test_depth", None)
|
||||
# Clear env that influences query selection / file loading.
|
||||
for var in ("COMPLEX_PATIENTS", "SELECTED_PATIENTS_FILE"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
return calls
|
||||
|
||||
|
||||
def test_loads_ids_from_file_without_calling_server(captured, tmp_path, monkeypatch):
|
||||
id_file = tmp_path / "patients.txt"
|
||||
id_file.write_text("p1\np2\n\np3\n") # blank line is skipped
|
||||
monkeypatch.setenv("SELECTED_PATIENTS_FILE", str(id_file))
|
||||
|
||||
pipeline.load_multiple_fhir_patients(10)
|
||||
|
||||
assert captured["get_bundle"] == [] # server never touched
|
||||
ids, _ = captured["bundles_to_graph"][0]
|
||||
assert ids == ["p1", "p2", "p3"]
|
||||
|
||||
|
||||
def test_complex_disabled_uses_plain_count_query(captured, monkeypatch):
|
||||
monkeypatch.setenv("COMPLEX_PATIENTS", "FALSE")
|
||||
captured["get_bundle_returns"] = [_bundle(["a", "b"])]
|
||||
|
||||
pipeline.load_multiple_fhir_patients(2)
|
||||
|
||||
_, query = captured["get_bundle"][0]
|
||||
assert query == "/Patient?_count=2"
|
||||
|
||||
|
||||
def test_default_uses_complex_observation_query(captured):
|
||||
captured["get_bundle_returns"] = [_bundle(["a", "b"])]
|
||||
|
||||
pipeline.load_multiple_fhir_patients(2)
|
||||
|
||||
_, query = captured["get_bundle"][0]
|
||||
assert "_has:Observation:subject:status=final" in query
|
||||
|
||||
|
||||
def test_follows_next_link_until_enough_ids(captured):
|
||||
captured["get_bundle_returns"] = [
|
||||
_bundle(["a", "b"], next_url="http://fhir/next"),
|
||||
_bundle(["c", "d"]),
|
||||
]
|
||||
|
||||
pipeline.load_multiple_fhir_patients(3)
|
||||
|
||||
# Two pages fetched; second call used the next link, no query.
|
||||
assert len(captured["get_bundle"]) == 2
|
||||
assert captured["get_bundle"][1] == ("http://fhir/next", None)
|
||||
ids, _ = captured["bundles_to_graph"][0]
|
||||
assert ids == ["a", "b", "c", "d"]
|
||||
|
||||
|
||||
def test_empty_bundle_exits_with_error(captured):
|
||||
captured["get_bundle_returns"] = [FakeResponse({"link": []})] # no 'entry'
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
pipeline.load_multiple_fhir_patients(1)
|
||||
assert exc.value.code == 1
|
||||
assert captured["bundles_to_graph"] == [] # never reached graph build
|
||||
|
||||
|
||||
def test_test_depth_one_exits_before_building_graph(captured, monkeypatch):
|
||||
monkeypatch.setattr(pipeline, "is_test", "1")
|
||||
monkeypatch.setattr(pipeline, "test_depth", "1")
|
||||
captured["get_bundle_returns"] = [_bundle(["a", "b"])]
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
pipeline.load_multiple_fhir_patients(2)
|
||||
assert exc.value.code == 0
|
||||
assert captured["bundles_to_graph"] == [] # short-circuited
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Orchestration test for ``multiple_adapters.main``.
|
||||
|
||||
``main`` is thin glue, so we mock every collaborator and assert the wiring:
|
||||
which adapter runs, the schema/script calls, and the sentinel file write.
|
||||
|
||||
LIMITATION: ``adapter_mode`` is a hardcoded local (= 0), so only the FHIR
|
||||
branch is reachable. The mode-1 (XML) and mode--1 (both) tests below are
|
||||
written but skipped — un-skip them once ``main`` accepts ``adapter_mode`` as
|
||||
a parameter or reads it from the environment. They document the intended
|
||||
behaviour and become live the moment the refactor lands.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
import multiple_adapters
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mocked(monkeypatch):
|
||||
m = mock.Mock()
|
||||
monkeypatch.setattr(multiple_adapters, "BioCypher", m.BioCypher)
|
||||
monkeypatch.setattr(multiple_adapters, "write_automated_schema", m.write_automated_schema)
|
||||
monkeypatch.setattr(multiple_adapters, "load_multiple_fhir_patients", m.load_fhir)
|
||||
monkeypatch.setattr(multiple_adapters, "generate_neo4j_import_script", m.gen_script)
|
||||
monkeypatch.setattr(multiple_adapters, "xp", m.xp)
|
||||
monkeypatch.setenv("NUMBER_OF_PATIENTS", "5")
|
||||
return m
|
||||
|
||||
|
||||
def test_mode_zero_runs_fhir_loader_and_writes_sentinel(mocked):
|
||||
open_mock = mock.mock_open()
|
||||
with mock.patch("builtins.open", open_mock):
|
||||
multiple_adapters.main()
|
||||
|
||||
mocked.load_fhir.assert_called_once_with(5)
|
||||
mocked.xp.parse_xml_generate_nodes.assert_not_called() # XML branch off
|
||||
mocked.write_automated_schema.assert_called_once()
|
||||
mocked.gen_script.assert_called_once()
|
||||
open_mock.assert_called_once_with("/neo4j_import/shell-scipt-complete", "w")
|
||||
|
||||
|
||||
def test_number_of_patients_unset_raises(mocked, monkeypatch):
|
||||
monkeypatch.delenv("NUMBER_OF_PATIENTS", raising=False)
|
||||
with mock.patch("builtins.open", mock.mock_open()):
|
||||
with pytest.raises(TypeError): # int(None)
|
||||
multiple_adapters.main()
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="adapter_mode is hardcoded to 0; un-skip after making it configurable")
|
||||
def test_mode_one_runs_xml_adapter(mocked):
|
||||
open_mock = mock.mock_open()
|
||||
with mock.patch("builtins.open", open_mock):
|
||||
multiple_adapters.main() # would need adapter_mode == 1
|
||||
mocked.xp.parse_xml_generate_nodes.assert_called_once()
|
||||
mocked.xp.parse_xml_generate_edges.assert_called_once()
|
||||
mocked.load_fhir.assert_not_called()
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Unit tests for ``replace_single_quotes``.
|
||||
|
||||
This is the cleanest function to test: pure, recursive, no I/O. It escapes
|
||||
single quotes (``'`` -> ``''``) for the Neo4j import, recursing through
|
||||
dicts and lists and leaving non-strings untouched.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, strategies as st
|
||||
|
||||
import import_fhir_to_nx_diGraph as pipeline
|
||||
|
||||
|
||||
def test_single_quote_in_string_is_doubled():
|
||||
assert pipeline.replace_single_quotes("O'Brien") == "O''Brien"
|
||||
|
||||
|
||||
def test_string_without_quotes_unchanged():
|
||||
assert pipeline.replace_single_quotes("Mueller") == "Mueller"
|
||||
|
||||
|
||||
def test_empty_string_unchanged():
|
||||
assert pipeline.replace_single_quotes("") == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [0, 1, 3.14, None, True, False])
|
||||
def test_non_string_scalars_unchanged(value):
|
||||
assert pipeline.replace_single_quotes(value) is value
|
||||
|
||||
|
||||
def test_nested_dict_recurses():
|
||||
data = {"name": "O'Hara", "nested": {"note": "it's fine"}}
|
||||
assert pipeline.replace_single_quotes(data) == {
|
||||
"name": "O''Hara",
|
||||
"nested": {"note": "it''s fine"},
|
||||
}
|
||||
|
||||
|
||||
def test_list_recurses():
|
||||
assert pipeline.replace_single_quotes(["a'b", 2, "c"]) == ["a''b", 2, "c"]
|
||||
|
||||
|
||||
def test_dict_keys_are_not_escaped():
|
||||
# Only values are recursed into; keys are left as-is.
|
||||
result = pipeline.replace_single_quotes({"it's": "fine"})
|
||||
assert list(result.keys()) == ["it's"]
|
||||
assert result["it's"] == "fine"
|
||||
|
||||
|
||||
# --- Property-based checks -------------------------------------------------
|
||||
|
||||
@given(st.text())
|
||||
def test_every_quote_is_doubled(text):
|
||||
# The function is intentionally NOT idempotent: each pass doubles the
|
||||
# quote count. This pins that contract so a future "fix" is a conscious
|
||||
# choice rather than a silent behaviour change.
|
||||
result = pipeline.replace_single_quotes(text)
|
||||
assert result.count("'") == 2 * text.count("'")
|
||||
|
||||
|
||||
@given(st.lists(st.one_of(st.integers(), st.text())))
|
||||
def test_list_length_preserved(items):
|
||||
assert len(pipeline.replace_single_quotes(items)) == len(items)
|
||||
Reference in New Issue
Block a user