Files
datatools-dev/tests/test_encodings_corpus.py
Michael 966af8ef94 feat: 3 new tools, format streaming, distribution-ready demo + landing pages
Tools shipped this batch (4 → 6 of 9 Ready):
  04 Missing Value Handler   src/core/missing.py + cli_missing.py + GUI
  05 Column Mapper           src/core/column_mapper.py + cli_column_map.py + GUI
  09 Pipeline Runner         src/core/pipeline.py + cli_pipeline.py + GUI
                             with soft tool-dependency graph (recommended,
                             not enforced) and JSON save/load for repeatable
                             weekly cleanups.

Format Standardizer reworked for 1 GB international files:
  • Vectorised dispatch + LRU cache over phone/date/currency/boolean/email
  • Per-row country / address columns drive parsing
  • Audit cap (default 10 k rows, ~50 MB RAM)
  • standardize_file(): chunked streaming entry point (~165 k rows/sec)
  • currency_decimal="auto" for EU comma-decimal locales
  • R$ / kr / zł multi-char currency prefixes
  • cli_format.py with auto-stream above 100 MB inputs

Encoding detection arbiter + language-aware probe:
  Closes the last 4 xfails (cp1250 / mac_iceland / shift_jis_2004 / lying-BOM)
  via tied-confidence arbiter + Cyrillic / EE-Latin coverage probes.

Distribution-readiness assets:
  • streamlit_app.py — Streamlit Community Cloud entry shim
  • src/gui/app_demo.py — single-page demo, ?p=<persona> routing,
    100-row cap + watermark, free-vs-paid boundary enforced at surface
  • samples/demo/ — 3 niche datasets + pre-tuned pipeline JSONs
  • landing/ — 4 static HTML pages (apex chooser + 3 niche),
    shared CSS, deploy.py URL-substitution script,
    auto-generated robots.txt + sitemap.xml + 404.html + favicon
  • docs/PLAN.md, DEMO-PLAN.md, DEPLOYMENT.md, POST-LAUNCH.md, NEXT-STEPS.md
    — full strategy + measurement + deployment + master checklist

Test counts:
  before: 1,520 passed · 4 skipped · 17 xfailed
  after:  1,729 passed · 0 skipped · 0  xfailed

Tier-1 corpora added:
  • missing-corpus           3 use cases + 16 edge cases
  • column-mapper-corpus     3 use cases + 5 edge cases
  • format-cleaner intl      20-row 13-country stress fixture

Engine hardening flushed out by the corpora:
  • interpolate guards against object-dtype columns
  • mean/median skip all-NaN columns (silences numpy warning)
  • fillna runs under future.no_silent_downcasting (silences pandas warning)
  • mojibake test no longer skips when ftfy installed (monkeypatch path)
  • drop-row threshold semantics: strict-greater (consistent across rows / cols)
  • currency_decimal validator allow-set updated for "auto"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:31:26 +00:00

171 lines
5.7 KiB
Python

