test(gui): add Streamlit AppTest layer (139 tests)
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>
This commit is contained in:
157
tests/gui/test_gate.py
Normal file
157
tests/gui/test_gate.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""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"
|
||||
)
|
||||
Reference in New Issue
Block a user