File size: 9,237 Bytes
b0b2691
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9011070
b0b2691
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d109222
9011070
b0b2691
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
"""Tests Sprint 74 โ€” A.I.3 chantier 1 : encart ยซ Ce corpus est-il habituel ? ยป.

Couvre :

1. ``build_corpus_difficulty_baseline_html`` :
   - Phrase factuelle rendue (harder / easier / usual)
   - Chaรฎne vide si ``percentile_data is None``
   - SVG omis si ``historical_values`` vide / None
   - SVG rendu si valeurs fournies
2. SVG :
   - Bien formรฉ (``<svg ...>...</svg>``)
   - Point courant placรฉ au bon endroit (couleur selon position)
   - Boรฎte Q1-Q3, mรฉdiane, moustaches min-max
3. Anti-injection : labels i18n contenant ``<script>`` รฉchappรฉs.
4. Complรฉtude i18n : nouvelles clรฉs ``baseline_corpus_*`` prรฉsentes
   en FR et EN.
"""

from __future__ import annotations

import json
from pathlib import Path

from picarones.reports.html.renderers.baseline import (
    _build_difficulty_boxplot_svg,
    _quantiles,
    build_corpus_difficulty_baseline_html,
)


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 1. _quantiles
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestQuantiles:
    def test_simple(self) -> None:
        v = [1.0, 2.0, 3.0, 4.0, 5.0]
        mn, q1, med, q3, mx = _quantiles(v)
        assert mn == 1.0
        assert mx == 5.0
        assert med == 3.0

    def test_empty(self) -> None:
        assert _quantiles([]) == (0.0, 0.0, 0.0, 0.0, 0.0)

    def test_single(self) -> None:
        assert _quantiles([0.5]) == (0.5, 0.5, 0.5, 0.5, 0.5)


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 2. SVG boxplot
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestSvg:
    def test_well_formed(self) -> None:
        svg = _build_difficulty_boxplot_svg(
            [0.1, 0.2, 0.3, 0.4, 0.5], current=0.35,
        )
        assert svg.startswith('<svg')
        assert svg.endswith('</svg>')
        assert 'xmlns="http://www.w3.org/2000/svg"' in svg

    def test_empty_returns_empty(self) -> None:
        assert _build_difficulty_boxplot_svg([], current=0.5) == ""

    def test_point_color_harder(self) -> None:
        # current > Q3 โ†’ rouge
        svg = _build_difficulty_boxplot_svg(
            [0.1, 0.2, 0.3, 0.4, 0.5], current=0.95,
        )
        assert "#d8553b" in svg

    def test_point_color_easier(self) -> None:
        # current < Q1 โ†’ bleu
        svg = _build_difficulty_boxplot_svg(
            [0.3, 0.4, 0.5, 0.6, 0.7], current=0.1,
        )
        assert "#3b87d8" in svg

    def test_point_color_usual(self) -> None:
        # current entre Q1 et Q3 โ†’ vert
        svg = _build_difficulty_boxplot_svg(
            [0.1, 0.2, 0.3, 0.4, 0.5], current=0.3,
        )
        assert "#5fa860" in svg

    def test_contains_box_and_whiskers(self) -> None:
        svg = _build_difficulty_boxplot_svg(
            [0.1, 0.2, 0.3, 0.4, 0.5], current=0.3,
        )
        # Au moins un rect (boรฎte) et plusieurs lignes (moustaches)
        assert "<rect" in svg
        assert "<line" in svg
        # Cercle pour le point courant
        assert "<circle" in svg

    def test_degenerate_all_same(self) -> None:
        # Toutes les valeurs identiques : ne doit pas crasher
        svg = _build_difficulty_boxplot_svg(
            [0.5, 0.5, 0.5], current=0.5,
        )
        assert svg.startswith('<svg')

    def test_current_outside_historical_range(self) -> None:
        # Le point courant peut dรฉpasser les valeurs historiques
        svg = _build_difficulty_boxplot_svg(
            [0.1, 0.2, 0.3], current=0.99,
        )
        assert svg.startswith('<svg')


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 3. build_corpus_difficulty_baseline_html
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestBuildHtml:
    def test_returns_empty_when_no_data(self) -> None:
        assert build_corpus_difficulty_baseline_html(None) == ""

    def test_renders_phrase_harder(self) -> None:
        data = {
            "current_difficulty": 0.62,
            "percentile": 88.0,
            "n_runs": 47,
            "median_historical": 0.40,
            "harder_than_usual": True,
            "easier_than_usual": False,
        }
        html = build_corpus_difficulty_baseline_html(data)
        assert "0.62" in html
        assert "88" in html
        assert "47" in html
        assert "plus difficile" in html

    def test_renders_phrase_easier(self) -> None:
        data = {
            "current_difficulty": 0.10,
            "percentile": 12.0,
            "n_runs": 30,
            "median_historical": 0.40,
            "harder_than_usual": False,
            "easier_than_usual": True,
        }
        html = build_corpus_difficulty_baseline_html(data)
        assert "plus facile" in html

    def test_renders_phrase_usual(self) -> None:
        data = {
            "current_difficulty": 0.40,
            "percentile": 50.0,
            "n_runs": 20,
            "median_historical": 0.40,
            "harder_than_usual": False,
            "easier_than_usual": False,
        }
        html = build_corpus_difficulty_baseline_html(data)
        assert "dans la moyenne" in html

    def test_svg_omitted_when_no_history_values(self) -> None:
        data = {
            "current_difficulty": 0.40,
            "percentile": 50.0,
            "n_runs": 20,
            "median_historical": 0.40,
            "harder_than_usual": False,
            "easier_than_usual": False,
        }
        html = build_corpus_difficulty_baseline_html(data)
        assert "<svg" not in html

    def test_svg_present_when_history_provided(self) -> None:
        data = {
            "current_difficulty": 0.62,
            "percentile": 88.0,
            "n_runs": 5,
            "median_historical": 0.30,
            "harder_than_usual": True,
            "easier_than_usual": False,
        }
        html = build_corpus_difficulty_baseline_html(
            data, historical_values=[0.1, 0.2, 0.3, 0.4, 0.5],
        )
        assert "<svg" in html


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 4. Anti-injection
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestAntiInjection:
    def test_label_via_i18n_escaped(self) -> None:
        data = {
            "current_difficulty": 0.40, "percentile": 50.0,
            "n_runs": 20, "median_historical": 0.40,
            "harder_than_usual": False, "easier_than_usual": False,
        }
        labels = {"baseline_corpus_title": "<b>Hack</b>"}
        html = build_corpus_difficulty_baseline_html(data, labels=labels)
        assert "<b>Hack</b>" not in html
        assert "&lt;b&gt;Hack&lt;/b&gt;" in html


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# 5. Complรฉtude i18n
# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€


class TestI18nCompleteness:
    def _load(self, lang: str) -> dict:
        path = (
            Path(__file__).parent.parent.parent
            / "picarones" / "reports" / "i18n" / f"{lang}.json"
        )
        return json.loads(path.read_text(encoding="utf-8"))

    def test_all_keys_present_fr(self) -> None:
        d = self._load("fr")
        for key in (
            "baseline_corpus_title",
            "baseline_corpus_harder",
            "baseline_corpus_easier",
            "baseline_corpus_usual",
        ):
            assert key in d, f"manque clรฉ FR : {key}"

    def test_all_keys_present_en(self) -> None:
        d_fr = self._load("fr")
        d_en = self._load("en")
        for key in d_fr:
            if key.startswith("baseline_corpus_"):
                assert key in d_en, f"manque clรฉ EN : {key}"