diff --git a/pyproject.toml b/pyproject.toml index 6cebcf9..43e2cf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "xlwt>=1.3.0", # Visualization "matplotlib>=3.7.0", + "plotly>=6.0", ] [project.optional-dependencies] @@ -117,6 +118,8 @@ module = [ "matplotlib", "matplotlib.*", "numpy", + "plotly", + "plotly.*", "numpy.*", "pyumya", "pyumya.*", diff --git a/src/excelbench/results/html_dashboard.py b/src/excelbench/results/html_dashboard.py index 12ebc94..3296b2f 100644 --- a/src/excelbench/results/html_dashboard.py +++ b/src/excelbench/results/html_dashboard.py @@ -172,18 +172,25 @@ def render_html_dashboard( if memory_json and memory_json.exists(): memory = json.loads(memory_json.read_text()) - svgs: dict[str, str] = {} + # Load SVGs from scatter_dir + heatmap_svg: str | None = None + scatter_svgs: dict[str, str] = {} if scatter_dir: - for name in ("scatter_tiers", "scatter_features", "heatmap"): - svg_path = scatter_dir / f"{name}.svg" + for name, prefix in [ + ("heatmap.svg", "heatmap-"), + ("scatter_tiers.svg", "sctier-"), + ("scatter_features.svg", "scfeat-"), + ]: + svg_path = scatter_dir / name if svg_path.exists(): - svgs[name] = _namespace_svg_ids(svg_path.read_text(), f"{name}-") + scatter_svgs[name] = _namespace_svg_ids(svg_path.read_text(), prefix) + heatmap_svg = scatter_svgs.get("heatmap.svg") body_parts = [ _section_nav(has_memory=memory is not None), _section_overview(fidelity, perf), _section_matrix(fidelity), - _section_scatter(svgs), + _section_scatter(fidelity, perf, heatmap_svg, scatter_svgs), _section_comparison(fidelity, perf), _section_features(fidelity), _section_performance(perf), @@ -227,11 +234,11 @@ def render_html_dashboard( background:var(--bg);color:var(--text);font-size:14px;line-height:1.5} a{color:var(--accent);text-decoration:none} a:hover{text-decoration:underline;color:#80bfff} -.container{max-width:1440px;margin:0 auto;padding:1.5rem 1.5rem} +.container{max-width:2800px;margin:0 auto;padding:1.5rem 3rem} /* ── Nav ── */ nav{position:sticky;top:0;z-index:100;background:linear-gradient(135deg,#0a0a0a,#191919); - padding:.6rem 1.5rem;display:flex;align-items:center;gap:1.5rem; + padding:.6rem 3rem;display:flex;align-items:center;gap:1.5rem; box-shadow:0 2px 12px rgba(0,0,0,.4);border-bottom:1px solid #2d2d2d} nav .brand{font-weight:700;font-size:1.1rem;color:#ededed;letter-spacing:-.02em} nav .links{display:flex;gap:1rem;flex-wrap:wrap} @@ -381,6 +388,28 @@ def render_html_dashboard( .wolfxl-hero .wolf-text p{margin:.2rem 0 0;font-size:.82rem;color:#fbbf24;line-height:1.4} .wolfxl-hero .wolf-text a{color:#fb923c;font-weight:600} +/* ── Interactive chart containers ── */ +.chart-maximize-wrap{position:relative;margin-bottom:1rem} +.chart-maximize-btn{position:absolute;top:6px;right:6px;z-index:10; + background:rgba(25,25,25,.85);border:1px solid #2d2d2d;border-radius:6px; + color:#a0a0a0;font-size:1rem;padding:.25rem .5rem;cursor:pointer; + transition:all .15s;line-height:1} +.chart-maximize-btn:hover{background:#282828;color:#ededed;border-color:#51a8ff} +.plotly-chart{width:100%;overflow-x:auto} +.plotly-chart .js-plotly-plot{width:100%!important} + +/* ── Chart maximize modal ── */ +.chart-modal-overlay{display:none;position:fixed;inset:0;z-index:9999; + background:rgba(0,0,0,.88);align-items:center;justify-content:center;padding:2vh 2vw} +.chart-modal-overlay.active{display:flex} +.chart-modal-content{width:96vw;height:92vh;background:#0a0a0a;border-radius:12px; + border:1px solid #2d2d2d;position:relative;overflow:hidden} +.chart-modal-close{position:absolute;top:10px;right:14px;z-index:10; + background:rgba(25,25,25,.9);border:1px solid #2d2d2d;border-radius:6px; + color:#a0a0a0;font-size:1.2rem;padding:.3rem .7rem;cursor:pointer;line-height:1} +.chart-modal-close:hover{background:#282828;color:#ededed;border-color:#ff6066} +.chart-modal-body{width:100%;height:100%;overflow:auto} + /* ── Misc ── */ .btn{display:inline-block;padding:.3rem .8rem;border:1px solid #2d2d2d;border-radius:6px; font-size:.78rem;cursor:pointer;background:#191919;color:#a0a0a0} @@ -403,6 +432,45 @@ def render_html_dashboard( .container{padding:1rem} .filter-box input{width:100%} } +@media(min-width:2000px){ + body{font-size:16px} + .container{padding:2rem 4rem} + nav{padding:.7rem 4rem;gap:2rem} + nav .brand{font-size:1.3rem} + nav .links a{font-size:.95rem} + h1{font-size:2rem} + h2{font-size:1.6rem} + h3{font-size:1.25rem} + .stat-card .val{font-size:2.8rem} + .stat-card .lbl{font-size:.95rem} + .stat-card{padding:1.6rem} + table{font-size:.92rem} + th,td{padding:.55rem .75rem} + .matrix th,.matrix td{padding:.45rem .65rem;min-width:64px;font-size:.88rem} + .matrix .feat{min-width:160px} + details summary{font-size:1rem;padding:.85rem 1.2rem} + .meta-bar{font-size:.92rem} + .wolfxl-hero{padding:1.5rem 2rem} + .wolfxl-hero .wolf-text h3{font-size:1.2rem} + .wolfxl-hero .wolf-text p{font-size:.95rem} + .filter-box input{width:360px;font-size:.95rem;padding:.5rem .9rem} +} +@media(min-width:3000px){ + body{font-size:18px} + .container{padding:2.5rem 5rem} + nav{padding:.8rem 5rem} + nav .brand{font-size:1.5rem} + h1{font-size:2.4rem} + h2{font-size:1.9rem} + h3{font-size:1.45rem} + .stat-card .val{font-size:3.4rem} + .stat-card .lbl{font-size:1.05rem} + table{font-size:1rem} + th,td{padding:.6rem .85rem} + .matrix th,.matrix td{padding:.5rem .75rem;min-width:76px;font-size:.95rem} + .matrix .feat{min-width:180px} + details summary{font-size:1.1rem} +} """ # ==================================================================== @@ -469,6 +537,55 @@ def render_html_dashboard( btn.textContent=open?'Expand All':'Collapse All'; }); }); +/* Chart maximize modal — all content is same-origin Plotly output, no untrusted data */ +(function(){ + var overlay=document.createElement('div'); + overlay.className='chart-modal-overlay'; + var content=document.createElement('div'); + content.className='chart-modal-content'; + var closeBtn=document.createElement('button'); + closeBtn.className='chart-modal-close'; + closeBtn.title='Close'; + closeBtn.textContent='\u00d7'; + var modalBody=document.createElement('div'); + modalBody.className='chart-modal-body'; + content.appendChild(closeBtn); + content.appendChild(modalBody); + overlay.appendChild(content); + document.body.appendChild(overlay); + var closeModal=function(){ + overlay.classList.remove('active'); + while(modalBody.firstChild)modalBody.removeChild(modalBody.firstChild); + }; + closeBtn.addEventListener('click',closeModal); + overlay.addEventListener('click',function(e){if(e.target===overlay)closeModal();}); + document.addEventListener('keydown',function(e){if(e.key==='Escape')closeModal();}); + document.querySelectorAll('.chart-maximize-btn').forEach(function(btn){ + btn.addEventListener('click',function(){ + var wrap=btn.closest('.chart-maximize-wrap'); + var chart=wrap?wrap.querySelector('.plotly-chart'):null; + if(!chart)return; + while(modalBody.firstChild)modalBody.removeChild(modalBody.firstChild); + /* Rebuild Plotly chart in modal with full interactivity */ + var srcPlot=chart.querySelector('.js-plotly-plot'); + if(srcPlot&&srcPlot.data&&srcPlot.layout&&window.Plotly){ + var plotDiv=document.createElement('div'); + plotDiv.style.width='100%'; + plotDiv.style.height='100%'; + modalBody.appendChild(plotDiv); + overlay.classList.add('active'); + var newLayout=Object.assign({},srcPlot.layout,{autosize:true, + height:content.clientHeight-20,width:content.clientWidth-20}); + window.Plotly.newPlot(plotDiv,srcPlot.data,newLayout, + {displayModeBar:true,displaylogo:false,responsive:true}); + }else{ + /* Fallback: clone static content */ + modalBody.appendChild(chart.cloneNode(true)); + overlay.classList.add('active'); + } + }); + }); +})(); /* Smooth scroll */ document.querySelectorAll('nav a[href^="#"]').forEach(a=>{ a.addEventListener('click',e=>{ @@ -693,17 +810,57 @@ def _green(lib: str) -> int: return "\n".join(rows) -def _section_scatter(svgs: dict[str, str]) -> str: - if not svgs: +def _section_scatter( + fidelity: dict[str, Any], + perf: dict[str, Any] | None, + heatmap_svg: str | None, + scatter_svgs: dict[str, str] | None = None, +) -> str: + if not perf and not heatmap_svg and not scatter_svgs: return "" + parts = ['

