File size: 15,110 Bytes
93f9cc0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
# -*- coding: utf-8 -*-
"""The note must render as the published note, not merely in its spirit.

The brief was asked to reproduce a specific published document exactly β€” same
colours, same placements. "Looks close" is not the requirement, so these tests
compare against the document itself rather than against a description of it.

``tests/fixtures/published_note.html`` holds the two things that can be
compared mechanically: the stylesheet, and the three figures. Everything else
about the page is data-dependent, so those assertions are structural β€” the ten
sections in their published order, six tiles in a six-column grid, a section
that keeps its slot when its data is missing.

Two divergences from the published file are deliberate, and each is asserted
here so it cannot drift into an accidental third:

* every selector carries a ``.rt-note`` prefix, because Streamlit's own base
  styles would otherwise win on specificity;
* every ``<svg>`` carries ``xmlns``, because Streamlit's sanitiser drops an
  SVG without it and the figure renders as an empty box.
"""
from __future__ import annotations

import pathlib
import re

import pytest

FIXTURE = pathlib.Path(__file__).parent / "fixtures" / "published_note.html"


@pytest.fixture(scope="module")
def published() -> str:
    return FIXTURE.read_text(encoding="utf-8")


# ── The stylesheet ───────────────────────────────────────────────────────────
def _rules(css: str) -> list[tuple[str, str]]:
    """Flatten CSS to ``(selector, sorted declarations)`` pairs, in order."""
    css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
    out = []
    for selector, block in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
        selector = " ".join(selector.split())
        if selector.startswith("@"):
            continue
        declarations = ";".join(
            sorted(d.strip() for d in block.split(";") if d.strip())
        )
        out.append((selector, declarations))
    return out


def _unscope(selector: str) -> str:
    """Strip the ``.rt-note`` prefix so selectors can be compared."""
    parts = []
    for part in selector.split(","):
        part = part.strip()
        if part == ".rt-note":
            part = "body"          # the note is a div here, a document there
        elif part.startswith(".rt-note "):
            part = part[len(".rt-note "):]
        parts.append(part)
    return ",".join(parts)


# Rules we add on top of the published stylesheet, each with its reason.
# Anything not listed here is drift, and the test says so. Empty is the goal:
# the stylesheet is currently the published one, rule for rule.
ADDED_RULES: dict[str, str] = {}


def test_stylesheet_declarations_are_the_published_ones(published):
    """Every published rule, in order, with the same declarations and values."""
    from dashboard.note_css import NOTE_CSS, TOKENS

    original = _rules(published[published.index("<style>") + 7: published.index("</style>")])
    # margin:0 belongs to the real <body> there; here the note is a div inside
    # a page that already sets it.
    original = [
        (sel, ";".join(d for d in decl.split(";") if d != "margin:0") if sel == "body" else decl)
        for sel, decl in original
    ]
    mine = [(_unscope(sel), decl) for sel, decl in _rules(TOKENS + NOTE_CSS)]

    extra = [(sel, decl) for sel, decl in mine if sel in ADDED_RULES]
    assert len(extra) == len(ADDED_RULES), (
        f"expected the additions {sorted(ADDED_RULES)}, found {[s for s, _ in extra]}"
    )
    kept = [(sel, decl) for sel, decl in mine if sel not in ADDED_RULES]

    assert len(kept) == len(original), (
        f"rule count drifted: published {len(original)}, ours {len(kept)}. "
        f"A new rule needs an entry in ADDED_RULES with its reason."
    )
    for (sel_a, decl_a), (sel_b, decl_b) in zip(original, kept):
        assert sel_a.replace(" ", "") == sel_b.replace(" ", ""), (
            f"selector drifted: published {sel_a!r}, ours {sel_b!r}"
        )
        assert decl_a == decl_b, f"{sel_a}: published {decl_a!r}, ours {decl_b!r}"


def test_every_selector_is_scoped_to_the_note():
    """An unscoped selector would restyle the whole Streamlit page."""
    from dashboard.note_css import NOTE_CSS

    for selector, _ in _rules(NOTE_CSS):
        for part in selector.split(","):
            part = part.strip()
            assert part == ".rt-note" or part.startswith(".rt-note "), (
                f"{part!r} escapes the note's scope"
            )


def test_the_light_tokens_are_the_published_values():
    """The palette the app is pinned to is the note's own light palette."""
    from dashboard.note_css import TOKENS, TOKENS_LIGHT

    def values(css: str) -> dict[str, str]:
        block = css[css.index(":root{") + 6: css.index("}", css.index(":root{"))]
        out = {}
        for decl in block.split(";"):
            if "--" in decl:
                name, _, value = decl.partition(":")
                out[name.strip()] = value.strip()
        return out

    published_light = values(TOKENS)
    pinned = values(TOKENS_LIGHT)
    for name, value in published_light.items():
        assert pinned.get(name) == value, f"{name}: pinned {pinned.get(name)!r} != {value!r}"


