File size: 28,088 Bytes
46f1a78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27c0524
 
46f1a78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27c0524
 
 
46f1a78
 
 
27c0524
 
46f1a78
 
 
 
 
 
 
 
 
 
 
 
 
 
27c0524
46f1a78
 
27c0524
 
 
 
 
 
46f1a78
 
 
 
27c0524
46f1a78
 
27c0524
 
46f1a78
 
 
27c0524
 
46f1a78
 
 
 
 
 
 
 
 
27c0524
46f1a78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27c0524
 
46f1a78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c201e66
 
 
 
 
 
 
 
46f1a78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bc0c291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
05562d4
 
 
 
 
 
 
 
 
 
 
 
 
 
bc0c291
 
 
 
 
 
 
 
 
 
 
98d7a3e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c201e66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f2deae
 
 
c201e66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0ac64a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b656fb9
f0ac64a
 
b656fb9
 
 
 
 
 
 
 
 
 
 
 
 
f0ac64a
 
 
 
 
b656fb9
f0ac64a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c425891
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
"""Phase 3 acceptance: the UI renders, and every displayed number is traceable.

These run against the real cached store when it is present, and skip cleanly
when it is not (a fresh clone with no `.cache/store` yet).
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

import pandas as pd
import plotly.graph_objects as go
import pytest

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

import app as bitapp
from src import charts, comparisons, config, runtime, strategies
from src.ui import components as C
from src.ui.format import money, num, pct
from src.runtime import RunRequest


def _store_ready() -> bool:
    try:
        m = runtime.get_store().load_manifest()
        return len(m.prices) > 0
    except Exception:
        return False


pytestmark = pytest.mark.skipif(not _store_ready(),
                                reason="no cached signal store available")


@pytest.fixture(scope="module")
def rec():
    return runtime.execute(RunRequest(
        strategy="SMA Crossover", asset="BTC-USD", timeframe="1d", date_range="3Y",
    ))


@pytest.fixture(scope="module")
def three_runs():
    reqs = [
        RunRequest(strategy="SMA Crossover", asset="BTC-USD", timeframe="1d", date_range="3Y"),
        RunRequest(strategy="RSI Mean Reversion", asset="ETH-USD", timeframe="1d", date_range="3Y"),
        RunRequest(strategy="Buy & Hold (benchmark)", asset="SPY", timeframe="1d", date_range="3Y"),
    ]
    return [runtime.execute(r) for r in reqs]


# --------------------------------------------------------------------------
# App construction
# --------------------------------------------------------------------------


def test_app_object_exists():
    assert bitapp.demo is not None


def test_theme_css_carries_the_design_tokens():
    from src.ui import theme

    css = theme.full_css()
    for token in ("--bg-canvas", "--accent-amber", "--fin-up", "--font-styrene"):
        assert token in css, f"{token} missing from theme CSS"
    assert "#161512" in css  # stone-950 canvas
    assert "#af9209" in css  # accent amber


def test_disclaimer_is_present_in_the_footer():
    assert "not indicative of future results" in C.footer()
    assert "Not financial advice" in C.footer() or \
           "not a licensed investment adviser" in C.footer()


def test_empty_state_offers_the_worked_example():
    assert "No run loaded" in C.empty_state()
    assert "worked example" in C.empty_state()


def test_glossary_covers_every_design_term():
    terms = {t for t, _ in bitapp.GLOSSARY}
    assert {"SHARPE", "SORTINO", "MAX DRAWDOWN", "PROFIT FACTOR",
            "R-MULTIPLE", "MAE / MFE", "WALK-FORWARD", "OOS"} <= terms


# --------------------------------------------------------------------------
# The stat band must match engine output exactly
# --------------------------------------------------------------------------


def test_stat_band_values_match_engine_metrics(rec):
    html = C.stat_band(rec)
    m = rec.result.metrics_all

    assert pct(m.total_return) in html
    assert pct(m.cagr) in html
    assert num(m.sharpe) in html
    assert num(m.sortino) in html
    assert pct(m.max_drawdown) in html
    assert num(m.profit_factor) in html
    assert f">{m.trade_count}<" in html


def test_stat_band_shows_is_and_oos_for_every_stat(rec):
    html = C.stat_band(rec)
    assert html.count("IS ") >= 9
    assert html.count("· OOS") >= 9
    assert num(rec.result.metrics_oos.sharpe) in html
    assert num(rec.result.metrics_is.sharpe) in html


def test_stat_band_reports_costs_actually_paid(rec):
    html = C.stat_band(rec)
    assert money(rec.result.costs_paid) in html
    assert rec.result.costs_paid > 0, "costs default to ON, so this must be positive"


def test_empty_segment_renders_an_em_dash_not_a_zero():
    """A segment with no bars must not read as 0.00."""
    short = runtime.execute(RunRequest(
        strategy="SMA Crossover", asset="BTC-USD", timeframe="1h", date_range="1Y"))
    if short.result.metrics_oos.bars:
        pytest.skip("this range did produce OOS windows")
    html = C.stat_band(short)
    assert "· OOS —" in html
    assert "no out-of-sample windows" in html


def test_trade_table_rows_match_the_engine_trade_list(rec):
    df = bitapp.trades_frame(rec)
    assert len(df) == len(rec.result.trades)
    if len(df):
        assert df["Net"].iloc[0] == pytest.approx(
            round(float(rec.result.trades["net_pnl"].iloc[0]), 2))
        assert set(df["Segment"]) <= {"IS", "OOS", "holdout"}
        assert (df["Costs"] >= 0).all()


def test_report_quotes_the_same_numbers_as_the_stat_band(rec):
    md = bitapp.report_markdown(rec)
    assert pct(rec.result.metrics_all.total_return) in md
    assert money(rec.result.costs_paid) in md
    assert rec.run_id in md


def test_costs_off_is_called_out_as_not_real():
    off = runtime.execute(RunRequest(
        strategy="SMA Crossover", asset="BTC-USD", timeframe="1d",
        date_range="3Y", costs_on=False))
    _, _, _, _, _, _, _, _, note = bitapp.build_overview(off, log_scale=False, cvd=False)
    assert "COSTS ARE OFF" in note
    assert off.result.costs_paid == 0.0


# --------------------------------------------------------------------------
# Charts
# --------------------------------------------------------------------------


def test_overview_builds_every_figure(rec):
    figs = bitapp.build_overview(rec, log_scale=False, cvd=False)
    assert len(figs) == 9
    for f in figs[:8]:
        assert isinstance(f, go.Figure)
    assert isinstance(figs[8], str)


def test_log_scale_and_colorblind_variants_render(rec):
    for log_s in (False, True):
        for cb in (False, True):
            figs = bitapp.build_overview(rec, log_scale=log_s, cvd=cb)
            assert isinstance(figs[0], go.Figure)


def test_equity_chart_marks_the_holdout_band(rec):
    fig = charts.equity_curve(rec.result.equity, rec.result.benchmark_equity,
                              plan=rec.result.plan)
    if rec.result.plan.holdout_start is not None:
        texts = [str(a.text) for a in fig.layout.annotations]
        assert any("HOLDOUT" in t for t in texts)


def test_charts_survive_empty_inputs():
    empty = pd.Series(dtype="float64")
    assert isinstance(charts.equity_curve(empty), go.Figure)
    assert isinstance(charts.underwater_chart(empty), go.Figure)
    assert isinstance(charts.pnl_histogram(pd.DataFrame()), go.Figure)
    assert isinstance(charts.mae_mfe_scatter(pd.DataFrame()), go.Figure)
    assert isinstance(charts.walk_forward_bars([]), go.Figure)
    assert isinstance(charts.monte_carlo_cone(None), go.Figure)


def test_colorblind_palette_differs_from_default():
    assert charts.up_color(cvd=True) != charts.up_color(cvd=False)
    assert charts.down_color(cvd=True) != charts.down_color(cvd=False)


# --------------------------------------------------------------------------
# Comparison tab with three runs
# --------------------------------------------------------------------------


def test_comparison_renders_three_runs(three_runs):
    curves = {r.label[:28]: r.result.equity for r in three_runs}
    rets = {r.label[:28]: r.result.equity.pct_change().dropna() for r in three_runs}

    assert len(curves) == 3
    assert isinstance(charts.overlaid_returns(curves), go.Figure)
    assert isinstance(charts.small_multiples(curves), go.Figure)

    corr = charts.correlation_matrix(rets)
    assert isinstance(corr, go.Figure)
    assert len(corr.data[0].z) == 3


def test_return_overlay_caps_the_series_it_will_draw():
    """The old six-run session picker was replaced by the catalog view; the
    readability cap now lives in the chart itself."""
    import numpy as np

    idx = pd.date_range("2024-01-01", periods=30, freq="D", tz="UTC")
    curves = {f"s{i}": pd.Series(np.linspace(0, 1, 30), index=idx) for i in range(50)}
    assert len(charts.multi_return_overlay(curves, max_series=6).data) == 6


def test_precomputed_heatmap_loads_from_the_store():
    heat = comparisons.load_table(runtime.get_store(), comparisons.HEATMAP)
    if heat.empty:
        pytest.skip("comparison tables not generated yet")
    assert {"asset", "strategy", "timeframe", "oos_sharpe"} <= set(heat.columns)
    assert isinstance(charts.strategy_timeframe_heatmap(heat), go.Figure)


def test_regime_breakdown_covers_the_named_regimes(rec):
    df = runtime.regime_breakdown(rec)
    if df.empty:
        pytest.skip("no regime variation in this window")
    assert set(df["regime"]) <= {"BULL", "BEAR", "CHOP"}


# --------------------------------------------------------------------------
# Coverage map & share links
# --------------------------------------------------------------------------


def test_coverage_map_renders_from_the_live_manifest():
    df = runtime.coverage_frame()
    assert not df.empty
    assert {"Model", "Asset", "TF", "Coverage", "Rows", "Real?"} <= set(df.columns)


def test_placeholder_slices_are_labelled_in_the_coverage_map():
    df = runtime.coverage_frame()
    assert set(df["Real?"]) <= {"real", "PLACEHOLDER"}


def test_share_link_round_trips():
    req = RunRequest(strategy="RSI Mean Reversion", asset="ETH-USD",
                     timeframe="1h", date_range="1Y", params={"rsi_period": 21})
    again = RunRequest.decode(req.encode())
    assert again.strategy == req.strategy
    assert again.asset == req.asset
    assert again.params["rsi_period"] == 21


@pytest.mark.parametrize("payload", [
    '{"strategy":"__import__(\'os\').system","asset":"BTC-USD"}',
    '{"strategy":"SMA Crossover","asset":"../../etc/passwd"}',
    '{"strategy":"SMA Crossover","asset":"BTC-USD","timeframe":"99y"}',
    '{"strategy":"SMA Crossover","asset":"BTC-USD","validation_mode":"eval"}',
])
def test_hostile_share_links_are_rejected(payload):
    import base64

    token = base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=")
    with pytest.raises(ValueError):
        RunRequest.decode(token)


def test_unavailable_presets_are_listed_but_refuse_to_run():
    assert "Custom (code)" in strategies.PRESETS
    assert not strategies.PRESETS["Custom (code)"].available
    with pytest.raises(runtime.RunError, match="never executes untrusted code"):
        runtime.execute(RunRequest(strategy="Custom (code)", asset="BTC-USD"))


# --------------------------------------------------------------------------
# Performance budget
# --------------------------------------------------------------------------


def test_default_three_year_daily_run_is_under_two_seconds():
    runtime.cache_clear()
    req = RunRequest(strategy="SMA Crossover", asset="BTC-USD",
                     timeframe="1d", date_range="3Y")
    runtime.execute(req)  # warm the slice cache
    import time

    t0 = time.perf_counter()
    runtime.execute(req)
    assert time.perf_counter() - t0 < 2.0


# --------------------------------------------------------------------------
# Design-system fidelity
#
# The original bug this guards: base.css was vendored into assets/ but never
# loaded, so the app silently inherited Gradio's rounded, roomy defaults and
# only approximated the design.
# --------------------------------------------------------------------------


def test_every_global_stylesheet_the_design_system_declares_is_loaded():
    """The DS manifest lists six globalCssPaths; all of them must reach the page."""
    from src.ui import theme

    css = theme.full_css()
    # colors / typography / spacing / base, each identified by a rule only it has
    markers = {
        "colors.css": "--accent-amber:#af9209",
        "typography.css": "--text-2xs:8px",
        "spacing.css": "--space-4:16px",
        "base.css": "border-radius:0 !important",
    }
    for name, marker in markers.items():
        assert marker in css, f"{name} is not loaded into the page"


def test_base_css_square_corner_reset_is_present():
    from src.ui import theme

    assert "border-radius:0 !important" in theme.full_css()
    # The app's own shell must not reintroduce a rounded default on top of the
    # reset. base.css legitimately defines opt-in `.bit-rounded*` utilities, so
    # only our shell layer is checked here.
    assert "border-radius" not in theme.SHELL_CSS
    assert "border-radius" not in theme.GRADIO_RESET


def test_base_css_scrollbar_and_selection_rules_survive():
    from src.ui import theme

    css = theme.full_css()
    assert "::-webkit-scrollbar" in css
    assert "::selection" in css


def test_data_surfaces_stay_selectable_despite_base_css():
    """base.css sets user-select:none globally; tables must opt back in."""
    from src.ui import theme

    css = theme.full_css()
    assert "user-select: text" in css
    assert ".bit-table" in css.split("user-select: text")[0][-400:] or \
           "table, table *" in css


def test_gradio_layout_spacing_is_configured_not_left_default():
    """Gradio defaults to layout_gap *spacing_xxl and block_padding *spacing_xl."""
    from src.ui import theme

    t = theme.bit_theme()
    assert t.layout_gap == "8px"
    assert t.block_padding == "0px"
    assert t.form_gap_width == "0px"


def test_gradio_radius_is_squared_off():
    from src.ui import theme

    t = theme.bit_theme()
    for attr in ("block_radius", "input_radius", "button_large_radius",
                 "button_small_radius", "container_radius"):
        assert getattr(t, attr) == "0px", f"{attr} is not square"


def test_type_scale_matches_the_designs_dominant_sizes():
    """The design's workhorse is --text-xs (10px) with --text-2xs (8px) micro."""
    from src.ui import theme

    t = theme.bit_theme()
    assert t.body_text_size == "10px"
    assert t.block_label_text_size == "8px"