Fidelity vs. Throughput

'] - for key, label in [ - ("scatter_tiers", "By Feature Group"), - ("scatter_features", "Per Feature"), - ("heatmap", "Heatmap"), - ]: - if key in svgs: - parts.append(f'

{label}

{svgs[key]}
') + + # Interactive Plotly scatter charts (preferred when perf data exists) + if perf: + from excelbench.results.scatter_interactive import ( + render_interactive_scatter_features_from_data, + render_interactive_scatter_tiers_from_data, + ) + + tiers_html = render_interactive_scatter_tiers_from_data(fidelity, perf) + features_html = render_interactive_scatter_features_from_data(fidelity, perf) + + parts.append( + '

By Feature Group

' + f'
' + f'' + f'
{tiers_html}
' + ) + parts.append( + '

Per Feature

' + f'
' + f'' + f'
{features_html}
' + ) + else: + # Fallback: embed pre-rendered static SVGs when perf data is unavailable + if scatter_svgs: + tiers_svg = scatter_svgs.get("scatter_tiers.svg") + feats_svg = scatter_svgs.get("scatter_features.svg") + if tiers_svg: + parts.append(f'

By Feature Group

' + f'
{tiers_svg}
') + if feats_svg: + parts.append(f'

Per Feature

' + f'
{feats_svg}
') + + # Static heatmap SVG (always shown if available) + if heatmap_svg: + parts.append(f'

