test(demo): cover the demo app + sales-surface coherence

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 19:06:50 +00:00
parent e7ec79b9b5
commit 9943e6e537
2 changed files with 161 additions and 0 deletions

116
tests/gui/test_app_demo.py Normal file
View File

@@ -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()

View File

@@ -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