def test_no_hardcoded_pixel_font_sizes_outside_the_theme():
    """Component markup must size through tokens, never literal px."""
    import re
    from pathlib import Path

    root = Path(__file__).resolve().parent.parent
    for rel in ("src/ui/components.py", "src/ui/compare_tab.py", "app.py"):
        text = (root / rel).read_text()
        assert not re.search(r"font-size:\s*\d", text), \
            f"{rel} hardcodes a pixel font-size instead of using a token"


def test_every_bit_class_used_in_markup_has_a_css_rule():
    """A class with no rule renders unstyled and silently breaks the design."""
    import re
    from pathlib import Path

    from src.ui import theme

    root = Path(__file__).resolve().parent.parent
    used = set()
    for rel in ("src/ui/components.py", "src/ui/compare_tab.py", "app.py"):
        text = (root / rel).read_text()
        # Only real class attributes -- a bare `bit-…` token can appear in a
        # comment or an asset filename and is not a class the page uses.
        for attr in (re.findall(r'class="([^"]*)"', text)
                     + re.findall(r"elem_classes=[\"']([^\"']+)", text)):
            for cls in attr.split():
                if not cls.startswith("bit-"):
                    continue
                # Class names are built in f-strings, e.g. `bit-chip{cls}` or
                # `bit-podium-{rank}`. Keep the static prefix; a name that is
                # only a prefix (ends in `-`) has variant rules instead.
                static = cls.split("{", 1)[0]
                if static and not static.endswith("-"):
                    used.add(static)
    defined = set(re.findall(r"\.(bit-[a-z0-9-]+)", theme.full_css()))
    missing = sorted(c for c in used if c not in defined and not c.endswith("-"))
    assert not missing, f"classes used in markup but never styled: {missing}"