Heatmap

{heatmap_svg}
') + parts.append("
") return "\n".join(parts) diff --git a/src/excelbench/results/scatter.py b/src/excelbench/results/scatter.py index 4bf8551..c9429e5 100644 --- a/src/excelbench/results/scatter.py +++ b/src/excelbench/results/scatter.py @@ -114,7 +114,7 @@ (0, 50, "#1a0a0a", "0"), # dark red tint (50, 80, "#1a1400", "1"), # dark amber tint (80, 100, "#0c1f12", "2"), # dark green tint (score 2) - (100, 110, "#133d1f", "3"), # brighter green tint (score 3 — visually distinct) + (100, 110, "#0e2916", "3"), # subtle green tint (score 3 — distinct but gridline-friendly) ] diff --git a/src/excelbench/results/scatter_interactive.py b/src/excelbench/results/scatter_interactive.py new file mode 100644 index 0000000..0481afb --- /dev/null +++ b/src/excelbench/results/scatter_interactive.py @@ -0,0 +1,400 @@ +"""Interactive Plotly scatter-plots: fidelity pass-rate vs. throughput. + +Drop-in replacement for the static matplotlib scatter panels. +Returns self-contained HTML fragments that can be embedded in the +single-file HTML dashboard. + +Public API: + render_interactive_scatter_tiers(fidelity_json, perf_json) -> str + render_interactive_scatter_features(fidelity_json, perf_json) -> str + render_interactive_scatter_tiers_from_data(fidelity, perf) -> str + render_interactive_scatter_features_from_data(fidelity, perf) -> str +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import plotly.graph_objects as go +from plotly.subplots import make_subplots + +# ── Reuse data pipeline + constants from the static renderer ────── +from excelbench.results.scatter import ( + _CAP_LABELS, + _CAP_MARKERS, + _DARK_BG, + _DARK_CARD, + _DARK_GRID, + _DARK_TEXT, + _DARK_TEXT2, + _FALLBACK_COLOR, + _FEATURE_LABELS, + _FEATURE_PERF_MAP, + _LIB_COLORS, + _LIB_SHORT, + _TIER_GROUPS, + _WOLFXL_MARKER_SCALE, + _ZONE_BANDS, + Point, + _compute_capabilities, + _compute_pass_rates, + _compute_representative_throughput, + _compute_throughputs, + _feature_points, + _jitter, + _overall_points, + _tier_points, +) + +# ── Plotly marker-symbol mapping (matplotlib → plotly names) ────── +_PLOTLY_MARKERS: dict[str, str] = { + "o": "circle", # R+W + "s": "square", # R (read-only) + "D": "diamond-wide", # W (write-only) +} + +# Plotly to_html config shared across all renders +_PLOTLY_CONFIG: dict[str, Any] = dict( + displayModeBar=True, + modeBarButtonsToRemove=["lasso2d", "select2d"], + displaylogo=False, + responsive=True, +) + + +# ==================================================================== +# Subplot axis-id helper +# ==================================================================== + + +def _subplot_axis_suffix(row: int, col: int, ncols: int) -> str: + """Return the Plotly axis suffix for a given (row, col) in a grid. + + Plotly numbers axes sequentially: first subplot → '', second → '2', etc. + Index = (row - 1) * ncols + col. + """ + idx = (row - 1) * ncols + col + return "" if idx == 1 else str(idx) + + +# ==================================================================== +# Panel builder +# ==================================================================== + + +def _build_panel( + fig: go.Figure, + row: int, + col: int, + ncols: int, + points: list[Point], +) -> None: + """Populate one subplot with zone bands, threshold lines, and scatter points.""" + suffix = _subplot_axis_suffix(row, col, ncols) + xref = f"x{suffix}" if suffix else "x" + yref = f"y{suffix}" if suffix else "y" + + # ── Score-zone background bands ── + for y_lo, y_hi, colour, _ in _ZONE_BANDS: + fig.add_shape( + type="rect", + xref=f"{xref} domain", + yref=yref, + x0=0, x1=1, y0=y_lo, y1=y_hi, + fillcolor=colour, + opacity=0.65, + layer="below", + line_width=0, + ) + + # ── Threshold lines ── + for y_val, dash_style, colour, width in [ + (50, "dash", "#444444", 0.8), + (80, "dash", "#444444", 0.8), + (100, "solid", "#62c073", 1.0), + ]: + fig.add_hline( + y=y_val, row=row, col=col, + line=dict(color=colour, width=width, dash=dash_style), + layer="below", + ) + + # ── Score zone annotations (left edge) ── + for y_pos, label, colour in [ + (25, "Score 0", "#e4484d"), + (65, "Score 1", "#e79c12"), + (90, "Score 2", "#4ead5b"), + (106, "Score 3", "#62c073"), + ]: + fig.add_annotation( + xref=f"{xref} domain", + yref=yref, + x=0.01, y=y_pos, + text=label, + showarrow=False, + font=dict(size=11, color=colour), + opacity=0.7, + xanchor="left", + yanchor="middle", + ) + + if not points: + fig.add_annotation( + xref=f"{xref} domain", + yref=f"{yref} domain", + x=0.5, y=0.5, + text="No data", + showarrow=False, + font=dict(size=14, color="#a0a0a0"), + ) + return + + # ── Plot each library as a separate trace for individual hover ── + for lib, rate, tp, cap in points: + color = _LIB_COLORS.get(lib, _FALLBACK_COLOR) + mpl_marker = _CAP_MARKERS.get(cap, "o") + plotly_symbol = _PLOTLY_MARKERS.get(mpl_marker, "circle") + display_name = _LIB_SHORT.get(lib, lib) + + y = rate + _jitter(lib, scale=3.0) + y = max(-2.0, min(107.0, y)) + + is_wolfxl = lib == "wolfxl" + sz = int(16 * _WOLFXL_MARKER_SCALE) if is_wolfxl else 16 + edge_color = "#fb923c" if is_wolfxl else _DARK_CARD + edge_width = 2.5 if is_wolfxl else 1.0 + + fig.add_trace( + go.Scatter( + x=[tp], + y=[y], + mode="markers+text", + marker=dict( + symbol=plotly_symbol, + size=sz, + color=color, + line=dict(color=edge_color, width=edge_width), + opacity=0.95 if is_wolfxl else 0.92, + ), + text=[display_name], + textposition="top center" if is_wolfxl else "bottom center", + textfont=dict( + size=14 if is_wolfxl else 11, + color=color, + family="system-ui, sans-serif", + ), + hovertemplate=( + f"{display_name}
" + f"Pass Rate: {rate:.1f}%
" + f"Throughput: %{{x:,.0f}} ops/s
" + f"Capability: {_CAP_LABELS.get(cap, cap)}" + "" + ), + showlegend=False, + ), + row=row, col=col, + ) + + +# ==================================================================== +# Layout & figure builders +# ==================================================================== + + +def _apply_layout(fig: go.Figure, title: str, nrows: int, ncols: int) -> None: + """Apply shared dark-theme layout settings to a multi-subplot figure.""" + fig.update_layout( + title=dict( + text=title, + font=dict(size=20, color=_DARK_TEXT, family="system-ui, sans-serif"), + x=0.5, + xanchor="center", + ), + paper_bgcolor=_DARK_BG, + plot_bgcolor=_DARK_CARD, + font=dict(color=_DARK_TEXT, family="system-ui, sans-serif"), + margin=dict(l=70, r=40, t=90, b=90), + showlegend=False, + height=550 * nrows + 130, + ) + + # Style all axes + axis_style: dict[str, Any] = dict( + gridcolor=_DARK_GRID, + gridwidth=0.5, + zerolinecolor=_DARK_GRID, + linecolor=_DARK_GRID, + tickfont=dict(size=12, color=_DARK_TEXT2), + ) + + n_panels = nrows * ncols + for i in range(1, n_panels + 1): + suffix = "" if i == 1 else str(i) + fig.update_layout(**{ + f"xaxis{suffix}": dict( + type="log", + title=dict(text="Throughput (ops/s)", font=dict(size=13, color=_DARK_TEXT)), + **axis_style, + ), + f"yaxis{suffix}": dict( + range=[-5, 110], + title=dict(text="Pass Rate (%)", font=dict(size=13, color=_DARK_TEXT)), + **axis_style, + ), + }) + + # Modebar customization + fig.update_layout( + modebar=dict( + bgcolor="rgba(0,0,0,0)", + color=_DARK_TEXT2, + activecolor="#51a8ff", + ), + ) + + +def _build_tiers_figure( + fidelity: dict[str, Any], + perf: dict[str, Any], +) -> go.Figure: + """Build the 1x3 tier scatter grid.""" + pass_rates = _compute_pass_rates(fidelity) + throughputs = _compute_throughputs(perf) + rep_tp = _compute_representative_throughput(perf) + caps = _compute_capabilities(fidelity) + + tier_titles = [name for name, _ in _TIER_GROUPS] + ["Overall"] + fig = make_subplots( + rows=1, cols=3, + subplot_titles=tier_titles, + horizontal_spacing=0.06, + ) + + # Tier panels + for col_idx, (_, tier_features) in enumerate(_TIER_GROUPS, start=1): + pts = _tier_points(tier_features, pass_rates, throughputs, caps) + _build_panel(fig, 1, col_idx, ncols=3, points=pts) + + # Overall panel + overall = _overall_points(pass_rates, rep_tp, caps) + _build_panel(fig, 1, 3, ncols=3, points=overall) + + _apply_layout(fig, "ExcelBench \u2014 Fidelity vs. Throughput by Feature Group", 1, 3) + + # Style subplot titles only (not zone labels added by _build_panel) + n_subplot_titles = len(tier_titles) + for ann in fig.layout.annotations[:n_subplot_titles]: + ann.font = dict(size=16, color=_DARK_TEXT, family="system-ui, sans-serif") + + _add_footer_annotations(fig) + return fig + + +def _build_features_figure( + fidelity: dict[str, Any], + perf: dict[str, Any], +) -> go.Figure: + """Build the 2x3 per-feature scatter grid.""" + pass_rates = _compute_pass_rates(fidelity) + throughputs = _compute_throughputs(perf) + caps = _compute_capabilities(fidelity) + + features = list(_FEATURE_PERF_MAP.keys()) + feature_titles = [_FEATURE_LABELS.get(f, f) for f in features] + + fig = make_subplots( + rows=2, cols=3, + subplot_titles=feature_titles, + vertical_spacing=0.12, + horizontal_spacing=0.06, + ) + + for idx, feature in enumerate(features): + r = idx // 3 + 1 + c = idx % 3 + 1 + pts = _feature_points(feature, pass_rates, throughputs, caps) + _build_panel(fig, r, c, ncols=3, points=pts) + + _apply_layout(fig, "ExcelBench \u2014 Per-Feature Fidelity vs. Throughput", 2, 3) + + # Style subplot titles only (not zone labels added by _build_panel) + n_subplot_titles = len(feature_titles) + for ann in fig.layout.annotations[:n_subplot_titles]: + ann.font = dict(size=16, color=_DARK_TEXT, family="system-ui, sans-serif") + + _add_footer_annotations(fig) + return fig + + +def _add_footer_annotations(fig: go.Figure) -> None: + """Add capability legend and jitter footnote at the bottom of the figure.""" + legend_parts = [] + for cap, mpl_marker in _CAP_MARKERS.items(): + symbol_char = {"o": "\u25cf", "s": "\u25a0", "D": "\u25c6"}.get(mpl_marker, "\u25cf") + legend_parts.append(f"{symbol_char} {_CAP_LABELS[cap]}") + legend_text = " ".join(legend_parts) + + fig.add_annotation( + text=f"{legend_text} | Y-positions include \u00b13% jitter for visibility", + xref="paper", yref="paper", + x=0.5, y=-0.08, + showarrow=False, + font=dict(size=12, color=_DARK_TEXT2), + xanchor="center", + ) + + +# ==================================================================== +# Public API — returns HTML fragments +# ==================================================================== + + +def render_interactive_scatter_tiers( + fidelity_json: Path, + perf_json: Path, +) -> str: + """Generate an interactive 1x3 tier scatter grid as an HTML fragment. + + The returned string includes the plotly.js library inline. + """ + fidelity = json.loads(fidelity_json.read_text()) + perf = json.loads(perf_json.read_text()) + fig = _build_tiers_figure(fidelity, perf) + return str(fig.to_html(full_html=False, include_plotlyjs=True, config=_PLOTLY_CONFIG)) + + +def render_interactive_scatter_features( + fidelity_json: Path, + perf_json: Path, +) -> str: + """Generate an interactive 2x3 per-feature scatter grid as an HTML fragment. + + Assumes plotly.js was already included by the tiers fragment. + """ + fidelity = json.loads(fidelity_json.read_text()) + perf = json.loads(perf_json.read_text()) + fig = _build_features_figure(fidelity, perf) + return str(fig.to_html(full_html=False, include_plotlyjs=False, config=_PLOTLY_CONFIG)) + + +# ── Convenience: build from raw dicts (for dashboard integration) ── + + +def render_interactive_scatter_tiers_from_data( + fidelity: dict[str, Any], + perf: dict[str, Any], +) -> str: + """Like render_interactive_scatter_tiers but accepts pre-loaded dicts.""" + fig = _build_tiers_figure(fidelity, perf) + return str(fig.to_html(full_html=False, include_plotlyjs=True, config=_PLOTLY_CONFIG)) + + +def render_interactive_scatter_features_from_data( + fidelity: dict[str, Any], + perf: dict[str, Any], +) -> str: + """Like render_interactive_scatter_features but accepts pre-loaded dicts.""" + fig = _build_features_figure(fidelity, perf) + return str(fig.to_html(full_html=False, include_plotlyjs=False, config=_PLOTLY_CONFIG)) diff --git a/tests/test_scatter_interactive.py b/tests/test_scatter_interactive.py new file mode 100644 index 0000000..fa2bcbe --- /dev/null +++ b/tests/test_scatter_interactive.py @@ -0,0 +1,127 @@ +"""Smoke tests for the interactive Plotly scatter renderer.""" + +from __future__ import annotations + +from pathlib import Path + +from excelbench.results.scatter_interactive import ( + render_interactive_scatter_features, + render_interactive_scatter_features_from_data, + render_interactive_scatter_tiers, + render_interactive_scatter_tiers_from_data, +) + +# ── Fixture paths (same as test_results_scatter.py) ────────────── + + +def _fixture_paths() -> tuple[Path, Path]: + fidelity = Path("results/xlsx/results.json") + perf = Path("results/perf/results.json") + assert fidelity.exists(), "expected repo fixture results/xlsx/results.json" + assert perf.exists(), "expected repo fixture results/perf/results.json" + return fidelity, perf + + +# ── Tiers tests ─────────────────────────────────────────────────── + + +def test_tiers_returns_html_with_plotlyjs() -> None: + fidelity, perf = _fixture_paths() + html = render_interactive_scatter_tiers(fidelity, perf) + + assert isinstance(html, str) + assert len(html) > 10_000, "expected substantial HTML output" + # Plotly.js must be inlined + assert "plotly" in html.lower() + assert " None: + fidelity, perf = _fixture_paths() + html = render_interactive_scatter_tiers(fidelity, perf) + + assert "#0a0a0a" in html, "expected dark background color" + assert "#191919" in html, "expected dark card color" + + +def test_tiers_contains_hover_template() -> None: + fidelity, perf = _fixture_paths() + html = render_interactive_scatter_tiers(fidelity, perf) + + assert "Pass Rate" in html + assert "Throughput" in html + # ops/s appears in hovertemplate; "/" is unicode-escaped as \u002f in Plotly JSON + assert "ops" in html and ("ops/s" in html or "ops\\u002fs" in html) + + +def test_tiers_contains_wolfxl_highlighting() -> None: + fidelity, perf = _fixture_paths() + html = render_interactive_scatter_tiers(fidelity, perf) + + assert "wolfxl" in html.lower() + assert "#fb923c" in html, "expected WolfXL orange edge color" + + +def test_tiers_contains_zone_bands() -> None: + fidelity, perf = _fixture_paths() + html = render_interactive_scatter_tiers(fidelity, perf) + + # Zone band colors from _ZONE_BANDS + assert "#1a0a0a" in html, "expected score-0 zone band color" + assert "#1a1400" in html, "expected score-1 zone band color" + + +# ── Features tests ──────────────────────────────────────────────── + + +def test_features_returns_html_without_plotlyjs() -> None: + fidelity, perf = _fixture_paths() + html = render_interactive_scatter_features(fidelity, perf) + + assert isinstance(html, str) + assert len(html) > 1_000 + # Should NOT re-include plotly.js (already included by tiers) + # Check that there is no massive plotly bundle (the full bundle is ~4MB) + assert len(html) < 500_000, "features fragment should not re-include plotly.js" + + +def test_features_contains_feature_labels() -> None: + fidelity, perf = _fixture_paths() + html = render_interactive_scatter_features(fidelity, perf) + + # At least some feature labels should appear in subplot titles + assert "Cell Values" in html + assert "Formulas" in html + + +# ── From-data convenience API tests ─────────────────────────────── + + +def test_from_data_tiers_matches_file_api() -> None: + """The from_data API should produce equivalent output to the file-based API.""" + import json + + fidelity_path, perf_path = _fixture_paths() + fidelity = json.loads(fidelity_path.read_text()) + perf = json.loads(perf_path.read_text()) + + html_file = render_interactive_scatter_tiers(fidelity_path, perf_path) + html_data = render_interactive_scatter_tiers_from_data(fidelity, perf) + + # Both should include plotly.js and have similar structure + assert abs(len(html_file) - len(html_data)) < 100, ( + "file-based and dict-based outputs should be nearly identical" + ) + + +def test_from_data_features_matches_file_api() -> None: + import json + + fidelity_path, perf_path = _fixture_paths() + fidelity = json.loads(fidelity_path.read_text()) + perf = json.loads(perf_path.read_text()) + + html_file = render_interactive_scatter_features(fidelity_path, perf_path) + html_data = render_interactive_scatter_features_from_data(fidelity, perf) + + assert abs(len(html_file) - len(html_data)) < 100 diff --git a/uv.lock b/uv.lock index 7909bba..7cf457c 100644 --- a/uv.lock +++ b/uv.lock @@ -381,6 +381,7 @@ dependencies = [ { name = "openpyxl" }, { name = "pandas" }, { name = "pillow" }, + { name = "plotly" }, { name = "polars" }, { name = "pydantic" }, { name = "pyexcel" }, @@ -422,6 +423,7 @@ requires-dist = [ { name = "openpyxl", specifier = ">=3.1.0" }, { name = "pandas", specifier = ">=2.0" }, { name = "pillow", specifier = ">=10.0.0" }, + { name = "plotly", specifier = ">=6.0" }, { name = "polars", specifier = ">=1.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pyexcel", specifier = ">=0.7.0" }, @@ -1056,6 +1058,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "narwhals" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/6f/713be67779028d482c6e0f2dde5bc430021b2578a4808c1c9f6d7ad48257/narwhals-2.16.0.tar.gz", hash = "sha256:155bb45132b370941ba0396d123cf9ed192bf25f39c4cea726f2da422ca4e145", size = 618268, upload-time = "2026-02-02T10:31:00.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/cc/7cb74758e6df95e0c4e1253f203b6dd7f348bf2f29cf89e9210a2416d535/narwhals-2.16.0-py3-none-any.whl", hash = "sha256:846f1fd7093ac69d63526e50732033e86c30ea0026a44d9b23991010c7d1485d", size = 443951, upload-time = "2026-02-02T10:30:58.635Z" }, +] + [[package]] name = "numpy" version = "2.4.2" @@ -1330,6 +1341,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/e3/1eddccb2c39ecfbe09b3add42a04abcc3fa5b468aa4224998ffb8a7e9c8f/platformdirs-4.7.0-py3-none-any.whl", hash = "sha256:1ed8db354e344c5bb6039cd727f096af975194b508e37177719d562b2b540ee6", size = 18983, upload-time = "2026-02-12T22:21:52.237Z" }, ] +[[package]] +name = "plotly" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/4f/8a10a9b9f5192cb6fdef62f1d77fa7d834190b2c50c0cd256bd62879212b/plotly-6.5.2.tar.gz", hash = "sha256:7478555be0198562d1435dee4c308268187553cc15516a2f4dd034453699e393", size = 7015695, upload-time = "2026-01-14T21:26:51.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/67/f95b5460f127840310d2187f916cf0023b5875c0717fdf893f71e1325e87/plotly-6.5.2-py3-none-any.whl", hash = "sha256:91757653bd9c550eeea2fa2404dba6b85d1e366d54804c340b2c874e5a7eb4a4", size = 9895973, upload-time = "2026-01-14T21:26:47.135Z" }, +] + [[package]] name = "pluggy" version = "1.6.0"