File size: 6,880 Bytes
f842a06
0341500
f842a06
 
0341500
f842a06
 
 
 
 
 
 
 
 
 
 
0341500
9097545
f842a06
 
 
 
 
0341500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9097545
0341500
 
 
 
f842a06
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0341500
 
 
f842a06
 
 
 
 
0341500
f842a06
0341500
f842a06
 
 
 
 
0341500
 
 
f842a06
0341500
f842a06
 
 
 
 
 
 
0341500
f842a06
0341500
f842a06
0341500
f842a06
 
 
 
 
 
 
0341500
f842a06
0341500
 
 
f842a06
0341500
f842a06
 
 
 
 
 
 
0341500
 
 
 
 
f842a06
0341500
f842a06
 
 
 
 
 
0341500
f842a06
0341500
f842a06
0341500
f842a06
 
 
 
 
0341500
f842a06
0341500
f842a06
0341500
f842a06
 
 
 
 
 
 
0341500
f842a06
0341500
 
 
 
 
 
 
 
f842a06
 
 
 
 
 
 
 
0341500
f842a06
0341500
f842a06
0341500
f842a06
 
 
 
 
 
 
 
0341500
 
 
 
 
 
 
 
 
 
 
 
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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
"""
Tests de l'endpoint GET /api/v1/search (Sprint 4 — recherche indexée).

Stratégie :
  - Données indexées directement dans la table page_search (BDD en mémoire)
  - Vérifie : 422 (paramètre manquant / trop court), résultats vides,
    correspondance OCR, insensibilité casse et accents, tri par score,
    extrait (excerpt) présent.
"""
# 1. stdlib
import uuid

# 2. third-party
import pytest

# 3. local
from app.models.page_search import PageSearchIndex
from app.services.search.indexer import _build_normalized_text
from tests.conftest_api import async_client, db_session  # noqa: F401


# ── Helpers ────────────────────────────────────────────────────────────────────

async def _index_page(
    db,
    page_id: str | None = None,
    diplomatic_text: str = "",
    translation_fr: str = "",
    tags: str = "",
    corpus_profile: str = "medieval-illuminated",
    manuscript_id: str = "ms-test",
    folio_label: str = "f001r",
) -> str:
    """Insère une entrée dans page_search et retourne le page_id."""
    pid = page_id or str(uuid.uuid4())
    entry = PageSearchIndex(
        page_id=pid,
        corpus_profile=corpus_profile,
        manuscript_id=manuscript_id,
        folio_label=folio_label,
        diplomatic_text=diplomatic_text,
        translation_fr=translation_fr,
        tags=tags,
        normalized_text=_build_normalized_text(diplomatic_text, translation_fr, tags),
    )
    db.add(entry)
    await db.commit()
    return pid


# ── Tests ──────────────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_search_missing_q(async_client):
    """q est obligatoire — 422 si absent."""
    resp = await async_client.get("/api/v1/search")
    assert resp.status_code == 422


@pytest.mark.asyncio
async def test_search_q_too_short(async_client):
    """q doit faire au moins 2 caractères — 422 si trop court."""
    resp = await async_client.get("/api/v1/search?q=a")
    assert resp.status_code == 422


@pytest.mark.asyncio
async def test_search_empty_results(async_client):
    """Retourne [] quand aucune page ne correspond."""
    resp = await async_client.get("/api/v1/search?q=rien")
    assert resp.status_code == 200
    assert resp.json() == []


@pytest.mark.asyncio
async def test_search_returns_list(async_client):
    """Le type de retour est toujours une liste."""
    resp = await async_client.get("/api/v1/search?q=texte")
    assert resp.status_code == 200
    assert isinstance(resp.json(), list)


@pytest.mark.asyncio
async def test_search_finds_ocr_text(async_client, db_session):
    """Trouve une page dont diplomatic_text contient la requête."""
    page_id = await _index_page(db_session, diplomatic_text="Incipit liber primus")

    resp = await async_client.get("/api/v1/search?q=Incipit")
    assert resp.status_code == 200
    results = resp.json()
    assert len(results) == 1
    assert results[0]["page_id"] == page_id


@pytest.mark.asyncio
async def test_search_case_insensitive(async_client, db_session):
    """La recherche est insensible à la casse."""
    page_id = await _index_page(db_session, diplomatic_text="INCIPIT LIBER")

    resp = await async_client.get("/api/v1/search?q=incipit")
    assert resp.status_code == 200
    results = resp.json()
    assert len(results) >= 1
    assert any(r["page_id"] == page_id for r in results)


@pytest.mark.asyncio
async def test_search_accent_insensitive(async_client, db_session):
    """La recherche est insensible aux accents."""
    page_id = await _index_page(
        db_session, diplomatic_text="Édition française médiévale"
    )

    resp = await async_client.get("/api/v1/search?q=edition")
    assert resp.status_code == 200
    results = resp.json()
    assert len(results) >= 1
    assert any(r["page_id"] == page_id for r in results)


@pytest.mark.asyncio
async def test_search_finds_translation_fr(async_client, db_session):
    """Trouve également dans translation_fr."""
    page_id = await _index_page(
        db_session, translation_fr="Ici commence le premier livre"
    )

    resp = await async_client.get("/api/v1/search?q=premier")
    assert resp.status_code == 200
    results = resp.json()
    assert any(r["page_id"] == page_id for r in results)


@pytest.mark.asyncio
async def test_search_no_match_returns_empty(async_client, db_session):
    """Ne retourne rien quand la requête ne correspond à aucun texte."""
    await _index_page(db_session, diplomatic_text="Incipit liber")

    resp = await async_client.get("/api/v1/search?q=xyznomatch")
    assert resp.status_code == 200
    assert resp.json() == []


@pytest.mark.asyncio
async def test_search_result_has_excerpt(async_client, db_session):
    """Chaque résultat contient un champ excerpt non vide."""
    await _index_page(db_session, diplomatic_text="Incipit liber primus")

    resp = await async_client.get("/api/v1/search?q=liber")
    assert resp.status_code == 200
    results = resp.json()
    assert len(results) >= 1
    assert results[0]["excerpt"] != ""


@pytest.mark.asyncio
async def test_search_sorted_by_score_desc(async_client, db_session):
    """Les résultats sont triés par score décroissant."""
    page_id_1 = await _index_page(
        db_session, diplomatic_text="liber liber liber"
    )
    page_id_2 = await _index_page(
        db_session, diplomatic_text="liber unus"
    )

    resp = await async_client.get("/api/v1/search?q=liber")
    assert resp.status_code == 200
    results = resp.json()
    assert len(results) == 2
    assert results[0]["score"] >= results[1]["score"]
    assert results[0]["page_id"] == page_id_1


@pytest.mark.asyncio
async def test_search_result_fields(async_client, db_session):
    """Chaque résultat expose les champs attendus."""
    await _index_page(db_session, diplomatic_text="Incipit liber")

    resp = await async_client.get("/api/v1/search?q=Incipit")
    assert resp.status_code == 200
    result = resp.json()[0]
    assert "page_id" in result
    assert "folio_label" in result
    assert "manuscript_id" in result
    assert "excerpt" in result
    assert "score" in result
    assert "corpus_profile" in result


@pytest.mark.asyncio
async def test_search_finds_tags(async_client, db_session):
    """Trouve dans les tags iconographiques."""
    page_id = await _index_page(db_session, tags="apocalypse sceau martyrs")

    resp = await async_client.get("/api/v1/search?q=apocalypse")
    assert resp.status_code == 200
    results = resp.json()
    assert len(results) >= 1
    assert any(r["page_id"] == page_id for r in results)