# ── The figures ──────────────────────────────────────────────────────────────
def _normalise(svg: str) -> str:
    """Compare on rendered output, not on the two encodings we must differ in.

    Undone here: the ``xmlns`` the sanitiser requires, and ``&#36;`` for the
    dollar sign, which st.markdown would otherwise read as LaTeX. Both render
    exactly as the published note does; everything else must match literally.
    """
    svg = re.sub(r">\s+<", "><", svg.strip())
    return svg.replace(' xmlns="http://www.w3.org/2000/svg"', "").replace("&#36;", "$")


def _fixture_figure(published: str, name: str) -> str:
    block = re.search(rf'<figure id="{name}">(.*?)</figure>', published, re.S)
    assert block, f"fixture has no figure {name!r}"
    return block.group(1)


def _footnote(svg: str) -> str:
    from html import unescape

    match = re.search(r'<text x="44" y="16" class="ct">([^<]*)</text>', svg)
    return unescape(match.group(1)) if match else ""


def test_bar_figure_geometry_is_identical(published):
    """Same bars, ticks, labels and coordinates as the published figure.

    The series is recovered from the published SVG's own geometry rather than
    from its printed labels: the note plots the unrounded value and prints the
    rounded one, so reading the labels back would move every bar by a tenth of
    a pixel and make the comparison meaningless.
    """
    from dashboard.note_charts import bars_vs_guidance

    svg = _fixture_figure(published, "revenue_vs_guidance")
    scale = (274.0 - 50.4) / 100.0
    bars = re.findall(
        r'<rect x="[\d.]+" y="([\d.]+)" width="[\d.]+" height="[\d.]+" '
        r'fill="var\((--[a-z0-9-]+)\)"',
        svg,
    )
    labels = re.findall(r'y="290" text-anchor="middle" class="ct">([^<]+)</text>', svg)
    printed = re.findall(r'class="cv">([\d,]+)</text>', svg)
    deltas = re.findall(r'y="306" text-anchor="middle" class="cb">([^<]+)</text>', svg)
    tick_y = re.findall(r'y1="([\d.]+)" y2="[\d.]+" stroke="var\(--warn\)"', svg)
    guides = [None] + [(274.0 - float(y)) / scale for y in tick_y]

    points = [
        {
            "label": label,
            "value": (274.0 - float(bar[0])) / scale,
            "value_text": printed[n],
            "guide": guides[n],
            "delta": deltas[n - 1] if n else "",
            "derived": bar[1] == "--ink-3",
        }
        for n, (bar, label) in enumerate(zip(bars, labels))
    ]
    assert len(points) == 14

    mine = bars_vs_guidance(
        points,
        label="Chiffre d’affaires trimestriel et point mΓ©dian de la guidance",
        footnote=_footnote(svg),
    )
    assert _normalise(mine) == _normalise(svg)


def test_paired_figure_geometry_is_identical(published):
    from dashboard.note_charts import paired_bars

    svg = _fixture_figure(published, "net_income_vs_fcf")
    scale = (250.0 - 59.7) / 60.0
    bars = re.findall(
        r'<rect x="[\d.]+" y="([\d.]+)" width="[\d.]+" height="[\d.]+" fill="var\(--[a-z0-9-]+\)"',
        svg,
    )
    printed = re.findall(r'class="cv">([\d,]+)</text>', svg)
    labels = re.findall(r'y="266" text-anchor="middle" class="ct">([^<]+)</text>', svg)
    ratios = re.findall(r'y="284" text-anchor="middle" class="(cb(?: warn)?)">([^<]+)</text>', svg)

    groups = [
        {
            "label": label,
            "left": (250.0 - float(bars[2 * n])) / scale,
            "left_text": printed[2 * n],
            "right": (250.0 - float(bars[2 * n + 1])) / scale,
            "right_text": printed[2 * n + 1],
            "ratio": ratios[n][1],
            "ratio_warn": "warn" in ratios[n][0],
        }
        for n, label in enumerate(labels)
    ]
    mine = paired_bars(
        groups,
        label="RΓ©sultat net contre free cash-flow par trimestre",
        footnote=_footnote(svg),
    )
    assert _normalise(mine) == _normalise(svg)


def test_line_figure_geometry_is_identical(published):
    from dashboard.note_charts import line_series

    svg = _fixture_figure(published, "days_sales_outstanding")
    scale = (226.0 - 28.0) / 40.0
    dots = re.findall(r'<circle cx="[\d.]+" cy="([\d.]+)" r="(\d)"', svg)
    printed = re.findall(r'class="cv">([\d,]+)</text>', svg)
    labels = re.findall(r'y="242" text-anchor="middle" class="ct">([^<]+)</text>', svg)

    points = [
        {
            "label": label,
            "value": 30.0 + (226.0 - float(cy)) / scale,
            "value_text": text,
            "emphasis": radius == "6",
        }
        for (cy, radius), text, label in zip(dots, printed, labels)
    ]
    mine = line_series(
        points,
        label="DΓ©lai de recouvrement clients en jours",
        footnote=_footnote(svg),
    )
    assert _normalise(mine) == _normalise(svg)