def test_fonts_are_served_for_every_weight_the_design_uses():
    from src.ui import theme

    css = theme.full_css()
    assert css.count("@font-face") >= 5
    assert "Styrene A" in css and "Mac Minecraft" in css


def test_app_constructs_without_any_hugging_face_credentials(monkeypatch):
    """Reading and backtesting are open to anyone, so the app must build with
    no token at all. Gradio's off-Space OAuth mock calls whoami and raises
    without one, which previously made the whole app unconstructable."""
    import importlib

    for var in ("HF_TOKEN", "HF_WRITE_TOKEN", "HUGGING_FACE_HUB_TOKEN", "SPACE_ID"):
        monkeypatch.delenv(var, raising=False)

    import app as fresh

    importlib.reload(fresh)
    assert fresh.demo is not None


# --------------------------------------------------------------------------
# Design-markup rendering (shell) and the click bridge
# --------------------------------------------------------------------------


def test_left_panel_is_design_markup_not_gradio_components():
    """Every control in the left panel must be a real button or input."""
    from src.ui import shell

    st = bitapp.default_state()
    html = bitapp.render_left(st)
    assert 'width:286px' in html, "zone width is not the design's 286px"
    assert html.count("<button") >= 12, "controls are not real buttons"
    assert "data-bit=" in html, "buttons are not wired to the bridge"
    # Gradio's own control DOM must not appear here.
    assert "<fieldset" not in html
    assert 'type="radio"' not in html


