feat(demo): add demo_toolbar component (tier simulation for DEMO_MODE)
This commit is contained in:
parent
d748081a53
commit
88e870df5c
2 changed files with 139 additions and 0 deletions
76
app/components/demo_toolbar.py
Normal file
76
app/components/demo_toolbar.py
Normal file
|
|
@ -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 = """
|
||||
<script>
|
||||
(function() {{
|
||||
document.cookie = 'prgn_demo_tier={tier}; path=/; SameSite=Lax';
|
||||
}})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
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()
|
||||
63
tests/test_demo_toolbar.py
Normal file
63
tests/test_demo_toolbar.py
Normal file
|
|
@ -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"
|
||||
Loading…
Reference in a new issue