def test_figures_declare_their_namespace():
    """Without xmlns, Streamlit's sanitiser drops the SVG and the figure empties."""
    from dashboard.note_charts import bars_vs_guidance, line_series, paired_bars

    bars = bars_vs_guidance([{"label": "Q1", "value": 1.0}], label="x")
    pair = paired_bars([{"label": "Q1", "left": 1.0, "right": 0.5}], label="x")
    line = line_series([{"label": "Q1", "value": 1.0}], label="x")
    for svg in (bars, pair, line):
        assert svg.startswith('<svg xmlns="http://www.w3.org/2000/svg"')


def test_figures_never_emit_a_bare_dollar_sign():
    """A pair of raw dollar signs is read as LaTeX by st.markdown."""
    from dashboard.note_charts import bars_vs_guidance

    svg = bars_vs_guidance(
        [{"label": "Q1", "value": 1.0}], label="x", footnote="bn$ per share"
    )
    assert "$" not in svg
    assert "&#36;" in svg


def test_the_stylesheet_contains_no_angle_bracket():
    """A "<" anywhere in the CSS truncates the <style> element that carries it.

    This is not hypothetical: a comment in this stylesheet that mentioned the
    svg tag by name cut the element off at that point, dropping every rule
    after it β€” the note's grid, its sticky rail, and the app chrome CSS
    concatenated behind it. The page still rendered, just with no layout.
    """
    from dashboard.note_css import FONT_IMPORT, NOTE_CSS, TOKENS, TOKENS_LIGHT

    for name, css in (("FONT_IMPORT", FONT_IMPORT), ("TOKENS", TOKENS),
                      ("TOKENS_LIGHT", TOKENS_LIGHT), ("NOTE_CSS", NOTE_CSS)):
        assert "<" not in css, f"{name} contains '<', which truncates the style element"
        assert "</" not in css, f"{name} contains '</'"


# ── The document's structure ─────────────────────────────────────────────────
PUBLISHED_SECTIONS = (
    "resume", "print", "cash", "bilan", "segments",
    "direction", "capital", "bullbear", "watch", "annexes",
)


@pytest.fixture
def thin_brief() -> dict:
    """A brief with nothing in it β€” the case that must not lose a section."""
    return {
        "ticker": "ZZZZ",
        "company_name": "Nothing Ingested Inc",
        "filing_date": "2026-01-31",
        "what_matters_most": "",
        "what_changed": [],
        "bull_points": [],
        "bear_points": [],
        "what_to_watch": [],
    }


def _sections(html: str) -> list[str]:
    return re.findall(r'<section id="([a-z]+)"', html)


def test_sections_are_the_published_ones_in_order(thin_brief):
    """The ten sections, in the published order, from any brief."""
    from dashboard.note import build

    assert tuple(_sections(build(thin_brief, series=[], market=None, lang="fr"))) == PUBLISHED_SECTIONS


def test_a_section_with_no_data_keeps_its_slot(thin_brief):
    """A section that vanished when empty would move every heading below it."""
    from dashboard.note import build

    html = build(thin_brief, series=[], market=None, lang="en")
    assert len(_sections(html)) == len(PUBLISHED_SECTIONS)
    # and it says so rather than standing empty
    assert "ZZZZ" in html


def test_the_tile_row_is_always_six_wide(thin_brief):
    """The grid is repeat(6,1fr): five tiles would leave a hole in the rule."""
    from dashboard.note import build

    for brief, series in ((thin_brief, []),):
        html = build(brief, series=series, market=None, lang="fr")
        assert html.count('class="tile"') == 6


def test_the_document_never_emits_a_bare_dollar_sign(thin_brief):
    """st.markdown reads a pair of dollar signs as LaTeX and eats the middle."""
    from dashboard.note import build

    assert "$" not in build(thin_brief, series=[], market=None, lang="en")


def test_the_masthead_and_rail_are_always_present(thin_brief):
    from dashboard.note import build

    html = build(thin_brief, series=[], market=None, lang="fr")
    for marker in ('class="mast"', 'class="head"', 'nav class="toc"',
                   'class="grid"', "<aside>", 'class="foot"'):
        assert marker in html, f"{marker} missing"


def test_currency_follows_the_language(thin_brief):
    """"96,2 Md$" in French, "$96.2bn" in English β€” same amount, same tile."""
    from dashboard.note import _money, _per_share

    with_lang = lambda fn, lang: fn(96_200_000_000, lang=lang) if fn is _money else fn(2.46, lang=lang)
    assert _money(96_200_000_000, lang="fr") == "96,2 Md&#36;"
    assert _money(96_200_000_000, lang="en") == "&#36;96.2bn"
    assert _per_share(2.46, lang="fr") == "2,46 &#36;"
    assert _per_share(2.46, lang="en") == "&#36;2.46"


def test_rounding_is_half_up_like_a_printed_table():
    """13.25 prints as 13.3, the way the filing does β€” not 13.2."""
    from dashboard.note import _num

    assert _num(13.25, 1, lang="en") == "13.3"
    assert _num(13.25, 1, lang="fr") == "13,3"