def test_left_panel_uses_the_designs_exact_spacing():
    html = bitapp.render_left(bitapp.default_state())
    for value in ("padding:9px 12px",     # panel header
                  "padding:0 12px 12px",  # section body
                  "gap:10px",             # section body gap
                  "padding:3px 7px",      # chips
                  "padding:8px 12px"):    # accordion header
        assert value in html, f"design spacing {value!r} missing"


def test_top_bar_uses_the_real_mark_and_design_header_treatment():
    from src.ui import shell

    html = shell.top_bar(context="BTC-USD", status="RUN COMPLETE", tone="ok")
    assert "M50 10H90V90H50Z" in html, "not the design's mark"
    assert "padding:8px 12px" in html
    assert "margin-top:3px" in html, "optical alignment on the title is missing"
    assert "mono-data" in html, "design-system utility class not used"


def test_selected_chip_is_the_only_active_one():
    st = bitapp.default_state()
    st["timeframe"] = "1h"
    html = bitapp.render_left(st)
    actives = re.findall(
        r'<button data-bit="(tf:[^"]+)"[^>]*background:var\(--accent-amber\)', html)
    assert actives == ["tf:1h"]


def test_html_entities_are_escaped_exactly_once():
    html = bitapp.render_left(bitapp.default_state())
    assert "&amp;amp;" not in html, "double-escaped entity"
    assert "Universe &amp; Data" in html


