File size: 13,396 Bytes
2d6c41d
 
 
 
 
 
 
 
9011070
2d6c41d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b80bb93
 
 
 
 
 
 
 
 
 
 
1e8b84c
 
 
 
 
 
 
b80bb93
 
 
 
 
 
 
 
 
1e8b84c
2d6c41d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
"""Tests unitaires des helpers de rendu mutualisΓ©s.

Couvre :func:`color_traffic_light`, :func:`color_single_gradient`,
:func:`color_diverging`, :func:`text_color_for_bg`, :func:`build_grid_svg`.
"""

from __future__ import annotations

from picarones.reports._helpers.render_helpers import (
    DIVERGING_NEGATIVE_RGB,
    DIVERGING_POSITIVE_RGB,
    GRADIENT_GREEN_RGB,
    GRADIENT_RED_RGB,
    GRADIENT_YELLOW_RGB,
    build_grid_svg,
    color_diverging,
    color_single_gradient,
    color_traffic_light,
    text_color_for_bg,
)


def _hex_to_rgb(hex_str: str) -> tuple[int, int, int]:
    s = hex_str.lstrip("#")
    return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)


# ──────────────────────────────────────────────────────────────────
# color_traffic_light
# ──────────────────────────────────────────────────────────────────
class TestColorTrafficLight:
    def test_value_zero_high_is_good_returns_red(self) -> None:
        assert _hex_to_rgb(color_traffic_light(0.0)) == GRADIENT_RED_RGB

    def test_value_max_high_is_good_returns_green(self) -> None:
        assert _hex_to_rgb(color_traffic_light(1.0)) == GRADIENT_GREEN_RGB

    def test_value_mid_high_is_good_returns_yellow(self) -> None:
        assert _hex_to_rgb(color_traffic_light(0.5)) == GRADIENT_YELLOW_RGB

    def test_value_zero_low_is_good_returns_green(self) -> None:
        assert _hex_to_rgb(color_traffic_light(0.0, low_is_good=True)) == GRADIENT_GREEN_RGB

    def test_value_max_low_is_good_returns_red(self) -> None:
        assert _hex_to_rgb(color_traffic_light(1.0, low_is_good=True)) == GRADIENT_RED_RGB

    def test_clamping_above_max(self) -> None:
        c = color_traffic_light(2.0)
        assert _hex_to_rgb(c) == GRADIENT_GREEN_RGB

    def test_clamping_below_zero(self) -> None:
        c = color_traffic_light(-1.0)
        assert _hex_to_rgb(c) == GRADIENT_RED_RGB

    def test_custom_scale_max(self) -> None:
        # CER 0.30 = max β†’ vert (avec low_is_good=True β†’ vert au max)
        # On vΓ©rifie plutΓ΄t high_is_good : 0.30 β†’ vert
        assert _hex_to_rgb(color_traffic_light(0.30, scale_max=0.30)) == GRADIENT_GREEN_RGB
        # 0.15 β†’ milieu β†’ jaune
        assert _hex_to_rgb(color_traffic_light(0.15, scale_max=0.30)) == GRADIENT_YELLOW_RGB

    def test_custom_scale_min_and_max(self) -> None:
        # Plage [10, 20] : 10 β†’ rouge, 20 β†’ vert, 15 β†’ jaune
        assert _hex_to_rgb(color_traffic_light(10.0, scale_min=10, scale_max=20)) == GRADIENT_RED_RGB
        assert _hex_to_rgb(color_traffic_light(20.0, scale_min=10, scale_max=20)) == GRADIENT_GREEN_RGB
        assert _hex_to_rgb(color_traffic_light(15.0, scale_min=10, scale_max=20)) == GRADIENT_YELLOW_RGB

    def test_zero_span_returns_yellow(self) -> None:
        # scale_min == scale_max β†’ milieu β†’ jaune
        assert _hex_to_rgb(color_traffic_light(5.0, scale_min=10, scale_max=10)) == GRADIENT_YELLOW_RGB

    def test_format_is_hex_lowercase(self) -> None:
        c = color_traffic_light(0.7)
        assert c.startswith("#")
        assert len(c) == 7
        assert c == c.lower()

    def test_nan_propagates_to_green(self) -> None:
        # VΓ©rification IEEE 754 : ``min(1.0, NaN)`` retourne ``1.0`` en
        # Python (la comparaison avec NaN est False, donc Python retient
        # le second argument).  ``max(0.0, 1.0) = 1.0``.  Donc f=1.0,
        # branche ``f > 0.5`` β†’ vert max.  Comportement dΓ©terministe et
        # testable (pas un test placebo "ne crash pas").
        assert _hex_to_rgb(color_traffic_light(float("nan"))) == GRADIENT_GREEN_RGB
        # Avec low_is_good=True, l'inversion donne f=0 β†’ rouge.
        assert _hex_to_rgb(
            color_traffic_light(float("nan"), low_is_good=True)
        ) == GRADIENT_RED_RGB

    def test_inf_clamped_to_max(self) -> None:
        # +inf > scale_max β†’ clamp Γ  scale_max β†’ vert (high_is_good)
        assert _hex_to_rgb(color_traffic_light(float("inf"))) == GRADIENT_GREEN_RGB
        # -inf < 0 β†’ clamp Γ  0 β†’ rouge
        assert _hex_to_rgb(color_traffic_light(float("-inf"))) == GRADIENT_RED_RGB

    def test_inverted_scale_returns_yellow_neutral(self) -> None:
        # scale_min > scale_max β†’ span <= 0 β†’ branche "zero span" β†’ f=0.5
        # → frontière exacte rouge/jaune/vert → jaune neutre.
        assert _hex_to_rgb(
            color_traffic_light(5.0, scale_min=10, scale_max=5)
        ) == GRADIENT_YELLOW_RGB
        # Idem pour scale_min == scale_max (dΓ©jΓ  couvert par
        # test_zero_span_returns_yellow plus haut, mais la cohΓ©rence
        # de comportement est explicite ici).