"""Run the analyzer + detector against the code-page test corpus.
Fixtures live in ``test-cases/encodings-corpus/`` (synced from
``Business/DataTools/test-case-code-page-variations``). Each test runs
against one fixture and uses the corpus manifest
(``expected_detection.csv``) for ground truth.
What's tested
-------------
1. ``analyze()`` does not crash on any fixture — every encoded file
produces a Finding list (possibly empty), never an exception.
2. ``detect_encoding()`` returns one of the manifest's accepted answers,
OR the manifest itself flagged the case as AMBIGUOUS / UNRELIABLE /
REJECT / LOW_CONFIDENCE.
3. The decoded DataFrame matches the canonical reference content.
Detection arbiter (cp1250→cp1252, mac_iceland→mac_roman, lying-BOM
recovery) and a language-aware probe (Cyrillic / EE-Latin coverage)
together close every documented gap; the ``KNOWN_*_FAILURES`` dicts
below are kept empty as a tripwire — re-add an entry only when a real
limitation surfaces.
"""
from __future__ import annotations
import csv
import io
from pathlib import Path
import pandas as pd
import pytest
from src.core.analyze import analyze, _load_for_analysis
from src.core.io import detect_encoding
CORPUS = Path(__file__).parent.parent / "test-cases" / "encodings-corpus"
MANIFEST = CORPUS / "expected_detection.csv"
REFERENCE_DIR = CORPUS / "reference"
# Known failures the analyzer does not yet handle correctly. Each entry
# has a one-line reason — drop the entry once a fix lands.
KNOWN_DETECTION_FAILURES: dict[str, str] = {}
KNOWN_DECODE_FAILURES: dict[str, str] = {}
def _normalize_encoding(name: str) -> str:
return name.lower().replace("-", "_").replace(" ", "_")
def _load_manifest() -> list[dict]:
if not MANIFEST.exists():
return []
with MANIFEST.open() as fh:
return list(csv.DictReader(fh))
def _load_references() -> dict[str, str]:
if not REFERENCE_DIR.exists():
return {}
return {
p.stem.replace(".utf8", ""): p.read_text(encoding="utf-8")
for p in REFERENCE_DIR.glob("*.utf8.txt")
}
MANIFEST_ENTRIES = _load_manifest()
REFERENCES = _load_references()
def _entry_id(entry: dict) -> str:
return entry["filename"]
# ---------------------------------------------------------------------------
# 1. Analyzer never crashes
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("entry", MANIFEST_ENTRIES, ids=_entry_id)
def test_analyzer_does_not_crash(entry):
findings = analyze(CORPUS / entry["filename"], sample_rows=1000)
# Either empty or a list of Findings — but never raises.
assert isinstance(findings, list)
# ---------------------------------------------------------------------------
# 2. detect_encoding returns an acceptable answer
# ---------------------------------------------------------------------------
def _detection_marker(entry):
fname = entry["filename"]
if fname in KNOWN_DETECTION_FAILURES:
return pytest.mark.xfail(
reason=KNOWN_DETECTION_FAILURES[fname], strict=False,
)
return ()
@pytest.mark.parametrize(
"entry",
[
pytest.param(e, marks=_detection_marker(e), id=_entry_id(e))
for e in MANIFEST_ENTRIES
],
)
def test_detect_encoding_accepted(entry):
accepted_raw = entry["expected_detection"]
# Manifest fuzzy markers — any answer is acceptable.
if any(m in accepted_raw for m in ("AMBIGUOUS", "UNRELIABLE", "REJECT", "LOW_CONFIDENCE")):
# Just call to ensure no exception.
detect_encoding(CORPUS / entry["filename"])
return
accepted = {_normalize_encoding(s.strip()) for s in accepted_raw.split("|") if s.strip()}
detected = detect_encoding(CORPUS / entry["filename"])
detected_n = _normalize_encoding(detected)
assert detected_n in accepted, (
f"{entry['filename']}: detected {detected!r} not in {sorted(accepted)}"
)
# ---------------------------------------------------------------------------
# 3. Decoded content matches the canonical reference
# ---------------------------------------------------------------------------
def _decode_marker(entry):
fname = entry["filename"]
if fname in KNOWN_DECODE_FAILURES:
return pytest.mark.xfail(
reason=KNOWN_DECODE_FAILURES[fname], strict=False,
)
return ()
def _decodable_entries():
"""Skip pathological cases that have no canonical reference."""
return [e for e in MANIFEST_ENTRIES if e["canonical_content_id"] in REFERENCES]
@pytest.mark.parametrize(
"entry",
[
pytest.param(e, marks=_decode_marker(e), id=_entry_id(e))
for e in _decodable_entries()
],
)
def test_decoded_matches_reference(entry):
# The reference files preserve smart quotes — disable byte-level
# smart-quote folding so this round-trip identity test isn't
# confounded by the analyzer's deliberate parser-safety fold.
df, _, _ = _load_for_analysis(
CORPUS / entry["filename"], sample_rows=1000, fold_quotes=False,
)
ref_text = REFERENCES[entry["canonical_content_id"]]
ref_rows = list(csv.reader(io.StringIO(ref_text)))
if not ref_rows:
pytest.skip("empty reference")
# First row = headers in the reference; compare data rows to df rows.
ref_data = ref_rows[1:]
assert len(df) >= len(ref_data), (
f"{entry['filename']}: parsed {len(df)} rows, reference has {len(ref_data)}"
)
for r, ref_row in enumerate(ref_data):
for c, ref_cell in enumerate(ref_row):
actual = str(df.iloc[r, c])
assert actual == ref_cell, (
f"{entry['filename']}: row {r} col {c}: "
f"got {actual!r}, expected {ref_cell!r}"
)