@pytest.mark.parametrize("action,key,expected", [
    ("tf:15m", "timeframe", "15m"),
    ("asset:ETH-USD", "asset", "ETH-USD"),
    ("range:1Y", "range", "1Y"),
    ("validation:holdout", "validation_mode", "holdout"),
    ("sizing:vol_target", "sizing_mode", "vol_target"),
    ("slippage:volume_scaled", "slippage_model", "volume_scaled"),
])
def test_actions_fold_into_state(action, key, expected):
    st, ran = bitapp.apply_action(bitapp.default_state(), f"{action}|nonce")
    assert st[key] == expected
    assert ran is False


def test_run_and_example_actions_request_a_run():
    _, ran = bitapp.apply_action(bitapp.default_state(), "run:|n")
    assert ran is True
    st, ran = bitapp.apply_action(bitapp.default_state(), "example:|n")
    assert ran is True and st["strategy"] == "Chronos Forecast Follower"


def test_accordion_action_toggles():
    st = bitapp.default_state()
    before = st["acc"]["costs"]
    st, _ = bitapp.apply_action(st, "acc:costs|n")
    assert st["acc"]["costs"] is not before


def test_numeric_param_commits_through_the_bridge():
    st, _ = bitapp.apply_action(bitapp.default_state(), "param:commission_bps=25|n")
    assert st["commission_bps"] == 25.0


def test_strategy_param_commits_and_survives_preset_defaults():
    st = bitapp.default_state()
    st, _ = bitapp.apply_action(st, "param:fast_ma=33|n")
    assert st["params"]["fast_ma"] == 33.0


