diff --git a/app/components/demo_toolbar.py b/app/components/demo_toolbar.py new file mode 100644 index 0000000..829c25c --- /dev/null +++ b/app/components/demo_toolbar.py @@ -0,0 +1,76 @@ +"""Demo toolbar — tier simulation for DEMO_MODE instances. + +Renders a slim full-width bar above the Streamlit nav showing +Free / Paid / Premium pills. Clicking a pill sets a prgn_demo_tier +cookie (for persistence across reloads) and st.session_state.simulated_tier +(for immediate use within the current render pass). + +Only ever rendered when DEMO_MODE=true. +""" +from __future__ import annotations + +import os + +import streamlit as st +import streamlit.components.v1 as components + +_VALID_TIERS = ("free", "paid", "premium") +_DEFAULT_TIER = "paid" # most compelling first impression + +_DEMO_MODE = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes") + +_COOKIE_JS = """ + +""" + + +def get_simulated_tier() -> str: + """Return the current simulated tier, defaulting to 'paid'.""" + return st.session_state.get("simulated_tier", _DEFAULT_TIER) + + +def set_simulated_tier(tier: str) -> None: + """Set simulated tier in session state + cookie. Reruns the page.""" + if tier not in _VALID_TIERS: + return + st.session_state["simulated_tier"] = tier + components.html(_COOKIE_JS.format(tier=tier), height=0) + st.rerun() + + +def render_demo_toolbar() -> None: + """Render the demo mode toolbar. + + Shows a dismissible info bar with tier-selection pills. + Call this at the TOP of app.py's render pass, before pg.run(). + """ + current = get_simulated_tier() + + labels = { + "free": "Free", + "paid": "Paid ✓" if current == "paid" else "Paid", + "premium": "Premium ✓" if current == "premium" else "Premium", + } + + with st.container(): + cols = st.columns([3, 1, 1, 1, 2]) + with cols[0]: + st.caption("🎭 **Demo mode** — exploring as:") + for i, tier in enumerate(_VALID_TIERS): + with cols[i + 1]: + is_active = tier == current + if st.button( + labels[tier], + key=f"_demo_tier_{tier}", + type="primary" if is_active else "secondary", + use_container_width=True, + ): + if not is_active: + set_simulated_tier(tier) + with cols[4]: + st.caption("[Get your own →](https://circuitforge.tech/software/peregrine)") + st.divider() diff --git a/tests/test_demo_toolbar.py b/tests/test_demo_toolbar.py new file mode 100644 index 0000000..fdeebb0 --- /dev/null +++ b/tests/test_demo_toolbar.py @@ -0,0 +1,63 @@ +"""Tests for app/components/demo_toolbar.py.""" +import sys, os +from pathlib import Path +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# Ensure DEMO_MODE is set so the module initialises correctly +os.environ["DEMO_MODE"] = "true" + + +def test_set_simulated_tier_updates_session_state(monkeypatch): + """set_simulated_tier writes to st.session_state.simulated_tier.""" + session = {} + injected = [] + monkeypatch.setattr("streamlit.components.v1.html", lambda h, height=0: injected.append(h)) + monkeypatch.setattr("streamlit.session_state", session, raising=False) + monkeypatch.setattr("streamlit.rerun", lambda: None) + + from unittest.mock import patch + with patch('app.components.demo_toolbar._DEMO_MODE', True): + from importlib import reload + import app.components.demo_toolbar as m + reload(m) + m.set_simulated_tier("premium") + + assert session.get("simulated_tier") == "premium" + assert any("prgn_demo_tier=premium" in h for h in injected) + + +def test_set_simulated_tier_invalid_ignored(monkeypatch): + """Invalid tier strings are rejected.""" + session = {} + monkeypatch.setattr("streamlit.components.v1.html", lambda h, height=0: None) + monkeypatch.setattr("streamlit.session_state", session, raising=False) + monkeypatch.setattr("streamlit.rerun", lambda: None) + + from unittest.mock import patch + with patch('app.components.demo_toolbar._DEMO_MODE', True): + from importlib import reload + import app.components.demo_toolbar as m + reload(m) + m.set_simulated_tier("ultramax") + + assert "simulated_tier" not in session + + +def test_get_simulated_tier_defaults_to_paid(monkeypatch): + """Returns 'paid' when no tier is set yet.""" + monkeypatch.setattr("streamlit.session_state", {}, raising=False) + monkeypatch.setattr("streamlit.query_params", {}, raising=False) + + from app.components.demo_toolbar import get_simulated_tier + assert get_simulated_tier() == "paid" + + +def test_get_simulated_tier_reads_session(monkeypatch): + """Returns tier from st.session_state when set.""" + monkeypatch.setattr("streamlit.session_state", {"simulated_tier": "free"}, raising=False) + monkeypatch.setattr("streamlit.query_params", {}, raising=False) + + from app.components.demo_toolbar import get_simulated_tier + assert get_simulated_tier() == "free"