rahim237cm Claude Opus 5 commited on
Commit
0e1604f
·
1 Parent(s): 51a64ba

Etape 14 : tests du code ajoute (54 nouveaux tests)

Browse files

Le code ecrit pendant ce chantier n'avait aucun test.

tests/test_api/test_auth_routes.py (16) — connexion JSON et formulaire, mot de
passe faux, email inconnu avec message IDENTIQUE (verifie explicitement : les
distinguer permettrait d'enumerer les comptes), last_login_at, /auth/me avec et
sans jeton, jeton malforme, matrice d'autorisation 401/403/200.
Un test parcourt les routes montees et echoue si un chemin sous /admin/ ou
/ocr/ n'a aucune dependance d'authentification : c'est lui qui rattrapera le
prochain endpoint ajoute sans garde.

tests/test_services/test_llama_parse_service.py (18) — respx intercepte httpx,
donc aucun appel reseau et aucun credit consomme. Couvre le nettoyage des
cachets (bloc multiligne, cachet condense sur une ligne, en-tete legitime
CONSERVE, filigrane, tableaux preserves, <sup> retire), le cache sha256
(verifie qu'un second appel ne repart pas sur le reseau), l'echec de job,
l'absence de retry sur 401, et les quatre formes de reponse de l'API v2.

tests/test_utils/test_chunk_refiner.py (20) — les 7 regles. Notamment :
l'en-tete de contexte est dans embed_text et jamais dans content ; PARA_n n'est
pas annonce comme un numero d'article (sinon le chatbot citerait des references
inexistantes) ; une suite de page n'est pas confondue avec des visas ; 20
personnes donnent 1 vecteur et non 20, avec appariement nom/matricule verifie ;
un tableau n'est jamais coupe ; et rien n'est jamais supprime, seulement
hierarchise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

tests/test_api/test_auth_routes.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests des routes d'authentification.
3
+
4
+ Aucun test ne couvrait l'authentification : elle n'existait pas (les endpoints
5
+ d'administration passaient par des stubs renvoyant un dictionnaire en dur).
6
+ """
7
+
8
+ import pytest
9
+ from httpx import AsyncClient
10
+
11
+ PASSWORD = "MotDePasseTest123"
12
+
13
+
14
+ class TestLogin:
15
+ """Connexion."""
16
+
17
+ @pytest.mark.asyncio
18
+ async def test_login_json_succeeds(self, client: AsyncClient, test_admin_user):
19
+ r = await client.post(
20
+ "/api/v1/auth/login/json",
21
+ json={"email": test_admin_user.email, "password": PASSWORD},
22
+ )
23
+ assert r.status_code == 200
24
+ body = r.json()
25
+ assert body["access_token"]
26
+ assert body["token_type"] == "bearer"
27
+ assert body["role"] == "admin"
28
+
29
+ @pytest.mark.asyncio
30
+ async def test_login_form_succeeds(self, client: AsyncClient, test_admin_user):
31
+ """Le formulaire OAuth2 alimente le bouton Authorize de /docs."""
32
+ r = await client.post(
33
+ "/api/v1/auth/login",
34
+ data={"username": test_admin_user.email, "password": PASSWORD},
35
+ )
36
+ assert r.status_code == 200
37
+ assert r.json()["access_token"]
38
+
39
+ @pytest.mark.asyncio
40
+ async def test_wrong_password_is_rejected(self, client: AsyncClient, test_user):
41
+ r = await client.post(
42
+ "/api/v1/auth/login/json",
43
+ json={"email": test_user.email, "password": "mauvais"},
44
+ )
45
+ assert r.status_code == 401
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_unknown_email_gives_same_message(
49
+ self, client: AsyncClient, test_user
50
+ ):
51
+ """
52
+ Message identique pour email inconnu et mot de passe faux.
53
+
54
+ Les distinguer permettrait d'énumérer les comptes existants.
55
+ """
56
+ wrong_pw = await client.post(
57
+ "/api/v1/auth/login/json",
58
+ json={"email": test_user.email, "password": "mauvais"},
59
+ )
60
+ unknown = await client.post(
61
+ "/api/v1/auth/login/json",
62
+ json={"email": "personne@nulle-part.cm", "password": "mauvais"},
63
+ )
64
+ assert wrong_pw.status_code == unknown.status_code == 401
65
+ assert wrong_pw.json()["detail"] == unknown.json()["detail"]
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_last_login_is_recorded(
69
+ self, client: AsyncClient, test_admin_user, db_session
70
+ ):
71
+ assert test_admin_user.last_login_at is None
72
+ await client.post(
73
+ "/api/v1/auth/login/json",
74
+ json={"email": test_admin_user.email, "password": PASSWORD},
75
+ )
76
+ await db_session.refresh(test_admin_user)
77
+ assert test_admin_user.last_login_at is not None
78
+
79
+
80
+ class TestMe:
81
+ """Identité associée au jeton."""
82
+
83
+ @pytest.mark.asyncio
84
+ async def test_me_requires_a_token(self, client: AsyncClient):
85
+ assert (await client.get("/api/v1/auth/me")).status_code == 401
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_me_rejects_a_bogus_token(self, client: AsyncClient):
89
+ r = await client.get(
90
+ "/api/v1/auth/me", headers={"Authorization": "Bearer pas-un-jeton"}
91
+ )
92
+ assert r.status_code == 401
93
+
94
+ @pytest.mark.asyncio
95
+ async def test_me_returns_the_account(
96
+ self, client: AsyncClient, test_user, auth_headers
97
+ ):
98
+ r = await client.get("/api/v1/auth/me", headers=auth_headers)
99
+ assert r.status_code == 200
100
+ assert r.json()["email"] == test_user.email
101
+
102
+
103
+ class TestAuthorization:
104
+ """Contrôle d'accès par rôle."""
105
+
106
+ # (méthode, chemin) — endpoints qui ne doivent jamais être publics
107
+ PROTECTED = [
108
+ ("get", "/api/v1/admin/users"),
109
+ ("post", "/api/v1/search/reindex"),
110
+ ("get", "/api/v1/search/stats"),
111
+ ("post", "/api/v1/upload/cleanup"),
112
+ ("get", "/api/v1/batch-upload/status"),
113
+ ]
114
+
115
+ @pytest.mark.asyncio
116
+ @pytest.mark.parametrize("method,path", PROTECTED)
117
+ async def test_anonymous_access_is_refused(self, client: AsyncClient, method, path):
118
+ r = await getattr(client, method)(path)
119
+ assert r.status_code == 401, f"{path} accessible sans jeton"
120
+
121
+ @pytest.mark.asyncio
122
+ async def test_plain_user_cannot_reach_admin(
123
+ self, client: AsyncClient, auth_headers
124
+ ):
125
+ """Un jeton valide mais sans privilège doit donner 403, pas 200."""
126
+ r = await client.get("/api/v1/admin/users", headers=auth_headers)
127
+ assert r.status_code == 403
128
+
129
+ @pytest.mark.asyncio
130
+ async def test_admin_reaches_admin(self, client: AsyncClient, admin_headers):
131
+ r = await client.get("/api/v1/admin/users", headers=admin_headers)
132
+ assert r.status_code == 200
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_no_protected_route_lacks_a_guard(self):
136
+ """
137
+ Filet de sécurité : parcourt les routes montées et échoue si l'une des
138
+ préfixes protégés n'a aucune dépendance d'authentification.
139
+
140
+ C'est ce test qui rattrapera le prochain endpoint ajouté sans garde.
141
+ """
142
+ from fastapi.routing import APIRoute
143
+
144
+ from app.main import app
145
+
146
+ prefixes = ("/api/v1/admin/", "/api/v1/ocr/")
147
+ exempt = {"/api/v1/admin/health"}
148
+
149
+ unguarded = []
150
+ for route in _iter_routes(app):
151
+ if not isinstance(route, APIRoute):
152
+ continue
153
+ if route.path in exempt or not route.path.startswith(prefixes):
154
+ continue
155
+ names = {d.name for d in route.dependant.dependencies}
156
+ if not any("current" in (n or "") for n in names):
157
+ unguarded.append(f"{sorted(route.methods)} {route.path}")
158
+
159
+ assert not unguarded, f"routes sans garde : {unguarded}"
160
+
161
+
162
+ def _iter_routes(app):
163
+ """Parcourt les routes, y compris celles imbriquées dans les routeurs inclus."""
164
+ stack = list(app.routes)
165
+ while stack:
166
+ route = stack.pop()
167
+ yield route
168
+ stack.extend(getattr(route, "routes", []) or [])
tests/test_services/test_llama_parse_service.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests du service d'extraction LlamaParse.
3
+
4
+ Aucun appel réseau : `respx` intercepte httpx. Sans cela ces tests
5
+ consommeraient des crédits LlamaParse à chaque exécution.
6
+ """
7
+
8
+ import json
9
+
10
+ import httpx
11
+ import pytest
12
+ import respx
13
+
14
+ from app.services.llama_parse_service import (
15
+ LlamaParseError,
16
+ LlamaParseService,
17
+ _strip_stamp_blocks,
18
+ )
19
+
20
+ BASE = "https://api.cloud.llamaindex.ai/api/v2/parse"
21
+
22
+
23
+ @pytest.fixture
24
+ def service(tmp_path):
25
+ return LlamaParseService(api_key="cle-de-test", cache_dir=tmp_path / "cache")
26
+
27
+
28
+ @pytest.fixture
29
+ def pdf(tmp_path):
30
+ p = tmp_path / "doc.pdf"
31
+ p.write_bytes(b"%PDF-1.4\ncontenu\n%%EOF")
32
+ return p
33
+
34
+
35
+ class TestNettoyageDesCachets:
36
+ """
37
+ Retrait du cachet officiel apposé sur chaque page du corpus prc.cm.
38
+
39
+ LlamaParse restitue ce cachet en texte ; sans nettoyage il pollue le FTS et
40
+ les embeddings de tous les documents.
41
+ """
42
+
43
+ def test_bloc_multiligne_retire(self):
44
+ md = (
45
+ "Article 1er. Contenu juridique réel.\n\n"
46
+ "PRESIDENCE DE LA REPUBLIQUE\n"
47
+ "SERVICE DU FICHIER LEGISLATIF ET REGLEMENTAIRE\n"
48
+ "COPIE CERTIFIEE CONFORME\n"
49
+ "CERTIFIED TRUE COPY\n\n"
50
+ "Article 2. Autre contenu."
51
+ )
52
+ out = _strip_stamp_blocks(md)
53
+ assert "COPIE CERTIFIEE CONFORME" not in out
54
+ assert "Article 1er. Contenu juridique réel." in out
55
+ assert "Article 2. Autre contenu." in out
56
+
57
+ def test_cachet_sur_une_seule_ligne_retire(self):
58
+ """LlamaParse condense parfois tout le cachet sur une ligne."""
59
+ md = (
60
+ "Article 1er. Contenu.\n"
61
+ "[signature: PRESIDENCE DE LA REPUBLIQUE SECRETARIAT GENERAL "
62
+ "COPIE CERTIFIEE CONFORME CERTIFIED TRUE COPY]\n"
63
+ )
64
+ out = _strip_stamp_blocks(md)
65
+ assert "CERTIFIED TRUE COPY" not in out
66
+ assert "Article 1er. Contenu." in out
67
+
68
+ def test_en_tete_legitime_conserve(self):
69
+ """
70
+ Beaucoup de décrets portent « PRESIDENCE DE LA REPUBLIQUE » comme
71
+ autorité émettrice : c'est du contenu, pas un tampon. Une seule phrase
72
+ du vocabulaire ne doit donc rien déclencher.
73
+ """
74
+ md = "PRESIDENCE DE LA REPUBLIQUE\n\nDECRET N° 2024/191 du 4 juin 2024"
75
+ out = _strip_stamp_blocks(md)
76
+ assert "PRESIDENCE DE LA REPUBLIQUE" in out
77
+ assert "DECRET N° 2024/191" in out
78
+
79
+ def test_filigrane_retire(self):
80
+ md = "Article 1er.\nw\nw\nw\n.prc\n.cm\nContenu."
81
+ out = _strip_stamp_blocks(md)
82
+ assert "Article 1er." in out and "Contenu." in out
83
+ assert "\nw\n" not in f"\n{out}\n"
84
+
85
+ def test_tableaux_conserves(self):
86
+ """Les <table> portent du sens (annexes budgétaires) — jamais retirés."""
87
+ md = "<table><tr><td>Recettes</td><td>66 900 000</td></tr></table>"
88
+ assert "<table>" in _strip_stamp_blocks(md)
89
+
90
+ def test_balises_inline_retirees(self):
91
+ """<sup> casse la détection d'article : ARTICLE 1<sup>ER</sup>."""
92
+ assert _strip_stamp_blocks("**ARTICLE 1<sup>ER</sup>**") == "**ARTICLE 1ER**"
93
+
94
+
95
+ class TestDisponibilite:
96
+ def test_indisponible_sans_cle(self, tmp_path, monkeypatch):
97
+ # api_key="" retombe volontairement sur settings.LLAMA_CLOUD_API_KEY :
98
+ # il faut donc neutraliser aussi la configuration pour tester ce cas.
99
+ monkeypatch.setattr(
100
+ "app.services.llama_parse_service.settings.LLAMA_CLOUD_API_KEY", ""
101
+ )
102
+ assert not LlamaParseService(api_key="", cache_dir=tmp_path).is_available()
103
+
104
+ def test_disponible_avec_cle(self, service):
105
+ assert service.is_available()
106
+
107
+
108
+ class TestExtraction:
109
+ @pytest.mark.asyncio
110
+ @respx.mock
111
+ async def test_extraction_et_marqueurs_de_page(self, service, pdf):
112
+ respx.post(f"{BASE}/upload").mock(
113
+ return_value=httpx.Response(200, json={"job": {"id": "job-1"}})
114
+ )
115
+ respx.get(f"{BASE}/job-1").mock(
116
+ return_value=httpx.Response(
117
+ 200,
118
+ json={
119
+ "job": {"status": "COMPLETED"},
120
+ "markdown": [{"markdown": "Page une"}, {"markdown": "Page deux"}],
121
+ },
122
+ )
123
+ )
124
+ service.POLL_INTERVAL = 0
125
+ text = await service.extract_text(pdf)
126
+ # Les marqueurs alimentent Article.page_number via text_chunker
127
+ assert "<<PAGE:1>>" in text and "<<PAGE:2>>" in text
128
+ assert "Page une" in text and "Page deux" in text
129
+
130
+ @pytest.mark.asyncio
131
+ @respx.mock
132
+ async def test_cache_evite_un_second_appel(self, service, pdf):
133
+ upload = respx.post(f"{BASE}/upload").mock(
134
+ return_value=httpx.Response(200, json={"job": {"id": "job-1"}})
135
+ )
136
+ respx.get(f"{BASE}/job-1").mock(
137
+ return_value=httpx.Response(
138
+ 200,
139
+ json={"job": {"status": "COMPLETED"}, "markdown": [{"markdown": "Texte"}]},
140
+ )
141
+ )
142
+ service.POLL_INTERVAL = 0
143
+ first = await service.extract_text(pdf)
144
+ second = await service.extract_text(pdf)
145
+ assert first == second
146
+ assert upload.call_count == 1, "le second appel aurait dû être servi par le cache"
147
+
148
+ @pytest.mark.asyncio
149
+ @respx.mock
150
+ async def test_job_en_echec_leve(self, service, pdf):
151
+ respx.post(f"{BASE}/upload").mock(
152
+ return_value=httpx.Response(200, json={"job": {"id": "job-1"}})
153
+ )
154
+ respx.get(f"{BASE}/job-1").mock(
155
+ return_value=httpx.Response(
156
+ 200,
157
+ json={"job": {"status": "FAILED", "error_message": "document illisible"}},
158
+ )
159
+ )
160
+ service.POLL_INTERVAL = 0
161
+ with pytest.raises(LlamaParseError, match="document illisible"):
162
+ await service.extract_text(pdf)
163
+
164
+ @pytest.mark.asyncio
165
+ @respx.mock
166
+ async def test_cle_refusee_ne_declenche_pas_de_retry(self, service, pdf):
167
+ """Un 401 est définitif : réessayer 4 fois ne ferait que perdre du temps."""
168
+ route = respx.post(f"{BASE}/upload").mock(
169
+ return_value=httpx.Response(401, text="cle invalide")
170
+ )
171
+ service.POLL_INTERVAL = 0
172
+ with pytest.raises(LlamaParseError):
173
+ await service.extract_text(pdf)
174
+ assert route.call_count == 1
175
+
176
+ @pytest.mark.asyncio
177
+ async def test_fichier_absent(self, service, tmp_path):
178
+ with pytest.raises(LlamaParseError, match="introuvable"):
179
+ await service.extract_text(tmp_path / "absent.pdf")
180
+
181
+
182
+ class TestNormalisationDesReponses:
183
+ """L'API v2 renvoie le markdown sous plusieurs formes selon les options."""
184
+
185
+ def test_liste_de_pages(self):
186
+ pages = LlamaParseService._pages_from(
187
+ {"markdown": [{"markdown": "a"}, {"markdown": "b"}]}
188
+ )
189
+ assert pages == ["a", "b"]
190
+
191
+ def test_chaine_unique(self):
192
+ assert LlamaParseService._pages_from({"markdown": "seule"}) == ["seule"]
193
+
194
+ def test_champ_pages(self):
195
+ assert LlamaParseService._pages_from({"pages": [{"markdown": "x"}]}) == ["x"]
196
+
197
+ def test_markdown_full(self):
198
+ assert LlamaParseService._pages_from({"markdown_full": "tout"}) == ["tout"]
199
+
200
+ def test_reponse_vide(self):
201
+ assert LlamaParseService._pages_from({}) == []
tests/test_utils/test_chunk_refiner.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests du raffineur de chunks (règles R2 à R7).
3
+
4
+ Le module n'avait aucun test. Ces règles déterminent directement ce que le
5
+ modèle reçoit en contexte : une erreur ici se traduit par des réponses sans
6
+ fondement, sans qu'aucune exception ne soit levée.
7
+ """
8
+
9
+ import pytest
10
+
11
+ from app.utils.chunk_refiner import (
12
+ DocumentContext,
13
+ normalize_for_chunking,
14
+ refine,
15
+ )
16
+
17
+
18
+ @pytest.fixture
19
+ def contexte():
20
+ return DocumentContext(
21
+ reference="Décret n° 2024/191",
22
+ title="portant ratification de la Convention de Crédit-Acheteur",
23
+ doc_type="decret",
24
+ date="4 juin 2024",
25
+ category="Finances publiques",
26
+ language="fr",
27
+ )
28
+
29
+
30
+ def chunk(number, content, **kw):
31
+ """Construit un chunk au format produit par text_chunker."""
32
+ base = {
33
+ "number": number,
34
+ "title": None,
35
+ "content": content,
36
+ "position": 0,
37
+ "parent_id": None,
38
+ "section": None,
39
+ "word_count": len(content.split()),
40
+ "char_count": len(content),
41
+ "page_number": 1,
42
+ }
43
+ base.update(kw)
44
+ return base
45
+
46
+
47
+ class TestNormalisation:
48
+ """Prépare le markdown OCR pour text_chunker."""
49
+
50
+ def test_gras_markdown_retire(self):
51
+ """**ARTICLE 1ER** empêche la détection : le motif attend « Article »
52
+ en début de ligne, pas « **Article »."""
53
+ assert "ARTICLE 1ER" in normalize_for_chunking("**ARTICLE 1ER**: Contenu")
54
+ assert "**" not in normalize_for_chunking("**ARTICLE 1ER**: Contenu")
55
+
56
+ def test_titre_markdown_retire(self):
57
+ assert normalize_for_chunking("# Titre").strip() == "Titre"
58
+
59
+ def test_lettres_espacees_recollees(self):
60
+ """L'OCR restitue l'interlettrage des titres : « A R R Ê T E »."""
61
+ assert "ARRÊTE" in normalize_for_chunking("A R R Ê T E :")
62
+
63
+ def test_marqueurs_de_page_preserves(self):
64
+ assert "<<PAGE:3>>" in normalize_for_chunking("<<PAGE:3>>\nTexte")
65
+
66
+ def test_tableaux_preserves(self):
67
+ out = normalize_for_chunking("<table><tr><td>a</td></tr></table>")
68
+ assert "<table>" in out
69
+
70
+
71
+ class TestR2Contextualisation:
72
+ def test_embed_text_porte_le_contexte(self, contexte):
73
+ r = refine([chunk("3", "La dépense résultant des présentes dispositions." * 5)], contexte)
74
+ embeddable = r.embeddable
75
+ assert embeddable, "le chunk aurait dû être vectorisable"
76
+ texte = embeddable[0]["embed_text"]
77
+ # Sans en-tête, « La dépense résultant des présentes dispositions » est
78
+ # un chunk orphelin : ni le lecteur ni l'embedding ne savent de quoi il parle.
79
+ assert "Décret n° 2024/191" in texte
80
+ assert "Finances publiques" in texte
81
+ assert "Article 3" in texte
82
+
83
+ def test_pas_de_libelle_article_pour_un_repli_paragraphe(self, contexte):
84
+ """PARA_n n'est pas un vrai numéro : l'annoncer ferait citer au chatbot
85
+ des références inexistantes."""
86
+ r = refine([chunk("PARA_2", "Contenu quelconque suffisamment long." * 4)], contexte)
87
+ assert "Article PARA_2" not in r.embeddable[0]["embed_text"]
88
+
89
+ def test_contenu_non_modifie(self, contexte):
90
+ """Le contexte va dans embed_text, jamais dans content (affichage/citation)."""
91
+ contenu = "Article utile avec un contenu suffisamment long pour être conservé."
92
+ r = refine([chunk("1", contenu)], contexte)
93
+ assert r.chunks[0]["content"] == contenu
94
+
95
+
96
+ class TestR3Visas:
97
+ def test_visas_hors_index_et_cites(self, contexte):
98
+ visas = (
99
+ "Vu la Constitution ;\n"
100
+ "Vu la loi n° 2007-006 du 26 décembre 2007 portant régime financier ;\n"
101
+ "Vu le décret n° 2011/412 du 09 décembre 2011 portant réorganisation ;"
102
+ )
103
+ r = refine([chunk("LEGAL_BASIS", visas)], contexte)
104
+ assert r.legal_basis is not None
105
+ assert not r.embeddable, "les visas ne doivent pas être vectorisés"
106
+ # Présents dans chaque document, ils domineraient le FTS et écraseraient
107
+ # la similarité cosinus.
108
+ assert any("2007-006" in c for c in r.citations)
109
+ assert any("2011/412" in c for c in r.citations)
110
+
111
+ def test_suite_de_page_reste_indexee(self, contexte):
112
+ """
113
+ text_chunker étiquette LEGAL_BASIS tout le texte précédant le premier
114
+ article. Sur une page qui ne commence pas par un article, c'est du
115
+ contenu juridique réel : l'exclure le ferait disparaître de la recherche.
116
+ """
117
+ suite = "la délivrance des titres de passeports ; l'obtention d'une carte grise." * 3
118
+ r = refine([chunk("LEGAL_BASIS", suite)], contexte)
119
+ assert r.legal_basis is None
120
+ assert r.chunks[0]["kind"] == "continuation"
121
+ assert r.chunks[0]["embed"] is True
122
+
123
+
124
+ class TestR4ListesNominatives:
125
+ TABLE = "<table><tbody>" + "".join(
126
+ f"<tr><td>{i}.</td><td>NOM PRENOM{i:02d}</td><td>765 6{i:02d}-Y</td></tr>"
127
+ for i in range(1, 21)
128
+ ) + "</tbody></table>"
129
+
130
+ def test_liste_effondree_en_un_chunk(self, contexte):
131
+ contenu = (
132
+ "Les anciens Gardiens de la Paix dont les noms suivent sont nommés "
133
+ "Élèves-Inspecteurs de Police, indice 230.\n" + self.TABLE
134
+ )
135
+ r = refine([chunk("1", contenu)], contexte)
136
+ assert len(r.roster) == 20
137
+ assert len(r.embeddable) == 1, "20 personnes ne doivent pas donner 20 vecteurs"
138
+ # L'enveloppe juridique est conservée, la liste ne l'est pas
139
+ assert "Élèves-Inspecteurs" in r.chunks[0]["content"]
140
+ assert "NOM PRENOM05" not in r.chunks[0]["content"]
141
+ assert "20 personnes concernées" in r.chunks[0]["content"]
142
+
143
+ def test_appariement_nom_matricule(self, contexte):
144
+ contenu = "Sont nommés :\n" + self.TABLE
145
+ r = refine([chunk("1", contenu)], contexte)
146
+ entree = r.roster[4]
147
+ assert entree.name == "NOM PRENOM05"
148
+ assert entree.identifier == "765 605-Y"
149
+
150
+ def test_entete_de_tableau_ignoree(self, contexte):
151
+ table = (
152
+ "<table><thead><tr><th>N°</th><th>Nom</th><th>Indice</th></tr></thead>"
153
+ "<tbody>" + "".join(
154
+ f"<tr><td>{i}.</td><td>NOM PRENOM{i:02d}</td><td>765 6{i:02d}-Y</td></tr>"
155
+ for i in range(1, 8)
156
+ ) + "</tbody></table>"
157
+ )
158
+ r = refine([chunk("1", "Sont nommés :\n" + table)], contexte)
159
+ assert all(e.name != "Nom" for e in r.roster), "« Nom » compté comme personne"
160
+ assert len(r.roster) == 7
161
+
162
+ def test_article_normatif_non_traite_comme_liste(self, contexte):
163
+ contenu = (
164
+ "L'administration fiscale met en œuvre l'assistance internationale "
165
+ "en matière de recouvrement des créances fiscales, qu'elle soit "
166
+ "sollicitée par une autorité étrangère ou qu'elle en fasse la demande."
167
+ )
168
+ r = refine([chunk("L 94 septies", contenu)], contexte)
169
+ assert not r.roster
170
+ assert r.chunks[0]["kind"] == "article"
171
+
172
+
173
+ class TestR5R6TaillesEtTableaux:
174
+ def test_tableau_jamais_coupe(self, contexte):
175
+ table = "<table>" + "".join(
176
+ f"<tr><td>ligne {i}</td><td>{i * 1000}</td></tr>" for i in range(200)
177
+ ) + "</table>"
178
+ r = refine([chunk("86", "Les charges sont évaluées ainsi :\n" + table)], contexte)
179
+ # Une ligne isolée ne répond à aucune question : le tableau reste entier
180
+ assert len(r.chunks) == 1
181
+ assert r.chunks[0]["oversized"] is True
182
+
183
+ def test_article_long_coupe_aux_alineas(self, contexte):
184
+ contenu = "Dispositions générales.\n" + "\n".join(
185
+ f"({i}) " + "Texte de l'alinéa suffisamment long pour compter. " * 12
186
+ for i in range(1, 6)
187
+ )
188
+ r = refine([chunk("94", contenu)], contexte, target_max_chars=800)
189
+ assert len(r.chunks) > 1
190
+ assert all(c["parent_id"] == "94" for c in r.chunks[1:])
191
+
192
+ def test_article_d_execution_hors_index(self, contexte):
193
+ boilerplate = (
194
+ "Le présent décret sera enregistré, publié selon la procédure "
195
+ "d'urgence, puis inséré au Journal Officiel en français et en anglais."
196
+ )
197
+ r = refine([chunk("5", boilerplate)], contexte)
198
+ # Présent dans presque chaque texte : indexé, il produirait des milliers
199
+ # de quasi-doublons. Conservé en base, retiré du vectoriel.
200
+ assert r.chunks[0]["kind"] == "boilerplate"
201
+ assert r.chunks[0]["embed"] is False
202
+ assert r.chunks[0]["content"] == boilerplate
203
+
204
+ def test_fragment_trop_court_hors_index(self, contexte):
205
+ r = refine([chunk("PARA_9", "45")], contexte)
206
+ assert r.chunks[0]["embed"] is False
207
+ assert r.chunks[0]["kind"] == "fragment"
208
+
209
+
210
+ class TestR7Deduplication:
211
+ def test_chunks_identiques_fusionnes(self, contexte):
212
+ contenu = "Le présent texte entre en application immédiate sur tout le territoire."
213
+ r = refine([chunk("1", contenu), chunk("2", contenu)], contexte)
214
+ assert r.stats["duplicates_removed"] == 1
215
+ assert len(r.chunks) == 1
216
+
217
+
218
+ class TestRienNEstPerdu:
219
+ def test_aucun_chunk_supprime(self, contexte):
220
+ """
221
+ Principe directeur du module : les chunks écartés du vectoriel gardent
222
+ embed=False mais restent en base et cherchables en FTS.
223
+ """
224
+ chunks = [
225
+ chunk("LEGAL_BASIS", "Vu la Constitution ;"),
226
+ chunk("1", "Contenu normatif suffisamment long pour être conservé." * 3),
227
+ chunk("5", "Le présent décret sera enregistré et publié au Journal Officiel."),
228
+ chunk("PARA_9", "12"),
229
+ ]
230
+ r = refine(chunks, contexte)
231
+ assert len(r.chunks) == len(chunks)
232
+ assert r.stats["embeddable"] < len(chunks), "tout ne doit pas être vectorisé"