jdmagent / tests /test_client.py
expAge
feat(phase-9): semantic enrichment — polarity + annotations
a26b99c
"""Tests du JDMClient avec mock httpx via respx."""
from __future__ import annotations
import httpx
import pytest
import respx
from jdm_agent.client import JDMClient
from jdm_agent.client.cache import DiskJSONCache
BASE = "https://jdm-api.demo.lirmm.fr"
REL_TYPES = [
{"id": 5, "name": "r_syn", "help": "synonymes"},
{"id": 6, "name": "r_isa", "help": "hyperonymes"},
{"id": 0, "name": "r_associated", "help": "associations"},
]
NODE_TYPES = [
{"id": 1, "name": "n_generic", "help": "terme"},
{"id": 4, "name": "n_pos", "help": "partie du discours"},
]
NODE_CHAT = {
"id": 150, "name": "chat", "type": 1, "w": 7967,
"c": 0, "level": 87.0, "infoid": None,
"creationdate": "2007-06-21", "touchdate": "2026-05-20",
}
SYN_RESPONSE = {
"nodes": [
{"id": 150, "name": "chat", "type": 1, "w": 7967},
{"id": 999, "name": "matou", "type": 1, "w": 100},
{"id": 888, "name": "minet", "type": 1, "w": 80},
],
"relations": [
{"id": 1, "node1": 150, "node2": 999, "type": 5, "w": 50.0},
{"id": 2, "node1": 150, "node2": 888, "type": 5, "w": 30.0},
],
}
@pytest.fixture
def client(tmp_path):
cache = DiskJSONCache(cache_dir=tmp_path / "cache")
return JDMClient(base_url=BASE, cache=cache)
@respx.mock
def test_ensure_meta_lazy_and_cached(client):
rels = respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
nodes = respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
# Premier appel : déclenche le chargement.
assert client.relation_type_id("r_syn") == 5
# Deuxième appel : cache en mémoire (et disque).
assert client.relation_type_name(6) == "r_isa"
assert rels.call_count == 1
assert nodes.call_count == 1
@respx.mock
def test_node_by_name(client):
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
route = respx.get(f"{BASE}/v0/node_by_name/chat").mock(return_value=httpx.Response(200, json=NODE_CHAT))
n = client.node_by_name("chat")
assert n.id == 150
assert n.name == "chat"
# Cache disque actif : 2e appel sans HTTP supplémentaire.
n2 = client.node_by_name("chat")
assert n2.id == 150
assert route.call_count == 1
@respx.mock
def test_synonyms_helper(client):
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
respx.get(f"{BASE}/v0/relations/from/chat").mock(return_value=httpx.Response(200, json=SYN_RESPONSE))
syns = client.synonyms("chat", min_weight=0, limit=10)
names = [n.name for n in syns]
assert "matou" in names and "minet" in names
# Trié par w décroissant.
assert syns[0].w >= syns[-1].w
@respx.mock
def test_retry_on_5xx(client):
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
route = respx.get(f"{BASE}/v0/node_by_name/test").mock(
side_effect=[
httpx.Response(503),
httpx.Response(200, json={**NODE_CHAT, "name": "test"}),
]
)
n = client.node_by_name("test")
assert n.name == "test"
assert route.call_count == 2
@respx.mock
def test_get_annotations_for_triplet(client):
"""Phase 9 : récupération des annotations via :r{id}.
Vérifie que :
- on appelle bien /v0/relations/from/:r{id} sans types_ids (bug JDM)
- on filtre les targets sur node.type==1 (skip r_pos type 4)
- on mappe les types 996/997/998 vers les bons 'kind'
"""
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
# Le node :r12345 a 3 outgoing :
# type=4 (r_pos) "Relation:" type=4 → à ignorer
# type=998 (r_annotation) "contrastif" type=1 → conservé
# type=996 (r_annotation_context) "constitutif" type=1 → conservé
respx.get(f"{BASE}/v0/relations/from/:r12345").mock(return_value=httpx.Response(200, json={
"nodes": [
{"id": 999, "name": ":r12345", "type": 10, "w": 0},
{"id": 100, "name": "Relation:", "type": 4, "w": 100},
{"id": 101, "name": "contrastif", "type": 1, "w": 200},
{"id": 102, "name": "constitutif", "type": 1, "w": 80},
],
"relations": [
{"id": 1, "node1": 999, "node2": 100, "type": 4, "w": 100}, # r_pos - skip
{"id": 2, "node1": 999, "node2": 101, "type": 998, "w": 200},
{"id": 3, "node1": 999, "node2": 102, "type": 996, "w": 80},
],
}))
annots = client.get_annotations_for_triplet(12345)
assert len(annots) == 2
# Tri par |w| desc : contrastif (200) puis constitutif (80)
assert annots[0].kind == "annotation"
assert annots[0].value == "contrastif"
assert annots[0].w == 200
assert annots[1].kind == "context"
assert annots[1].value == "constitutif"
@respx.mock
def test_get_annotations_returns_empty_on_500(client):
"""Le pattern :r{id} 500-fail gracieusement si le triplet n'est pas annoté."""
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
respx.get(f"{BASE}/v0/relations/from/:r999999").mock(return_value=httpx.Response(500, json={}))
annots = client.get_annotations_for_triplet(999999)
assert annots == []
@respx.mock
def test_refinements_decoded(client):
"""Décode `avocat>116477>66699` → "avocat (personne, juriste)" via lookups node_by_id."""
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
respx.get(f"{BASE}/v0/refinements/avocat").mock(return_value=httpx.Response(200, json={
"nodes": [
{"id": 1, "name": "avocat", "type": 1, "w": 100},
{"id": 116477, "name": "personne", "type": 1, "w": 1000},
{"id": 87286, "name": "fruit", "type": 1, "w": 50},
],
"refinements": [
{"id": 100, "name": "avocat>116477>66699", "type": 1, "w": 356.0},
{"id": 101, "name": "avocat>87286", "type": 1, "w": 98.0},
],
}))
# 66699 = juriste — pas dans nodes locales → un lookup HTTP supplémentaire.
respx.get(f"{BASE}/v0/node_by_id/66699").mock(return_value=httpx.Response(200, json={
"id": 66699, "name": "juriste", "type": 1, "w": 911,
}))
decoded = client.refinements_decoded("avocat")
by_name = {d.name: d for d in decoded}
assert "avocat (personne, juriste)" == by_name["avocat>116477>66699"].decoded
assert by_name["avocat>116477>66699"].path == ["avocat", "personne", "juriste"]
assert by_name["avocat>116477>66699"].path_ids == [116477, 66699]
assert "avocat (fruit)" == by_name["avocat>87286"].decoded
@respx.mock
def test_relations_between_with_filters(client):
respx.get(f"{BASE}/v0/relations_types").mock(return_value=httpx.Response(200, json=REL_TYPES))
respx.get(f"{BASE}/v0/nodes_types").mock(return_value=httpx.Response(200, json=NODE_TYPES))
route = respx.get(f"{BASE}/v0/relations/from/chat/to/internet").mock(
return_value=httpx.Response(200, json={"nodes": [], "relations": []})
)
client.relations_between("chat", "internet", types_ids=[5, 6], min_weight=10, limit=5)
assert route.called
qs = dict(route.calls.last.request.url.params)
assert qs.get("types_ids") == "5,6"
assert qs.get("min_weight") == "10"
assert qs.get("limit") == "5"