From 9943e6e53755cea6cfbd87f7357c5362417a26b7 Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 22 Jun 2026 19:06:50 +0000 Subject: [PATCH] test(demo): cover the demo app + sales-surface coherence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a demo test suite on top of the data-value pins: - tests/gui/test_app_demo.py (new, AppTest): every accounting persona renders with its dataset, the default/unknown-persona fallback resolves to bookkeeper, clicking Run produces the AFTER value (rows reduced to the validated count) with the watermarked download + Gumroad CTA, and switching persona via the quick-switch dropdown clears the stale result. - tests/test_demo_pipelines.py (extended): cross-surface coherence — each persona key served by app_demo has a matching landing page whose iframe (?p=) and CTA (from=) point at it and that the hub links to; no retired Shopify/RevOps language remains in landing HTML; and the demo download still appends exactly one watermark row. Full suite: 2584 passed, 91 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/gui/test_app_demo.py | 116 +++++++++++++++++++++++++++++++++++ tests/test_demo_pipelines.py | 45 ++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 tests/gui/test_app_demo.py diff --git a/tests/gui/test_app_demo.py b/tests/gui/test_app_demo.py new file mode 100644 index 0000000..6689b32 --- /dev/null +++ b/tests/gui/test_app_demo.py @@ -0,0 +1,116 @@ +"""Public demo app (``src/gui/app_demo.py``) behavior — AppTest. + +The demo app is the marketing surface: it preloads one accounting persona's +dataset, runs the saved pipeline, and shows BEFORE/AFTER + a buy CTA. These +tests pin that every persona renders, the run produces its headline value, +persona switching works, and the buy path is present — so a regression can't +silently ship a broken or empty demo to a prospect. + +The dataset value numbers themselves are pinned separately in +``tests/test_demo_pipelines.py``; here we assert the *app* surfaces them. +""" + +from __future__ import annotations + +from pathlib import Path + +import pandas as pd +import pytest +from streamlit.testing.v1 import AppTest + +_PAGE = str( + Path(__file__).resolve().parent.parent.parent / "src" / "gui" / "app_demo.py" +) +_DEMO = Path(__file__).resolve().parent.parent.parent / "samples" / "demo" + +# (persona key, data file, expected rows before -> after, a label substring) +_PERSONAS = [ + ("bookkeeper", "bank_reconciliation.csv", 26, 20, "Bookkeeper"), + ("ap-1099", "vendor_1099.csv", 24, 8, "payable"), + ("ar-aging", "ar_open_invoices.csv", 26, 21, "receivable"), +] + + +def _app(persona: str | None = None) -> AppTest: + at = AppTest.from_file(_PAGE, default_timeout=60) + if persona is not None: + at.query_params["p"] = persona + return at.run() + + +def _md(at: AppTest) -> str: + return " ".join(m.value for m in at.markdown) + + +@pytest.mark.parametrize("key,data_file,before,after,label", _PERSONAS) +def test_persona_renders_with_its_dataset(key, data_file, before, after, label): + at = _app(key) + assert not at.exception + md = _md(at) + assert label in md, f"persona label {label!r} not rendered" + # BEFORE preview reflects the real dataset size. + real_rows = len(pd.read_csv(_DEMO / data_file, dtype=str, keep_default_na=False)) + assert real_rows == before # guards the fixture against silent drift + assert f"BEFORE — {before} rows" in md + # The saved pipeline is shown (read-only) as the canonical steps. + assert "text_clean" in md and "dedup" in md + assert any("Run pipeline" in b.label for b in at.button) + + +def test_default_persona_is_bookkeeper(): + at = _app(None) + assert not at.exception + assert "Bookkeeper" in _md(at) + + +def test_unknown_persona_falls_back_to_default(): + at = _app("not-a-real-persona") + assert not at.exception + assert "Bookkeeper" in _md(at) + + +@pytest.mark.parametrize("key,data_file,before,after,label", _PERSONAS) +def test_run_shows_after_value_and_buy_path(key, data_file, before, after, label): + at = _app(key) + [b for b in at.button if "Run pipeline" in b.label][0].click().run() + assert not at.exception, at.exception + + # A result is cached and the AFTER header reports the dedup win. + assert "demo_result" in at.session_state + result = at.session_state["demo_result"] + assert len(result.final_df) == after + assert result.final_rows < result.initial_rows + assert f"{before} → {after} rows" in _md(at) + + # The buy path is present after a run (download + Gumroad CTA). The + # cleaned-CSV download is a download_button, not a plain button. + downloads = at.get("download_button") + assert any("Download cleaned CSV" in d.label for d in downloads) + assert f"gumroad.com/l/datatools?from={key}" in _md(at) + + +def test_persona_switch_clears_stale_result(): + # Run the bookkeeper demo, then switch persona via the quick-switch + # dropdown (driving the selectbox — a raw query-param change is + # overridden by the dropdown's persisted value). + at = _app("bookkeeper") + [b for b in at.button if "Run pipeline" in b.label][0].click().run() + assert "demo_result" in at.session_state + + switch = [s for s in at.selectbox if s.key == "persona_switch"][0] + switch.set_value("ap-1099").run() + assert not at.exception + # The page drops the stale bookkeeper result when the persona changes, + # so the visitor never sees the wrong dataset's AFTER block. + assert "demo_result" not in at.session_state + assert "payable" in _md(at) # now showing the AP/1099 persona + + +def test_run_offers_a_watermarked_download(): + """After a run the visitor gets a download, labeled as watermarked + (the free/paid boundary from DEMO-PLAN §6).""" + at = _app("bookkeeper") + [b for b in at.button if "Run pipeline" in b.label][0].click().run() + dl = [d for d in at.get("download_button") if "Download cleaned CSV" in d.label] + assert dl, "no cleaned-CSV download after a run" + assert "watermark" in dl[0].label.lower() diff --git a/tests/test_demo_pipelines.py b/tests/test_demo_pipelines.py index 1901f1f..7db7d6d 100644 --- a/tests/test_demo_pipelines.py +++ b/tests/test_demo_pipelines.py @@ -69,3 +69,48 @@ def test_app_demo_references_each_demo_file(): assert pipeline_file in src, f"{pipeline_file} not referenced in app_demo.py" assert (_DEMO / data_file).exists(), f"missing {data_file}" assert (_DEMO / pipeline_file).exists(), f"missing {pipeline_file}" + + +# The accounting persona keys served by the demo app — each must line up with +# a landing page that embeds the matching demo. (key, data-file stem) +_PERSONA_KEYS = [ + ("bookkeeper", "bank_reconciliation"), + ("ap-1099", "vendor_1099"), + ("ar-aging", "ar_open_invoices"), +] +_LANDING = _REPO / "landing" + + +@pytest.mark.parametrize("key,stem", _PERSONA_KEYS) +def test_landing_page_embeds_the_matching_demo(key, stem): + """Each landing page exists and its iframe + CTA point at this persona — + so the sales surface (landing -> demo app -> dataset) stays coherent.""" + app_src = (_REPO / "src" / "gui" / "app_demo.py").read_text(encoding="utf-8") + assert f'"{key}"' in app_src, f"persona key {key!r} not served by app_demo.py" + + page = _LANDING / key / "index.html" + assert page.exists(), f"missing landing page for {key}" + html = page.read_text(encoding="utf-8") + assert f"?p={key}" in html, f"{key} landing iframe doesn't load ?p={key}" + assert f"from={key}" in html, f"{key} landing CTA isn't tagged from={key}" + + # The hub links to this persona's page. + hub = (_LANDING / "index.html").read_text(encoding="utf-8") + assert f'href="{key}/"' in hub, f"hub doesn't link to {key}/" + + +def test_landing_surface_has_no_stale_persona_refs(): + """No retired Shopify / RevOps persona language remains in landing HTML.""" + for html_file in _LANDING.rglob("*.html"): + text = html_file.read_text(encoding="utf-8").lower() + for stale in ("shopify", "revops", "klaviyo", "hubspot"): + assert stale not in text, f"{stale!r} still in {html_file.relative_to(_REPO)}" + + +def test_demo_app_builds_a_single_watermark_row(): + """The demo download appends exactly one trailing watermark row + (DEMO-PLAN §6: the AFTER preview must read as production-quality).""" + src = (_REPO / "src" / "gui" / "app_demo.py").read_text(encoding="utf-8") + assert "DataTools demo — buy at" in src + # One trailing row concatenated onto the result frame. + assert "watermark_row" in src and "pd.concat([result.final_df, watermark_row]" in src