GUI/lang-pack tests were asserting against pre-v3 strings ("Data
Cleaning Mastery", "Maestría en limpieza…") that the brand refresh
replaced with "UNALOGIX DataTools" + "Clean. Normalize. Transform."
Updated assertions to the current copy and switched the findings
panel tests to the redesigned flat-list layout (per-finding "Open
Tool →" buttons instead of per-tool expanders).
New coverage:
- tests/test_cli_reconcile.py (13) — preview/apply, tolerance flags,
sign inversion, key flags, error paths, Excel input.
- tests/test_tools_registry.py (27) — unique tool_ids, page_slug →
real file, valid sections/tiers, localized accessor fallbacks,
explicit pins for PDF Extractor + Reconciler entries.
- tests/test_reconcile.py — one-side-empty, key-pass tagging,
additional validation cases, input-DataFrame immutability.
- tests/gui/test_smoke.py — PAGE_SLUGS now includes 10_PDF_Extractor
and 11_Reconciler in both en/es.
- tests/gui/test_workflows.py — TestPdfExtractorWorkflow and
TestReconcilerWorkflow render checks.
Net: 2317 passed → 2418 passed, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
252 lines
9.5 KiB
Python
252 lines
9.5 KiB
Python
"""GUI activation + license-gate tests.
|
|
|
|
These exercise the chrome-level gate that ``hide_streamlit_chrome``
|
|
installs: when no valid license is on disk, every page renders the
|
|
activation form instead of the page body, and tool widgets do NOT
|
|
appear. We test against the Find Duplicates page since it's the smallest
|
|
real-world tool that depends on chrome.
|
|
|
|
The autouse fixture in ``tests/conftest.py`` sets
|
|
``DATATOOLS_DEV_MODE=1``, which the GUI gate respects. Each test
|
|
below uses ``monkeypatch`` to clear that env var so the real gate
|
|
fires; ``isolated_license_path`` then redirects the manager to a
|
|
tmp file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from streamlit.testing.v1 import AppTest
|
|
|
|
from .conftest import collected_text, stash_upload, with_language
|
|
|
|
|
|
@pytest.fixture
|
|
def no_license_env(monkeypatch, tmp_path):
|
|
"""Clear dev mode and point the license at a fresh empty tmp path."""
|
|
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()
|
|
yield tmp_path / "license.json"
|
|
reset_singleton_for_tests()
|
|
|
|
|
|
@pytest.fixture
|
|
def trial_license(no_license_env):
|
|
"""Pre-activate a 1-year trial license for tests that need to
|
|
pass the gate."""
|
|
from src.license import LicenseManager, Tier
|
|
mgr = LicenseManager()
|
|
mgr.issue_trial(name="Test User", email="test@example.com")
|
|
yield mgr
|
|
|
|
|
|
class TestGateBlocksWithoutLicense:
|
|
"""When no license file exists, every page should render the
|
|
activation form and short-circuit the tool body."""
|
|
|
|
def test_home_renders_activation_form(self, no_license_env, home_app):
|
|
home_app.run()
|
|
text = collected_text(home_app)
|
|
assert "Activate DataTools" in text or "Activar DataTools" in text
|
|
|
|
def test_dedup_page_does_not_render_tool_widgets(
|
|
self, no_license_env, app_factory,
|
|
):
|
|
app = app_factory("1_Deduplicator")
|
|
app.run()
|
|
# Without a license, the page should NOT have the dedup-
|
|
# specific advanced-options expander or Find Duplicates button.
|
|
labels = [b.label for b in app.button]
|
|
assert not any("Find Duplicates" in lbl for lbl in labels), (
|
|
f"tool widgets leaked past the gate; got: {labels}"
|
|
)
|
|
|
|
def test_activation_form_localizes_to_spanish(
|
|
self, no_license_env, home_app,
|
|
):
|
|
with_language(home_app, "es")
|
|
home_app.run()
|
|
text = collected_text(home_app)
|
|
assert "Activar DataTools" in text
|
|
|
|
def test_sidebar_shows_not_activated(self, no_license_env, home_app):
|
|
home_app.run()
|
|
# Sidebar caption "🔒 Not activated".
|
|
captions = [c.value for c in home_app.sidebar.caption]
|
|
joined = " ".join(captions)
|
|
assert "Not activated" in joined or "Sin activar" in joined
|
|
|
|
|
|
class TestGatePassesWithTrialLicense:
|
|
def test_home_renders_full_grid(self, trial_license, home_app):
|
|
home_app.run()
|
|
text = collected_text(home_app)
|
|
# With a valid license, the activation form should NOT be the
|
|
# primary content; we should see the home tagline + tool cards.
|
|
assert "Clean. Normalize. Transform." in text
|
|
assert "Activate DataTools" not in text # form not shown inline
|
|
|
|
def test_sidebar_shows_active_status(self, trial_license, home_app):
|
|
home_app.run()
|
|
captions = " ".join(c.value for c in home_app.sidebar.caption)
|
|
# "Trial · 364 days left" (give or take one).
|
|
assert "Trial" in captions or "Prueba" in captions
|
|
assert "days left" in captions or "días" in captions
|
|
|
|
def test_dedup_page_renders_tool_widgets(
|
|
self, trial_license, app_factory, small_csv_bytes,
|
|
):
|
|
app = app_factory("1_Deduplicator")
|
|
stash_upload(app, name="messy.csv", data=small_csv_bytes)
|
|
app.run()
|
|
labels = [b.label for b in app.button]
|
|
assert any("Find Duplicates" in lbl for lbl in labels), (
|
|
f"tool widgets blocked by gate even with valid license; "
|
|
f"got: {labels}"
|
|
)
|
|
|
|
|
|
class TestActivationFormSubmission:
|
|
"""End-to-end: paste a generated blob into the inline form and
|
|
confirm the gate releases on next render."""
|
|
|
|
def test_paste_blob_activates_and_unlocks(
|
|
self, no_license_env, home_app,
|
|
):
|
|
# Generate a blob the same way scripts/generate_license.py does.
|
|
from src.license import LicenseManager, Tier
|
|
from src.license.crypto import encode_blob
|
|
mint_mgr = LicenseManager()
|
|
# Use a separate tmp path so the trial we mint doesn't fight
|
|
# with the manager the GUI will use.
|
|
from tempfile import mkstemp
|
|
from pathlib import Path
|
|
_, p = mkstemp(suffix=".json")
|
|
mint_mgr._path = Path(p)
|
|
lic = mint_mgr._mint(
|
|
name="Buyer", email="buyer@example.com", tier=Tier.CORE,
|
|
)
|
|
blob = encode_blob(lic.to_dict())
|
|
# Wipe the temporary mint manager so its file doesn't collide.
|
|
mint_mgr.deactivate()
|
|
|
|
# Now drive the real GUI manager.
|
|
home_app.run()
|
|
# The activation form is inline in chrome — its widget keys
|
|
# are prefixed ``gate_``.
|
|
home_app.text_input(key="gate_name").set_value("Buyer").run()
|
|
home_app.text_input(key="gate_email").set_value("buyer@example.com").run()
|
|
home_app.text_area(key="gate_blob").set_value(blob).run()
|
|
# Submit primary form button.
|
|
submit = next(
|
|
b for b in home_app.button
|
|
if b.label in ("Activate", "Apply renewal")
|
|
)
|
|
submit.click().run()
|
|
|
|
# After activation the page reruns and the activation form
|
|
# should be gone — we should see the home page proper.
|
|
text = collected_text(home_app)
|
|
assert "Clean. Normalize. Transform." in text
|
|
|
|
def test_trial_button_absent_paid_only(self, no_license_env, home_app):
|
|
"""v1.6 dropped the user-facing trial flow — paid licenses only.
|
|
Verify the trial button is not on the activation form."""
|
|
home_app.run()
|
|
labels = [b.label for b in home_app.button]
|
|
for lbl in labels:
|
|
assert "trial" not in lbl.lower(), (
|
|
f"trial button leaked into activation form: {lbl!r}"
|
|
)
|
|
assert "prueba" not in lbl.lower(), (
|
|
f"Spanish trial button leaked into form: {lbl!r}"
|
|
)
|
|
|
|
|
|
class TestActivationPageDirect:
|
|
"""``pages/_Activate.py`` renders the same form regardless of
|
|
license state — buyer can revisit it to review or deactivate."""
|
|
|
|
def test_activate_page_renders_with_valid_license(
|
|
self, trial_license, app_factory,
|
|
):
|
|
app = app_factory("_Activate")
|
|
app.run()
|
|
text = collected_text(app)
|
|
# Page title localized.
|
|
assert "Activate DataTools" in text
|
|
# Deactivate option only shown after activation.
|
|
labels = [b.label for b in app.button]
|
|
assert any("Deactivate" in lbl for lbl in labels)
|
|
|
|
def test_activate_page_renders_without_license(
|
|
self, no_license_env, app_factory,
|
|
):
|
|
app = app_factory("_Activate")
|
|
app.run()
|
|
text = collected_text(app)
|
|
assert "Activate DataTools" in text
|
|
# Deactivate button should NOT appear when nothing is active.
|
|
labels = [b.label for b in app.button]
|
|
assert not any("Deactivate" in lbl for lbl in labels)
|
|
|
|
|
|
class TestSidebarRenewalWarning:
|
|
"""A license with <30 days remaining surfaces a sidebar warning."""
|
|
|
|
def test_renewal_warning_appears_under_30_days(
|
|
self, no_license_env, home_app, monkeypatch,
|
|
):
|
|
# Mint a license with 7 days left.
|
|
from datetime import datetime, timedelta, timezone
|
|
from src.license import License, LicenseManager, Tier
|
|
from src.license import crypto as _crypto
|
|
from src.license.features import all_features_for_tier
|
|
|
|
future = (datetime.now(timezone.utc) + timedelta(days=7)).strftime(
|
|
"%Y-%m-%dT%H:%M:%SZ"
|
|
)
|
|
lic = License(
|
|
name="X", email="x@x.com",
|
|
license_key="DT1-CORE-EXPIRING",
|
|
tier=Tier.CORE,
|
|
features=all_features_for_tier(Tier.CORE),
|
|
issued_at="2026-05-13T00:00:00Z",
|
|
expires_at=future,
|
|
signature="",
|
|
)
|
|
sig = _crypto.sign(lic.to_canonical_dict())
|
|
signed = License(**{**lic.__dict__, "signature": sig})
|
|
LicenseManager().save(signed)
|
|
|
|
home_app.run()
|
|
sidebar_warnings = [w.body for w in home_app.sidebar.warning if w.body]
|
|
joined = " ".join(sidebar_warnings)
|
|
assert "expires in" in joined.lower() or "caduca en" in joined.lower(), (
|
|
f"expected renewal warning in sidebar; got: {sidebar_warnings}"
|
|
)
|
|
|
|
|
|
class TestLicenseStatusBadgeI18n:
|
|
"""Sidebar status badge tier name must localize."""
|
|
|
|
def test_core_tier_localizes_in_spanish(
|
|
self, no_license_env, home_app, monkeypatch,
|
|
):
|
|
from src.license import LicenseManager, Tier
|
|
LicenseManager()._mint(
|
|
name="X", email="x@x.com", tier=Tier.CORE,
|
|
)
|
|
with_language(home_app, "es")
|
|
home_app.run()
|
|
captions = " ".join(c.value for c in home_app.sidebar.caption)
|
|
# The es pack maps ``license.tier_core`` to "Core" — same word
|
|
# in Spanish — but the surrounding template (``días restantes``)
|
|
# localizes.
|
|
assert "días restantes" in captions, (
|
|
f"Spanish status label missing; sidebar captions: {captions}"
|
|
)
|