Until now every test ran against core or the CLI; the Streamlit GUI was verified by hand. This commit adds tests/gui/ — 139 AppTest- driven tests behind a 'gui' marker so the quick loop (``pytest -m 'not gui'``) stays at 1777 tests / ~10s while ``pytest`` runs everything (1916 / ~14s). Coverage: - test_smoke.py (59): every page renders in EN and ES, expected substring present, sidebar selector mounted. - test_chrome.py (18): language selector flips session state and re-renders; quit button + farewell strings localize; tool-card names use the active language. - test_gate.py (9): require_normalization_gate no-op / warning / short-circuit / hash-mismatch invariants; warning + button localized. - test_workflows.py (14): happy path per Ready tool — stash upload, render, find primary action, verify result lands in session state. - test_dedup_review.py (8): Accept All / Reject All / Clear Decisions wire through to review_decisions; apply_review_decisions semantics (keep-all, merge, column override). - test_advanced_panels.py (15): config_panel widget defaults and options (algorithm, threshold, survivor rule, merge, multiselects, config save/load). - test_errors.py (4): garbage / empty / single-column uploads don't crash; duplicate-target mapping raises InputValidationError. - test_findings_panel.py (12): driven via a small standalone harness page so we test the component without faking a file_uploader. EN + ES strings, per-tool grouping, open-tool button label, untargeted expander, severity summary. Shared infrastructure in tests/gui/conftest.py: - ``stash_upload`` / ``stash_upload_without_gate`` — populate session_state to pre-pass or block the gate. - ``with_language`` — set ``ui_lang`` before run(). - ``collected_text`` — flatten title/caption/markdown/etc. into one string for substring assertions. - Auto-marking: every test in tests/gui/ gets ``@pytest.mark.gui`` via ``pytest_collection_modifyitems``, so the marker isn't per-test boilerplate. 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 "Deduplicator" 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"
|
|
)
|