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:
2026-05-13 16:13:40 +00:00
parent d0423a8912
commit 35d46a0c1a
12 changed files with 1676 additions and 0 deletions

157
tests/gui/test_gate.py Normal file
View 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"
)