# ──────────────────────────────────────────────────────────────────
# color_single_gradient
# ──────────────────────────────────────────────────────────────────
class TestColorSingleGradient:
    def test_zero_returns_start(self) -> None:
        assert color_single_gradient(0.0, end_rgb=(30, 58, 138)) == "#ffffff"

    def test_max_returns_end(self) -> None:
        assert color_single_gradient(1.0, end_rgb=(30, 58, 138)) == "#1e3a8a"

    def test_half_is_midpoint(self) -> None:
        c = color_single_gradient(0.5, end_rgb=(30, 58, 138))
        # ((255+30)/2, (255+58)/2, (255+138)/2) β‰ˆ (142, 156, 196) β†’ ~#8e9cc4
        r, g, b = _hex_to_rgb(c)
        assert abs(r - 142) <= 1
        assert abs(g - 156) <= 1
        assert abs(b - 196) <= 1

    def test_above_max_clamped(self) -> None:
        assert color_single_gradient(2.0, end_rgb=(30, 58, 138)) == "#1e3a8a"

    def test_below_zero_clamped(self) -> None:
        assert color_single_gradient(-1.0, end_rgb=(30, 58, 138)) == "#ffffff"

    def test_custom_max_value(self) -> None:
        # value = 50, max = 100 β†’ 0.5 β†’ milieu
        c = color_single_gradient(50.0, end_rgb=(30, 58, 138), max_value=100.0)
        r, g, b = _hex_to_rgb(c)
        assert abs(r - 142) <= 1

    def test_custom_start_rgb(self) -> None:
        c = color_single_gradient(0.0, end_rgb=(30, 58, 138), start_rgb=(0, 0, 0))
        assert c == "#000000"

    def test_zero_max_value_returns_start(self) -> None:
        # Garde-fou contre division par zΓ©ro
        assert color_single_gradient(5.0, end_rgb=(30, 58, 138), max_value=0) == "#ffffff"


# ──────────────────────────────────────────────────────────────────
# color_diverging
# ──────────────────────────────────────────────────────────────────
class TestColorDiverging:
    def test_zero_returns_neutral(self) -> None:
        c = color_diverging(0.0)
        # Neutre = vert par dΓ©faut
        assert _hex_to_rgb(c) == (130, 200, 130)

    def test_positive_max_returns_positive_color(self) -> None:
        c = color_diverging(1.0, max_abs=1.0)
        assert _hex_to_rgb(c) == DIVERGING_POSITIVE_RGB

    def test_negative_max_returns_negative_color(self) -> None:
        c = color_diverging(-1.0, max_abs=1.0)
        assert _hex_to_rgb(c) == DIVERGING_NEGATIVE_RGB

    def test_clamping_above_max_abs(self) -> None:
        assert _hex_to_rgb(color_diverging(5.0, max_abs=1.0)) == DIVERGING_POSITIVE_RGB

    def test_clamping_below_negative_max_abs(self) -> None:
        assert _hex_to_rgb(color_diverging(-5.0, max_abs=1.0)) == DIVERGING_NEGATIVE_RGB

    def test_zero_max_abs_returns_neutral(self) -> None:
        c = color_diverging(5.0, max_abs=0)
        assert _hex_to_rgb(c) == (130, 200, 130)