@pytest.mark.parametrize("bad", [
    "param:commission_bps=nonsense", "param:commission_bps=", "topn:abc",
    "tf:99y", "asset:../etc/passwd", "strategy:__import__",
])
def test_malformed_actions_leave_state_untouched(bad):
    before = bitapp.default_state()
    after, ran = bitapp.apply_action(bitapp.default_state(), f"{bad}|n")
    assert ran is False
    assert after["timeframe"] == before["timeframe"]
    assert after["asset"] == before["asset"]
    assert after["strategy"] == before["strategy"]
    assert after["commission_bps"] == before["commission_bps"]


def test_unknown_action_keys_are_dropped():
    from src.ui.bridge import parse_action

    assert parse_action("evil:rm -rf|n") is None
    assert parse_action("os.system:x|n") is None


def test_bridge_survives_values_containing_spaces():
    """Preset names contain spaces; the nonce separator must not collide."""
    from src.ui.bridge import emit, parse_action

    raw = emit("strategy", "Buy & Hold (benchmark)")
    assert parse_action(raw + "|nonce").value == "Buy & Hold (benchmark)"


def test_tables_render_as_markup_not_dataframes():
    df = pd.DataFrame({"A": [1, 2], "B": ["x", "y"]})
    html = bitapp.render_table(df)
    assert "<table" in html and "pixel-text" in html
    assert "bit-selectable" in html, "table text must stay selectable"


def test_empty_table_says_so():
    assert "no rows" in bitapp.render_table(pd.DataFrame(), empty="no rows")


def test_zone_widths_are_pinned_in_css():
    from src.ui import theme

    css = theme.full_css()
    assert "flex:0 0 286px" in css, "left aside is not pinned to the design width"
    # The right tray was removed; its rules should not linger.
    assert "bit-zone-right" not in css


def test_bridge_target_is_hidden_but_present():
    from src.ui import theme

    css = theme.full_css()
    assert "#bit-action" in css
    assert "opacity:0" in css.split("#bit-action")[1][:220]


def test_gradio_content_wrapper_is_stretched_full_bleed():
    """Gradio centres the app in a padded `.contain`, which cost 208px of a
    1680px viewport. The design is a full-bleed dashboard."""
    from src.ui import theme

    import re

    css = theme.full_css()
    # Match the rule whose selector list starts with `.contain`, not the
    # `.container` rule -- one is a prefix of the other.
    m = re.search(r"\.gradio-container \.contain,(.*?)\{(.*?)\}", css, re.S)
    assert m, "no full-bleed rule for Gradio's .contain wrapper"
    body = m.group(2)
    assert "max-width:100% !important" in body
    assert "padding:0 !important" in body


# --------------------------------------------------------------------------
# Regressions from the header/nav restructure
# --------------------------------------------------------------------------


def test_header_carries_nav_login_and_glossary():
    from src.ui import shell

    html = shell.top_bar(tab="backtest", glossary=bitapp.GLOSSARY, on_space=True)
    assert 'data-bit="tab:compare"' in html
    assert 'data-bit="tab:backtest"' in html
    assert "bit-help" in html, "glossary tooltip missing from the header"


def test_signed_in_header_shows_the_handle():
    from src.ui import shell

    assert "@alice" in shell.top_bar(user="alice", on_space=True)


def test_sign_in_uses_a_real_login_button_not_a_handrolled_link():
    """Gradio only mounts /login/huggingface when it sees a LoginButton in the
    app. A hand-rolled anchor points at a route that returns 404."""
    import inspect
    from src.ui import shell

    src = inspect.getsource(bitapp.build_app)
    assert "gr.LoginButton" in src, "no LoginButton, so OAuth routes never mount"
    # and the markup must not fake one
    assert "/login/huggingface" not in shell.top_bar(on_space=True)


def test_off_space_header_explains_sign_in_is_unavailable():
    from src.ui import shell

    assert "SIGN IN" in shell.top_bar(on_space=False)


