Two coupled changes:
1. Lite tier
- New Tier.LITE in src/license/schema.py.
- FEATURES_BY_TIER[Tier.LITE] = {Deduplicator, Text Cleaner,
Format Standardizer}. The three universally-useful tools that
cover the most common bookkeeping / RevOps / Klaviyo prep
workflows. Other six tools require Core.
- i18n: license.tier_lite, license.feature_locked_title,
license.feature_locked_body, license.upgrade_link,
license.status_locked (en + es).
- Per-tool feature gate at every GUI tool page
(require_feature_or_render_upgrade) and every tool CLI
(guard(feature=...)). A locked tool renders an upgrade
prompt + Manage-license button (GUI) or exits with code 2
(CLI).
- Home grid: tool cards the user's tier doesn't unlock get a
red 🔒 Locked badge in place of green Ready.
2. Trial removed
- Activation form's "Start 1-year trial" button removed.
- license_cli's `trial` subcommand removed.
- activation.trial_button / activation.trial_help i18n keys
dropped (pack parity test stays green).
- Tier.TRIAL stays in the enum (back-compat with any field-
tested trial licenses); LicenseManager._mint stays internal
for tests and the seller's key generator.
- Decision logged in DECISIONS §9b: a 1-year all-features
trial undercuts paid Lite; paid-only keeps tier economics
clean.
Tests (+29 net): +17 Lite-tier unit/guard tests + 13 Lite-tier
GUI tests + 1 trial-absent assertion - 2 trial CLI tests - 1
trial GUI button test. Total: 1995 → 2024.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
136 lines
4.9 KiB
Python
136 lines
4.9 KiB
Python
"""GUI tests for the Lite tier.
|
|
|
|
A Lite license unlocks Deduplicator, Text Cleaner, Format
|
|
Standardizer. Opening any other tool page (Missing Values, Column
|
|
Mapper, Pipeline Runner, etc.) must render an upgrade prompt and
|
|
short-circuit the page body.
|
|
|
|
The home grid shows a 🔒 Locked badge on the cards for tools the
|
|
user's tier doesn't unlock.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from .conftest import collected_text, stash_upload
|
|
|
|
|
|
@pytest.fixture
|
|
def lite_license(monkeypatch, tmp_path):
|
|
"""Activate a Lite license; return nothing — the env vars route
|
|
every page on this test through this license."""
|
|
monkeypatch.delenv("DATATOOLS_DEV_MODE", raising=False)
|
|
monkeypatch.setenv(
|
|
"DATATOOLS_LICENSE_PATH", str(tmp_path / "license.json"),
|
|
)
|
|
from src.license.manager import reset_singleton_for_tests
|
|
reset_singleton_for_tests()
|
|
from src.license import LicenseManager, Tier
|
|
LicenseManager()._mint(
|
|
name="Lite User", email="lite@example.com", tier=Tier.LITE,
|
|
)
|
|
yield
|
|
reset_singleton_for_tests()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unlocked tools render normally
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLiteUnlockedPages:
|
|
@pytest.mark.parametrize("slug,signal", [
|
|
("1_Deduplicator", "Deduplicator"),
|
|
("2_Text_Cleaner", "Text Cleaner"),
|
|
("3_Format_Standardizer", "Format Standardizer"),
|
|
])
|
|
def test_unlocked_pages_render_body(
|
|
self, lite_license, app_factory, slug, signal, small_csv_bytes,
|
|
):
|
|
app = app_factory(slug)
|
|
stash_upload(app, name="messy.csv", data=small_csv_bytes)
|
|
app.run()
|
|
text = collected_text(app)
|
|
assert signal in text
|
|
# No upgrade prompt.
|
|
assert "isn't on your" not in text and "no está incluida" not in text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Locked tools render upgrade prompt
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLiteLockedPages:
|
|
@pytest.mark.parametrize("slug", [
|
|
"4_Missing_Values",
|
|
"5_Column_Mapper",
|
|
"9_Pipeline_Runner",
|
|
"6_Outlier_Detector",
|
|
"7_Multi_File_Merger",
|
|
"8_Validator_Reporter",
|
|
])
|
|
def test_locked_page_shows_upgrade_prompt(
|
|
self, lite_license, app_factory, slug, small_csv_bytes,
|
|
):
|
|
app = app_factory(slug)
|
|
stash_upload(app, name="messy.csv", data=small_csv_bytes)
|
|
app.run()
|
|
text = collected_text(app)
|
|
# Upgrade prompt title carries the localized lock + tier label.
|
|
assert "isn't on your" in text or "no está incluida" in text, (
|
|
f"locked page {slug!r} missing upgrade prompt; got:\n"
|
|
f"{text[:500]}"
|
|
)
|
|
|
|
def test_upgrade_button_present(
|
|
self, lite_license, app_factory, small_csv_bytes,
|
|
):
|
|
app = app_factory("4_Missing_Values")
|
|
stash_upload(app, name="messy.csv", data=small_csv_bytes)
|
|
app.run()
|
|
labels = [b.label for b in app.button]
|
|
assert any("Manage license" in lbl for lbl in labels), (
|
|
f"upgrade prompt missing Manage-license button; got: {labels}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Home grid shows lock badges
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLiteHomeGridBadges:
|
|
def test_locked_tool_card_shows_lock_badge(
|
|
self, lite_license, home_app,
|
|
):
|
|
home_app.run()
|
|
text = collected_text(home_app)
|
|
# Missing Value Handler is locked under Lite — its card should
|
|
# have a 🔒 Locked badge.
|
|
# We assert the lock glyph appears alongside the locked tool's
|
|
# display name. Streamlit renders the markdown verbatim so the
|
|
# ``🔒 Locked`` text appears in the page markdown stream.
|
|
assert "🔒" in text or "Locked" in text, (
|
|
"home grid missing lock badge for Lite-locked tool"
|
|
)
|
|
|
|
def test_unlocked_tool_card_no_lock(self, lite_license, home_app):
|
|
home_app.run()
|
|
# Dedup is unlocked under Lite. Its card markdown should NOT
|
|
# contain a lock glyph adjacent to its name. We can't easily
|
|
# scope by card without parsing the markdown stream, but we
|
|
# can confirm both ``Ready`` (unlocked) and ``Locked``
|
|
# (locked) badges coexist on the page.
|
|
text = collected_text(home_app)
|
|
assert "Ready" in text or "Listo" in text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sidebar status shows Lite
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLiteSidebarStatus:
|
|
def test_sidebar_caption_mentions_lite(self, lite_license, home_app):
|
|
home_app.run()
|
|
captions = " ".join(c.value for c in home_app.sidebar.caption)
|
|
assert "Lite" in captions
|