# ──────────────────────────────────────────────────────────────────
# text_color_for_bg
# ──────────────────────────────────────────────────────────────────
class TestTextColorForBg:
    def test_low_intensity_dark_text(self) -> None:
        assert text_color_for_bg(0.2) == "#222"

    def test_high_intensity_white_text(self) -> None:
        assert text_color_for_bg(0.8) == "#fff"

    def test_threshold_boundary(self) -> None:
        assert text_color_for_bg(0.55) == "#222"
        assert text_color_for_bg(0.56) == "#fff"

    def test_custom_threshold(self) -> None:
        assert text_color_for_bg(0.4, threshold=0.3) == "#fff"


# ──────────────────────────────────────────────────────────────────
# build_grid_svg
# ──────────────────────────────────────────────────────────────────
class TestBuildGridSvg:
    def test_empty_grid_returns_empty_string(self) -> None:
        svg = build_grid_svg(
            n_rows=0,
            n_cols=0,
            row_label_fn=lambda i: "",
            col_label_fn=lambda j: "",
            cell_color_fn=lambda i, j: "#fff",
        )
        assert svg == ""

    def test_zero_rows_returns_empty(self) -> None:
        svg = build_grid_svg(
            n_rows=0,
            n_cols=3,
            row_label_fn=lambda i: "",
            col_label_fn=lambda j: str(j),
            cell_color_fn=lambda i, j: "#fff",
        )
        assert svg == ""

    def test_minimal_grid_renders(self) -> None:
        svg = build_grid_svg(
            n_rows=1,
            n_cols=1,
            row_label_fn=lambda i: "row0",
            col_label_fn=lambda j: "col0",
            cell_color_fn=lambda i, j: "#abc123",
        )
        assert "<svg" in svg
        assert "row0" in svg
        assert "col0" in svg
        assert "#abc123" in svg

    def test_cell_text_displayed_when_provided(self) -> None:
        svg = build_grid_svg(
            n_rows=1,
            n_cols=1,
            row_label_fn=lambda i: "r",
            col_label_fn=lambda j: "c",
            cell_color_fn=lambda i, j: "#fff",
            cell_text_fn=lambda i, j: "0.42",
        )
        assert "0.42" in svg

    def test_cell_text_omitted_when_none(self) -> None:
        svg = build_grid_svg(
            n_rows=1,
            n_cols=1,
            row_label_fn=lambda i: "r",
            col_label_fn=lambda j: "c",
            cell_color_fn=lambda i, j: "#fff",
            cell_text_fn=lambda i, j: None,
        )
        # Pas de chiffre dans la cellule
        assert ">0.<" not in svg

    def test_rotate_col_labels(self) -> None:
        svg = build_grid_svg(
            n_rows=1,
            n_cols=1,
            row_label_fn=lambda i: "r",
            col_label_fn=lambda j: "long_label",
            cell_color_fn=lambda i, j: "#fff",
            rotate_col_labels=True,
        )
        assert "rotate(-45" in svg

    def test_x_axis_title(self) -> None:
        svg = build_grid_svg(
            n_rows=1,
            n_cols=1,
            row_label_fn=lambda i: "r",
            col_label_fn=lambda j: "c",
            cell_color_fn=lambda i, j: "#fff",
            x_axis_title="Position dans le document",
        )
        assert "Position dans le document" in svg

    def test_html_escape_in_labels(self) -> None:
        svg = build_grid_svg(
            n_rows=1,
            n_cols=1,
            row_label_fn=lambda i: "<script>alert(1)</script>",
            col_label_fn=lambda j: "<b>bold</b>",
            cell_color_fn=lambda i, j: "#fff",
            cell_text_fn=lambda i, j: "<i>italic</i>",
        )
        assert "<script>" not in svg
        assert "&lt;script&gt;" in svg
        assert "<b>" not in svg
        assert "&lt;b&gt;" in svg
        assert "<i>" not in svg

    def test_grid_dimensions(self) -> None:
        svg = build_grid_svg(
            n_rows=3,
            n_cols=4,
            row_label_fn=lambda i: f"r{i}",
            col_label_fn=lambda j: f"c{j}",
            cell_color_fn=lambda i, j: "#fff",
        )
        # 12 cellules attendues (3Γ—4)
        assert svg.count("<rect") == 12
        # 3 Γ©tiquettes de ligne + 4 de colonne
        # On compte les <text> qui ne sont pas dans une cellule (donc qui sont des labels) :
        # 3 labels lignes + 4 labels colonnes = 7. Pas de cell_text_fn donc 0.
        assert svg.count("<text") == 7

    def test_aria_label_present(self) -> None:
        svg = build_grid_svg(
            n_rows=1,
            n_cols=1,
            row_label_fn=lambda i: "r",
            col_label_fn=lambda j: "c",
            cell_color_fn=lambda i, j: "#fff",
            aria_label="Test heatmap",
        )
        assert 'aria-label="Test heatmap"' in svg