def test_context_chip_only_appears_on_the_backtest_tab():
    """On Compare, a run context chip would describe something not on screen."""
    st = bitapp.default_state()
    st["tab"] = "compare"

    class FakeRec:
        run_id = "abcd1234"
        elapsed_s = 1.0

    # The chip is the only place the asset and timeframe appear together.
    # "WALK-FORWARD" alone is ambiguous -- it is also a glossary term.
    compare_header = bitapp.render_top(st, FakeRec())
    assert "BTC-USD · 1D" not in compare_header

    st["tab"] = "backtest"
    assert "BTC-USD · 1D" in bitapp.render_top(st, FakeRec())


def test_tab_action_switches_views():
    st, _ = bitapp.apply_action(bitapp.default_state(), "tab:backtest|n1")
    assert st["tab"] == "backtest"
    st, _ = bitapp.apply_action(st, "tab:compare|n2")
    assert st["tab"] == "compare"


def test_unknown_tab_value_is_ignored():
    st, _ = bitapp.apply_action(bitapp.default_state(), "tab:../admin|n")
    assert st["tab"] == "compare"


def test_the_same_click_cannot_run_twice():
    """Two event bindings deliver one click; the nonce must de-duplicate it."""
    st = bitapp.default_state()
    st, first = bitapp.apply_action(st, "run:|nonce-a")
    _, replay = bitapp.apply_action(st, "run:|nonce-a")
    assert first is True and replay is False


def test_a_fresh_click_still_runs_after_a_deduped_one():
    st = bitapp.default_state()
    st, _ = bitapp.apply_action(st, "run:|nonce-a")
    _, again = bitapp.apply_action(st, "run:|nonce-b")
    assert again is True


def test_running_switches_to_the_backtest_tab():
    st = bitapp.default_state()
    assert st["tab"] == "compare"
    st, should_run = bitapp.apply_action(st, "example:|n")
    assert should_run is True


def test_right_sidebar_is_gone():
    import inspect

    src = inspect.getsource(bitapp.build_app)
    assert "bit-zone-right" not in src
    assert "right_panel" not in src


def test_bridge_elements_are_rendered_not_visible_false():
    """`visible=False` removes an element from the DOM, which left the click
    bridge with nothing to write into."""
    import inspect

    src = inspect.getsource(bitapp.build_app)
    box = src[src.index("action_box = gr.Textbox"):]
    assert "visible=True" in box[:260]
    trig = src[src.index("action_trigger = gr.Button"):]
    assert "visible=True" in trig[:200]


def test_bridge_js_is_loaded_without_outputs():
    """Gradio treats a `js=` return value as the output values, so the bridge
    installer must not share a load call that has outputs."""
    import inspect

    src = inspect.getsource(bitapp.build_app)
    assert "demo.load(fn=None, inputs=None, outputs=None, js=BRIDGE_LOAD_JS)" in src


def test_no_oauth_annotated_load_handler():
    """A `demo.load` handler that takes `gr.OAuthProfile` asks for an
    authenticated session on every page render. An unauthenticated visitor is
    sent to sign in, returns, fires load again, and is sent back -- an infinite
    redirect. The profile belongs on user-initiated handlers only."""
    import inspect
    import re

    src = inspect.getsource(bitapp.build_app)
    # Strip comments so the explanation of this rule does not trip it.
    code = "\n".join(ln for ln in src.splitlines()
                     if not ln.lstrip().startswith("#"))
    assert "OAuthProfile" not in code, \
        "an OAuth-annotated handler is registered in build_app"

    for call in re.findall(r"demo\.load\((.*?)\)\n", code, re.S):
        assert "profile" not in call, f"load handler takes a profile: {call[:80]}"


def test_profile_is_read_only_on_user_initiated_handlers():
    """Where the profile *is* needed, it must hang off a click, not a load."""
    import inspect

    from src import extension

    for fn in (extension.extend_ui, extension.add_model_ui):
        params = inspect.signature(fn).parameters
        assert "profile" in params, f"{fn.__name__} should receive the profile"


def test_login_button_is_the_only_sign_in_mechanism():
    import inspect

    from src.ui import shell

    src = inspect.getsource(bitapp.build_app)
    assert "gr.LoginButton" in src
    # The header must not also hand-roll a link to the OAuth route.
    assert "/login/huggingface" not in shell.top_bar(on_space=True)