Spaces:
Sleeping
Sleeping
File size: 14,436 Bytes
b6beb2d 5531435 b6beb2d 5531435 9b2aa9f 5531435 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 | """Tests for the Gradio demo's rendering layer (``docfield.web.app``).
The demo's job is to answer "why did it decide that" without making the reader
learn rule codes. Two properties carry that, and both are easy to break silently:
1. **Rule codes never reach the user-facing surfaces.** The verdict and the
checks list must be plain language; codes belong only in the details tab.
A rule added without copy would otherwise leak "H5" into the UI at the exact
moment nobody is looking, so the drift guards below fail the build instead.
2. **The stated reason is the real one.** Cause-of-review precedence
(processing failure > hard rule > low confidence) and the threshold
comparison are pinned, because a wrong reason is worse than no reason.
Results are built by running the real pipeline with ``StubBackend`` and an
injected acquirer, following ``tests/test_core_smoke.py``. Using genuine
``validate()`` output rather than hand-built reports is what makes the drift
guard meaningful. No network, no API key, no Gradio server.
"""
from __future__ import annotations
import re
from datetime import date
from pathlib import Path
from typing import Any
import gradio as gr
import pytest
from docfield.backends.base import DocumentPayload
from docfield.backends.stub import DEFAULT_STUB_DOCUMENT, StubBackend
from docfield.config import ConfigError, Settings, load_config
from docfield.core import ExtractionResult, process_document
from docfield.parsing.detect import Modality
from docfield.schema.models import Document
from docfield.validation.rules import validate
from docfield.web import app as webapp
TODAY = date(2024, 6, 1)
# Matches a bare rule code (H1..H4, S1..S4) as a whole word.
_CODE_RE = re.compile(r"\b[HS][0-9]\b")
def _offline_settings(**overrides: Any) -> Settings:
"""Settings needing no API key or network (Ollama + OCR is the offline combo)."""
return load_config(extraction_backend="ollama", image_strategy="ocr_then_text", **overrides)
def _acquire(path: Path, modality: Modality) -> DocumentPayload:
return DocumentPayload(modality=modality, source_path=path, text="ignored by stub")
def _run(data: dict[str, Any] | None = None, **backend_kwargs: Any) -> ExtractionResult:
"""Run the real pipeline over a stub backend and return the result."""
backend = StubBackend(data=data or dict(DEFAULT_STUB_DOCUMENT), **backend_kwargs)
return process_document(
"receipt.png",
settings=_offline_settings(),
backend=backend,
acquire=_acquire,
today=TODAY,
)
class _ExplodingBackend:
"""A backend that always raises, to drive the pipeline-error path."""
name = "exploding"
def extract(self, payload: DocumentPayload, schema: type[Document]) -> Any:
raise RuntimeError("backend unavailable")
def _accepted() -> ExtractionResult:
return _run()
def _hard_failed() -> ExtractionResult:
"""Totals that do not reconcile -> H2 fails."""
return _run({**DEFAULT_STUB_DOCUMENT, "total": 999.00})
def _two_hard_failures() -> ExtractionResult:
"""Both the H2 sum and the H3 line-item sum disagree with the stated figures."""
return _run({**DEFAULT_STUB_DOCUMENT, "subtotal": 500.00, "total": 999.00})
def _low_confidence(threshold: float = 0.85) -> ExtractionResult:
"""Clean document with no model signal, decided against a raised threshold.
The threshold must be applied at decision time, not only at render time: the
renderers read ``result.decision`` rather than recomputing it, so handing a
renderer a threshold the pipeline never saw would describe a decision that
was never made.
"""
backend = StubBackend(data=dict(DEFAULT_STUB_DOCUMENT), field_confidence={})
return process_document(
"receipt.png",
settings=_offline_settings(confidence_threshold=threshold),
backend=backend,
acquire=_acquire,
today=TODAY,
)
def _errored() -> ExtractionResult:
return process_document(
"receipt.png",
settings=_offline_settings(),
backend=_ExplodingBackend(),
acquire=_acquire,
today=TODAY,
)
ALL_RESULTS = {
"accepted": _accepted,
"hard_failed": _hard_failed,
"low_confidence": _low_confidence,
"errored": _errored,
}
# ---------------------------------------------------------------------------
# Drift guards -- the maps must track the rules
# ---------------------------------------------------------------------------
def _emitted_codes() -> set[str]:
"""Every rule code a real validate() call can emit."""
codes: set[str] = set()
for doc in (Document(), Document.model_validate(DEFAULT_STUB_DOCUMENT)):
codes.update(r.code for r in validate(doc, today=TODAY).results)
return codes
def test_every_emitted_rule_code_has_plain_language_copy() -> None:
"""A rule with no copy would render as a bare code in the UI."""
missing = _emitted_codes() - set(webapp._RULE_COPY)
assert not missing, f"validation rules with no plain-language copy: {sorted(missing)}"
def test_no_stale_rule_copy() -> None:
"""Copy for a rule that no longer exists is a documentation lie."""
stale = set(webapp._RULE_COPY) - _emitted_codes()
assert not stale, f"copy for rules that no longer exist: {sorted(stale)}"
def test_rule_copy_is_itself_plain_language() -> None:
"""No copy string may contain a rule code, and none may be empty."""
for code, copy in webapp._RULE_COPY.items():
for field, text in copy._asdict().items():
assert text.strip(), f"{code}.{field} is empty"
assert not _CODE_RE.search(text), f"{code}.{field} contains a rule code: {text!r}"
# ---------------------------------------------------------------------------
# Rule codes must not leak into the user-facing surfaces
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("name", sorted(ALL_RESULTS))
def test_checks_block_contains_no_rule_codes(name: str) -> None:
rendered = webapp._render_checks(ALL_RESULTS[name]())
assert not _CODE_RE.search(rendered), rendered
@pytest.mark.parametrize("name", sorted(ALL_RESULTS))
def test_verdict_block_contains_no_rule_codes(name: str) -> None:
rendered = webapp._render_verdict(ALL_RESULTS[name](), threshold=0.50)
assert not _CODE_RE.search(rendered), rendered
def test_details_block_does_contain_rule_codes() -> None:
"""The split is intentional, so assert it from both sides."""
rendered = webapp._render_details(_hard_failed(), threshold=0.50)
assert "H2" in rendered
assert "S1" in rendered
# ---------------------------------------------------------------------------
# Verdict and cause-of-review precedence
# ---------------------------------------------------------------------------
def test_accept_verdict_states_acceptance_plainly() -> None:
rendered = webapp._render_verdict(_accepted(), threshold=0.50)
assert "Accepted automatically" in rendered
assert "Sent to review" not in rendered
def test_accept_verdict_omits_the_numeric_confidence() -> None:
"""Deliberate: the backend reports no per-field signal, so the number would
read as a coin flip on the happy path. It stays in the details tab."""
result = _accepted()
rendered = webapp._render_verdict(result, threshold=0.50)
assert f"{result.confidence:.0%}" not in rendered
def test_hard_failure_verdict_names_the_specific_check_and_its_evidence() -> None:
result = _hard_failed()
rendered = webapp._render_verdict(result, threshold=0.50)
assert "Sent to review" in rendered
assert webapp._RULE_COPY["H2"].bad in rendered
assert "999.0" in rendered # the raw arithmetic, as evidence
def test_multiple_hard_failures_are_all_listed() -> None:
result = _two_hard_failures()
assert len(result.report.hard_failures) > 1
rendered = webapp._render_verdict(result, threshold=0.50)
for rule in result.report.hard_failures:
assert webapp._RULE_COPY[rule.code].bad in rendered
@pytest.mark.parametrize("threshold", [0.85, 0.95])
def test_low_confidence_verdict_names_both_numbers(threshold: float) -> None:
"""Proves the threshold reached the renderer instead of being hardcoded."""
result = _low_confidence(threshold)
assert result.decision == "review"
assert not result.report.hard_failed # low confidence is the only cause
rendered = webapp._render_verdict(result, threshold=threshold)
assert f"{result.confidence:.0%}" in rendered
assert f"{threshold:.0%}" in rendered
def test_pipeline_error_outranks_hard_failure() -> None:
"""The error path validates an empty document, so H4 also fails there.
The reason must name the processing failure, not the downstream symptom.
"""
result = _errored()
assert result.report.hard_failed # H4: no total on the empty document
rendered = webapp._render_verdict(result, threshold=0.50)
assert "Something went wrong" in rendered
assert "backend unavailable" in rendered
assert webapp._RULE_COPY["H4"].bad not in rendered
def test_accepted_result_has_no_review_reason() -> None:
assert webapp._review_reason(_accepted(), threshold=0.50) is None
def test_review_reason_never_falls_through_at_the_threshold_boundary() -> None:
"""``confidence < threshold`` must be the exact negation of route()'s ``>=``.
A document scoring exactly the threshold is accepted, so no reason is owed.
One step below, a reason must be found -- if the comparison here were ``<=``
or a rounded form, a boundary review would reach the useless catch-all.
"""
at_boundary = _low_confidence(0.50)
assert at_boundary.decision == "accept"
assert webapp._review_reason(at_boundary, threshold=0.50) is None
just_under = _low_confidence(0.51)
assert just_under.decision == "review"
reason = webapp._review_reason(just_under, threshold=0.51)
assert reason is not None
assert "held back for a person to confirm" not in reason
# ---------------------------------------------------------------------------
# Checks block content
# ---------------------------------------------------------------------------
def test_checks_lists_both_severity_groups() -> None:
rendered = webapp._render_checks(_accepted())
assert "Must pass to auto-accept" in rendered
assert "Quality signals" in rendered
def test_skip_renders_as_not_applicable() -> None:
"""A skipped rule is not a failure and must not read like one."""
result = _run({"total": "10.00", "vendor_name": "Acme"}) # no currency, no line items
statuses = {r.code: r.status for r in result.report.results}
assert statuses["S2"] == "skip" # absent currency skips since the S2 change
rendered = webapp._render_checks(result)
assert "Not applicable" in rendered
assert "SKIP" not in rendered
def test_failing_check_shows_evidence_and_passing_check_does_not() -> None:
"""Evidence is actionable on failure and noise on success; details has both."""
result = _hard_failed()
checks = webapp._render_checks(result)
details = webapp._render_details(result, threshold=0.50)
h2 = result.report.by_code("H2")
h4 = result.report.by_code("H4")
assert h2 is not None and h2.status == "fail"
assert h4 is not None and h4.status == "pass"
assert h2.message in checks # failure evidence is shown
assert h4.message not in checks # passing evidence is not
assert h4.message in details # but it is one tab away
def test_error_result_notes_the_checks_ran_on_an_empty_document() -> None:
rendered = webapp._render_checks(_errored())
assert "ran against an empty document" in rendered
# ---------------------------------------------------------------------------
# Details block
# ---------------------------------------------------------------------------
def test_details_carries_the_technical_view() -> None:
result = _accepted()
rendered = webapp._render_details(result, threshold=0.50)
assert "stub" in rendered
assert f"{result.confidence:.0%}" in rendered
assert "50%" in rendered
assert str(result.modality) in rendered
def test_details_shows_the_pipeline_error_when_present() -> None:
assert "backend unavailable" in webapp._render_details(_errored(), threshold=0.50)
# ---------------------------------------------------------------------------
# Callback wiring and empty state
# ---------------------------------------------------------------------------
def test_process_with_no_file_returns_the_empty_state() -> None:
assert webapp._process(None) == (
webapp._NO_FILE_VERDICT,
webapp._EMPTY_FIELDS,
webapp._EMPTY_CHECKS,
webapp._EMPTY_DETAILS,
)
def test_process_returns_four_values() -> None:
"""Catches an outputs-arity mismatch that otherwise only shows in a browser."""
assert len(webapp._process(None)) == 4
def test_startup_error_is_rendered_not_raised(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A missing API key is the likeliest failure on a fresh Space deploy.
It raises before any ExtractionResult exists, so it cannot be expressed as a
document outcome and must not surface as a traceback.
"""
doc = tmp_path / "receipt.png"
doc.write_bytes(b"not really a png") # copied before load_config is reached
def _boom() -> Settings:
raise ConfigError("GEMINI_API_KEY is not set")
monkeypatch.setattr(webapp, "load_config", _boom)
verdict, fields, checks, details = webapp._process(str(doc))
assert "Could not run" in verdict
assert "setup problem with the demo" in verdict
assert "GEMINI_API_KEY is not set" in verdict
assert fields == webapp._EMPTY_FIELDS
assert checks == webapp._EMPTY_CHECKS
def test_build_demo_constructs_without_configuration() -> None:
"""Also proves the module reads no config at import or build time, which
would otherwise crash the Space whenever a secret is absent."""
assert isinstance(webapp.build_demo(), gr.Blocks)
def test_privacy_notice_carries_the_training_warning() -> None:
"""NFR-2: the hosted demo must warn that free-tier inputs may be trained on."""
assert "SYNTHETIC / PUBLIC DOCUMENTS ONLY" in webapp._PRIVACY_NOTICE
assert "may train on your inputs" in webapp._PRIVACY_NOTICE
|