Sidebar nav now groups tools under Data Review / Data Cleaners / Transformations / Automations via st.navigation, replacing the flat auto-discovered list. Tool display names switch to action-first phrasing (Find Duplicates, Fix Missing Values, Find Unusual Values, Standardize Formats, Clean Text, Quality Check, Map Columns, Combine Files, Automated Workflows) in EN + ES packs and on each page's H1. The Data Cleaners section follows the requested order: Missing Values → Outliers → Text Cleaner → Format Standardizer → Deduplicator → Quality Check. (Text Cleaner kept inside cleaners since the request didn't list it but the tool still ships.) Registry now carries a section field; helpers added: tools_in_section(), section_label(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
158 lines
6.3 KiB
Python
158 lines
6.3 KiB
Python
"""Gate tests — ``require_normalization_gate()`` behaviour.
|
|
|
|
The gate sits between every tool page and the user's data. Three states
|
|
exist, each pinned here:
|
|
|
|
1. **No upload** — gate is a no-op; the page proceeds and its own
|
|
uploader handles the file.
|
|
2. **Upload but no normalization result** — gate shows a warning and a
|
|
"Go to Review & Normalize" button, then ``st.stop()`` short-circuits
|
|
the rest of the page.
|
|
3. **Upload + matching passed normalization** — gate is a no-op; the
|
|
page proceeds.
|
|
|
|
We exercise the gate via the Deduplicator page (any tool page would
|
|
work; dedup is the smallest one that doesn't depend on heavy widgets).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from .conftest import (
|
|
collected_text,
|
|
stash_upload,
|
|
stash_upload_without_gate,
|
|
with_language,
|
|
)
|
|
|
|
|
|
# Deduplicator is our canary — it calls ``require_normalization_gate``
|
|
# on the second line of the module. If the gate blocks, the dedup-
|
|
# specific title shouldn't even render.
|
|
GATED_PAGE = "1_Deduplicator"
|
|
|
|
|
|
class TestGateNoUpload:
|
|
"""No upload → the gate exits early and the page renders normally,
|
|
showing its own file uploader. (This is the "user opened the dedup
|
|
page first instead of coming from home" path.)"""
|
|
|
|
def test_no_upload_lets_page_render(self, app_factory):
|
|
app = app_factory(GATED_PAGE)
|
|
app.run()
|
|
assert not app.exception
|
|
text = collected_text(app)
|
|
# The dedup page title is the unambiguous signal that the gate
|
|
# didn't short-circuit.
|
|
assert "Find Duplicates" in text
|
|
|
|
def test_no_upload_no_gate_warning(self, app_factory):
|
|
app = app_factory(GATED_PAGE)
|
|
app.run()
|
|
# The gate's warning string starts with the upload filename. No
|
|
# warning should be present when there's no upload.
|
|
for w in app.warning:
|
|
assert "normalization gate" not in (w.body or "")
|
|
|
|
|
|
class TestGateBlocksWithoutNormalization:
|
|
"""Upload present but no passing normalization → gate fires:
|
|
warning + Go-to-Review button + page short-circuit."""
|
|
|
|
def test_gate_warning_renders(self, app_factory, small_csv_bytes):
|
|
app = app_factory(GATED_PAGE)
|
|
stash_upload_without_gate(app, name="messy.csv", data=small_csv_bytes)
|
|
app.run()
|
|
warnings = [w.body for w in app.warning if w.body]
|
|
joined = " ".join(warnings)
|
|
assert "normalization gate" in joined, (
|
|
f"expected gate warning; got warnings: {warnings}"
|
|
)
|
|
assert "messy.csv" in joined, (
|
|
"gate warning should name the offending file"
|
|
)
|
|
|
|
def test_gate_renders_go_to_review_button(self, app_factory, small_csv_bytes):
|
|
app = app_factory(GATED_PAGE)
|
|
stash_upload_without_gate(app, name="messy.csv", data=small_csv_bytes)
|
|
app.run()
|
|
labels = [b.label for b in app.button]
|
|
assert any("Review & Normalize" in lbl for lbl in labels), (
|
|
f"missing 'Go to Review & Normalize' button; got: {labels}"
|
|
)
|
|
|
|
def test_gate_short_circuits_page(self, app_factory, small_csv_bytes):
|
|
app = app_factory(GATED_PAGE)
|
|
stash_upload_without_gate(app, name="messy.csv", data=small_csv_bytes)
|
|
app.run()
|
|
# When the gate fires it calls ``st.stop()`` after the warning.
|
|
# The page-body widgets (e.g., the advanced-options expander, the
|
|
# dedup-strategy widgets) must NOT be present.
|
|
labels = [b.label for b in app.button]
|
|
# The Run-Dedup primary action lives below the gate — make sure
|
|
# the gate killed the render before it.
|
|
assert not any("Run Deduplication" in lbl for lbl in labels), (
|
|
f"gate failed to short-circuit; saw button: {labels}"
|
|
)
|
|
|
|
def test_gate_warning_localizes_to_spanish(self, app_factory, small_csv_bytes):
|
|
app = app_factory(GATED_PAGE)
|
|
with_language(app, "es")
|
|
stash_upload_without_gate(app, name="messy.csv", data=small_csv_bytes)
|
|
app.run()
|
|
warnings = " ".join(w.body for w in app.warning if w.body)
|
|
# Spanish pack: ``debe pasar la verificación de normalización CSV``.
|
|
assert "normalización" in warnings
|
|
|
|
def test_gate_button_localizes_to_spanish(self, app_factory, small_csv_bytes):
|
|
app = app_factory(GATED_PAGE)
|
|
with_language(app, "es")
|
|
stash_upload_without_gate(app, name="messy.csv", data=small_csv_bytes)
|
|
app.run()
|
|
labels = [b.label for b in app.button]
|
|
assert any("Revisar y Normalizar" in lbl for lbl in labels), (
|
|
f"Spanish gate button missing; got: {labels}"
|
|
)
|
|
|
|
|
|
class TestGateAllowsWithPassedNormalization:
|
|
"""Upload + passed normalization → gate is a no-op and the page
|
|
renders past the gate."""
|
|
|
|
def test_passed_gate_lets_page_render(self, app_factory, small_csv_bytes):
|
|
app = app_factory(GATED_PAGE)
|
|
stash_upload(app, name="messy.csv", data=small_csv_bytes)
|
|
app.run()
|
|
assert not app.exception, f"page raised past gate: {app.exception}"
|
|
# The pickup banner uses the upload name — that's our signal
|
|
# that the gate let us through AND the pickup helper engaged.
|
|
text = collected_text(app)
|
|
assert "messy.csv" in text
|
|
|
|
|
|
class TestGateMismatchedHash:
|
|
"""Upload changes (different bytes) but normalization_for still
|
|
points at the old hash → gate fires again because the result is
|
|
stale. Pins the security-relevant "stale fix doesn't carry over to
|
|
a new file" invariant."""
|
|
|
|
def test_stale_normalization_blocks_new_upload(self, app_factory, small_csv_bytes):
|
|
app = app_factory(GATED_PAGE)
|
|
# Stash bytes A but a normalization_for hash that points at B.
|
|
app.session_state["home_uploaded_bytes"] = small_csv_bytes
|
|
app.session_state["home_uploaded_name"] = "new.csv"
|
|
app.session_state["home_uploaded_size"] = len(small_csv_bytes)
|
|
app.session_state["normalization_for"] = "different-hash-from-an-old-upload"
|
|
|
|
# A passed-result object exists but is keyed to a different file.
|
|
class _Passed:
|
|
passed = True
|
|
app.session_state["normalization_result"] = _Passed()
|
|
|
|
app.run()
|
|
warnings = " ".join(w.body for w in app.warning if w.body)
|
|
assert "normalization gate" in warnings, (
|
|
"stale gate result should not unlock a new upload"
|
|
)
|