File size: 7,026 Bytes
893a679
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
"""
« article 35 du code minier » ouvre le bon document sur le bon article.

Trois choses devaient exister ensemble : reconnaitre le numero, reconnaitre le
document, et verifier que l'article s'y trouve. Avant, la premiere seule etait
faite — `_parse_article_reference` extrayait bien `doc_hint`, mais personne ne
s'en servait, et `direct_navigation` etait conditionne a `total == 1`, ce qui ne
se produit presque jamais.

Usage:
    pytest tests/test_services/test_article_reference.py -v
"""

from datetime import date

import pytest

from app.models.law import Article, Law
from app.schemas.search import SearchRequest
from app.services.article_reference import (
    ArticleReference,
    normalize_number,
    parse_reference,
)
from app.services.search_service import SearchService
from app.services.search_vectors import REINDEX_ARTICLES_SQL, REINDEX_LAWS_SQL

CODE_MINIER = 300
CODE_TRAVAIL = 301


@pytest.fixture
async def codes(db_session, category_ids):
    from sqlalchemy import text as sql_text

    laws = [
        Law(id=CODE_MINIER, reference="LOI-2023-014",
            title="Loi N°2023/014 du 19 décembre 2023 portant Code Minier",
            content="Code régissant les activités minières au Cameroun.",
            type="loi", language="fr", status="published",
            category_id=category_ids["Droit de l'Environnement et des Ressources Naturelles"],
            publication_date=date(2023, 12, 19)),
        Law(id=CODE_TRAVAIL, reference="LOI-1992-007",
            title="Loi N°92/007 du 14 août 1992 portant Code du Travail",
            content="Code régissant les relations de travail.",
            type="loi", language="fr", status="published",
            category_id=category_ids["Droit du Travail et Sécurité Sociale"],
            publication_date=date(1992, 8, 14)),
    ]
    db_session.add_all(laws)
    await db_session.flush()

    articles = []
    for law_id, numbers in ((CODE_MINIER, ("1", "35", "36")), (CODE_TRAVAIL, ("1", "2"))):
        for number in numbers:
            articles.append(Article(
                law_id=law_id, number=number,
                content=(f"Dispositions de l'article {number}. "
                         "La superficie couverte par le permis de recherche est limitée."),
                order=int(number), kind="article", embed=True,
            ))
    db_session.add_all(articles)
    await db_session.commit()
    await db_session.execute(sql_text(REINDEX_LAWS_SQL))
    await db_session.execute(sql_text(REINDEX_ARTICLES_SQL))
    await db_session.commit()
    return laws


@pytest.fixture
def service(db_session):
    return SearchService(db_session, use_cache=False)


class TestParsing:
    """Le module unique remplace huit motifs « article N » divergents."""

    @pytest.mark.parametrize("query,number,hint", [
        ("article 35 du code minier", "35", "code minier"),
        ("Article 35 de la Constitution", "35", "Constitution"),
        ("art. 35 du code du travail", "35", "code du travail"),
        # « art.35 » sans espace : le motif du RAG exigeait `art\.?\s+`.
        ("art.35", "35", ""),
        ("article 35", "35", ""),
        ("article premier de la constitution", "1", "constitution"),
        ("article 1er du code minier", "1", "code minier"),
        ("article L 94 du code des impots", "L 94", "code des impots"),
    ])
    def test_parses(self, query, number, hint):
        reference = parse_reference(query)
        assert reference == ArticleReference(number, hint), f"pour {query!r}"

    @pytest.mark.parametrize("query", [
        "code minier", "permis de recherche", "", "les articles du code",
    ])
    def test_not_a_reference(self, query):
        assert parse_reference(query) is None

    @pytest.mark.parametrize("raw,expected", [
        ("Article 1er", "1"), ("PREMIER", "1"), ("1ÈRE", "1"),
        ("deuxieme", "2"), ("35", "35"), ("Art. 35", "35"), ("", ""),
    ])
    def test_normalisation(self, raw, expected):
        """`articles.number` stocke « 1 » : rendre « PREMIER » ne matcherait rien."""
        assert normalize_number(raw) == expected


class TestDirectNavigation:
    @pytest.mark.asyncio
    async def test_opens_the_right_document_on_the_right_article(self, service, codes):
        response = await service.search(
            SearchRequest(query="article 35 du code minier", mode="text", limit=10)
        )

        assert response.target_law_id == CODE_MINIER
        assert response.target_article == "35"
        assert response.direct_navigation is True

    @pytest.mark.asyncio
    async def test_the_document_hint_tolerates_a_typo(self, service, codes):
        """word_similarity('code miner', 'Loi ... portant Code Minier') = 0,727."""
        response = await service.search(
            SearchRequest(query="article 35 du code miner", mode="text", limit=10)
        )

        assert response.target_law_id == CODE_MINIER
        assert response.direct_navigation is True

    @pytest.mark.asyncio
    async def test_the_hint_selects_between_two_codes(self, service, codes):
        response = await service.search(
            SearchRequest(query="article 1 du code du travail", mode="text", limit=10)
        )

        assert response.target_law_id == CODE_TRAVAIL

    @pytest.mark.asyncio
    async def test_an_absent_article_does_not_navigate(self, service, codes):
        """
        Ouvrir un document sur un article qui n'y est pas serait pire que ne pas
        l'ouvrir : l'utilisateur croirait avoir lu la reponse.
        """
        response = await service.search(
            SearchRequest(query="article 999 du code minier", mode="text", limit=10)
        )

        assert response.direct_navigation is False
        assert response.target_article == "999"
        assert response.results, "les resultats de recherche doivent rester"

    @pytest.mark.asyncio
    async def test_article_premier_resolves_to_the_stored_number(self, service, codes):
        response = await service.search(
            SearchRequest(query="article premier du code minier", mode="text", limit=10)
        )

        assert response.target_article == "1"
        assert response.target_law_id == CODE_MINIER

    @pytest.mark.asyncio
    async def test_a_plain_query_does_not_navigate(self, service, codes):
        response = await service.search(
            SearchRequest(query="permis de recherche", mode="text", limit=10)
        )

        assert response.direct_navigation is False
        assert response.target_article is None


class TestRemovedEndpoint:
    @pytest.mark.asyncio
    async def test_the_dead_article_endpoint_is_gone(self, client, db_session):
        """
        Repondait 500 : il appelait `_parse_article_reference` ET
        `_estimate_page_number`, aucun des deux defini dans ce module. Aucun
        test ne le couvrait — c'est ce qui lui a permis de survivre.
        """
        response = await client.get("/api/v1/search/article?q=article%205")

        assert response.status_code == 404