diff --git a/src/rtichoke/_report_browser.py b/src/rtichoke/_report_browser.py index f12c357d..ec8b05fd 100644 --- a/src/rtichoke/_report_browser.py +++ b/src/rtichoke/_report_browser.py @@ -37,6 +37,81 @@ def _resolve_render_report_symbol( ) +def _performance_metrics_cheat_sheet_html() -> str: + """Generate Performance Metrics Cheat Sheet HTML for browser summary reports.""" + return ( + '
\n' + " Performance Metrics Cheat Sheet\n" + '
\n' + '
\n' + "

Confusion Matrix

\n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "
Predicted +Predicted -
Real PositiveTPFN
Real NegativeFPTN
\n" + "
\n" + '
\n' + "

Metrics & Formulas

\n" + '
\n' + "
Prevalence
\n" + "
(TP + FN) / (TP + FP + TN + FN)
\n" + "
PPCR
\n" + "
(TP + FP) / (TP + FP + TN + FN)
\n" + "
Sensitivity / Recall / TPR
\n" + "
\n" + " TP / (TP + FN)
\n" + " TP / Real Positives
\n" + " P(Predicted Positive | Real Positive)\n" + "
\n" + "
Specificity / TNR
\n" + "
\n" + " TN / (TN + FP)
\n" + " TN / Real Negatives
\n" + " P(Predicted Negative | Real Negative)\n" + "
\n" + "
PPV / Precision
\n" + "
\n" + " TP / (TP + FP)
\n" + " TP / Predicted Positives
\n" + " P(Real Positive | Predicted Positive)\n" + "
\n" + "
NPV
\n" + "
\n" + " TN / (TN + FN)
\n" + " TN / Predicted Negatives
\n" + " P(Real Negative | Predicted Negative)\n" + "
\n" + "
Lift
\n" + "
PPV / Prevalence
\n" + "
Net Benefit
\n" + "
\n" + " TP / N - FP / N * p_t / (1 - p_t)
\n" + " where N = TP + FP + TN + FN\n" + "
\n" + "
\n" + "
\n" + "
\n" + "
" + ) + + def _sanitize_nan_values(obj: Any) -> Any: """Recursively replace NaN and Inf float values with None for valid JSON serialization.""" if isinstance(obj, dict): @@ -104,8 +179,14 @@ def _summary_report_density_css() -> str: class RtichokeBrowserReport: """A complete canonical ReportSpec rendered by shared ``rtichoke_viz``.""" - def __init__(self, spec: dict[str, Any]) -> None: + def __init__( + self, + spec: dict[str, Any], + *, + include_cheat_sheet: bool = False, + ) -> None: self.spec = spec + self.include_cheat_sheet = include_cheat_sheet def write_html(self, path: str | Path) -> Path: """Write an offline HTML page that delegates composition to renderReport().""" @@ -128,6 +209,32 @@ def write_html(self, path: str | Path) -> Path: else: doc_title = "rtichoke report" + if self.include_cheat_sheet: + cheat_sheet_json = json.dumps( + _performance_metrics_cheat_sheet_html() + ).replace(" @@ -147,11 +254,7 @@ def write_html(self, path: str | Path) -> Path: const spec = JSON.parse( document.querySelector("#rtichoke-report-spec").textContent ); - document.querySelector("#rtichoke-report").append({render_fn}(spec, {{ - sectionGroupPresentation: "tabs", - groupPresentation: "tabs", - sectionComponentPresentation: "tabs" - }})); +{mount_js} diff --git a/src/rtichoke/summary_report/summary_report.py b/src/rtichoke/summary_report/summary_report.py index d898f88c..42cedc8f 100644 --- a/src/rtichoke/summary_report/summary_report.py +++ b/src/rtichoke/summary_report/summary_report.py @@ -636,7 +636,9 @@ def _create_browser_summary_report( ] report = _build_report_spec_v11(sections, title="Summary Report") - return RtichokeBrowserReport(cast(dict[str, Any], report)).write_html(output_file) + return RtichokeBrowserReport( + cast(dict[str, Any], report), include_cheat_sheet=True + ).write_html(output_file) def render_summary_report(): diff --git a/tests/test_quarto_summary_report_browser.py b/tests/test_quarto_summary_report_browser.py index dfd1ee08..351e4291 100644 --- a/tests/test_quarto_summary_report_browser.py +++ b/tests/test_quarto_summary_report_browser.py @@ -130,7 +130,7 @@ def test_quarto_single_browser_summary_report(tmp_path): assert frame is not None frame.wait_for_selector("#rtichoke-report") - frame.wait_for_selector("table") + frame.wait_for_selector(".rtichoke-performance-table__table") frame.wait_for_selector("svg") assert "Performance" in frame.content() @@ -230,7 +230,7 @@ def test_quarto_two_browser_summary_reports(tmp_path): assert frame is not None frame.wait_for_selector("#rtichoke-report") - frame.wait_for_selector("table") + frame.wait_for_selector(".rtichoke-performance-table__table") frame.wait_for_selector("svg") assert "Performance" in frame.content() diff --git a/tests/test_report_browser.py b/tests/test_report_browser.py index 374bf756..a23eb183 100644 --- a/tests/test_report_browser.py +++ b/tests/test_report_browser.py @@ -12,6 +12,7 @@ ) from rtichoke._report_browser import ( RtichokeBrowserReport, + _performance_metrics_cheat_sheet_html, _resolve_render_report_symbol, ) from rtichoke._report_spec import _build_report_spec_v11 @@ -253,3 +254,68 @@ def test_resolve_render_report_symbol_export_clauses(): missing_js = "export { foo, bar };" with pytest.raises(ValueError, match="Could not resolve 'renderReport' export"): _resolve_render_report_symbol(missing_js) + + +def test_cheat_sheet_html_exact_markup(): + html = _performance_metrics_cheat_sheet_html() + assert html.startswith('
\n') + assert "Performance Metrics Cheat Sheet" in html + assert "

Confusion Matrix

" in html + assert "Predicted +" in html + assert "Predicted -" in html + assert "Real Positive" in html + assert "TP" in html + assert "FN" in html + assert "Real Negative" in html + assert "FP" in html + assert "TN" in html + assert "

Metrics & Formulas

" in html + assert "
Prevalence
" in html + assert "
(TP + FN) / (TP + FP + TN + FN)
" in html + assert "
PPCR
" in html + assert "
(TP + FP) / (TP + FP + TN + FN)
" in html + assert "
Sensitivity / Recall / TPR
" in html + assert "TP / (TP + FN)" in html + assert "TP / Real Positives" in html + assert "P(Predicted Positive | Real Positive)" in html + assert "
Specificity / TNR
" in html + assert "TN / (TN + FP)" in html + assert "TN / Real Negatives" in html + assert "P(Predicted Negative | Real Negative)" in html + assert "
PPV / Precision
" in html + assert "TP / (TP + FP)" in html + assert "TP / Predicted Positives" in html + assert "P(Real Positive | Predicted Positive)" in html + assert "
NPV
" in html + assert "TN / (TN + FN)" in html + assert "TN / Predicted Negatives" in html + assert "P(Real Negative | Predicted Negative)" in html + assert "
Lift
" in html + assert "
PPV / Prevalence
" in html + assert "
Net Benefit
" in html + assert "TP / N - FP / N * p_t / (1 - p_t)" in html + assert "where N = TP + FP + TN + FN" in html + assert html.endswith("
") + + +def test_browser_report_include_cheat_sheet_flag_default(tmp_path): + report_spec = {"title": "Summary Report", "sections": []} + report = RtichokeBrowserReport(report_spec) + assert report.include_cheat_sheet is False + + output = report.write_html(tmp_path / "default_report.html") + html_content = output.read_text(encoding="utf-8") + assert "rtichoke-cheat-sheet" not in html_content + + +def test_browser_report_include_cheat_sheet_opt_in(tmp_path): + report_spec = {"title": "Summary Report", "sections": []} + report = RtichokeBrowserReport(report_spec, include_cheat_sheet=True) + assert report.include_cheat_sheet is True + + output = report.write_html(tmp_path / "cheat_sheet_report.html") + html_content = output.read_text(encoding="utf-8") + assert "rtichoke-cheat-sheet" in html_content + assert "cheatSheetWrapper.innerHTML =" in html_content + assert "insertBefore(cheatSheetNode, headerNode.nextSibling)" in html_content + assert _embedded_report(html_content) == report_spec diff --git a/tests/test_summary_report_browser.py b/tests/test_summary_report_browser.py index 3b6127cb..ca075a7c 100644 --- a/tests/test_summary_report_browser.py +++ b/tests/test_summary_report_browser.py @@ -333,6 +333,41 @@ def test_static_performance_table_confusion_matrix_disclosure(tmp_path): page.on("console", lambda msg: print("CONSOLE:", msg.type, msg.text)) page.on("pageerror", lambda err: print("PAGE ERROR:", err)) + # 0. Cheat sheet presence, placement, and content in static summary report + page.goto(f"{base_url}/{output_thresh.name}") + page.wait_for_selector(".rtichoke-report") + + cheat_sheets = page.locator(".rtichoke-cheat-sheet") + assert cheat_sheets.count() == 1 + + cs = cheat_sheets.first + summary_el = cs.locator("summary") + assert summary_el.inner_text() == "Performance Metrics Cheat Sheet" + + # Verify element order: .rtichoke-report__header -> .rtichoke-cheat-sheet -> .rtichoke-report__nav + is_correct_order = page.evaluate("""() => { + const header = document.querySelector('.rtichoke-report__header'); + const cheatSheet = document.querySelector('.rtichoke-cheat-sheet'); + const nav = document.querySelector('.rtichoke-report__nav'); + if (!header || !cheatSheet || !nav) return false; + const afterHeader = (header.compareDocumentPosition(cheatSheet) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0; + const beforeNav = (cheatSheet.compareDocumentPosition(nav) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0; + return afterHeader && beforeNav; + }""") + assert is_correct_order is True + + # Check cheat sheet text/formulas + cs_text = cs.text_content() + assert "Confusion Matrix" in cs_text + assert "Prevalence" in cs_text + assert "PPCR" in cs_text + assert "Sensitivity / Recall / TPR" in cs_text + assert "Specificity / TNR" in cs_text + assert "PPV / Precision" in cs_text + assert "NPV" in cs_text + assert "Lift" in cs_text + assert "Net Benefit" in cs_text + # 1. Static probability threshold performance table disclosure page.goto(f"{base_url}/{output_thresh.name}") page.wait_for_selector(".rtichoke-performance-table__table") diff --git a/tests/test_summary_report_times_browser.py b/tests/test_summary_report_times_browser.py index b4218478..78021feb 100644 --- a/tests/test_summary_report_times_browser.py +++ b/tests/test_summary_report_times_browser.py @@ -118,6 +118,7 @@ def _assert_report_rendered(browser: subprocess.CompletedProcess[str]) -> None: assert "By Predicted Positives Condition Rate (PPCR)" in rendered assert "= 2 + assert "rtichoke-cheat-sheet" not in rendered def test_public_export_create_summary_report_times():