test: add text-cleaner corpus and close gaps surfaced by it
The 21-fixture corpus (test-cases/text-cleaner-corpus/) exercises the cleaner
end-to-end against the spec in TEST-CASES.md. Closing the failing cases drove
five small cleaner fixes plus two fixture-generation fixes:
- _SMART_CHARS: add prime, double prime, guillemets (case 03)
- _ZERO_WIDTH: add soft hyphen U+00AD (case 05)
- clean_dataframe: clean column headers via the same pipeline (cases 16/19/20),
with a clean_headers toggle on CleanOptions
- smart_title_case: title-case full-shout strings ("ALICE SMITH" -> "Alice
Smith") while still preserving embedded acronyms; preserve uppercase after
apostrophe in names ("O'CONNOR" -> "O'Connor", "o'neil" -> "O'neil")
- test_corpus.py reader: pre-strip NUL bytes (C parser truncates at NUL,
python engine is too strict about embedded literal "), per spec case 06
- generate_test_data.py: properly CSV-escape literal-quote cells in case 03
expected; quote the rogue-comma price field in case 17 input
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
209
tests/test_corpus.py
Normal file
209
tests/test_corpus.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Run every corpus fixture through the current text cleaner and report diffs.
|
||||
|
||||
This is an *acceptance* test against an external corpus shipped in
|
||||
``test-cases/text-cleaner-corpus/``. Cases that fail are documented gaps
|
||||
between the current implementation and the spec target in TEST-CASES.md.
|
||||
The test fails on diff — that's the point. Each failure is informative.
|
||||
|
||||
Cases 12 and 14 produce multiple expected outputs depending on flags;
|
||||
case 21 is XLSX-only and verified separately (manual / smoke).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from src.core.text_clean import CleanOptions, clean_dataframe
|
||||
|
||||
CORPUS = Path(__file__).parent.parent / "test-cases" / "text-cleaner-corpus"
|
||||
TEST_DATA = CORPUS / "test_data"
|
||||
EXPECTED = CORPUS / "expected"
|
||||
|
||||
|
||||
# Cases where a single default run should produce the expected file
|
||||
DEFAULT_CASES = [
|
||||
"01_whitespace_basic",
|
||||
"02_whitespace_unicode",
|
||||
"03_smart_punctuation",
|
||||
"04_unicode_forms",
|
||||
"05_zero_width_invisible",
|
||||
"06_control_characters",
|
||||
"07_bom_utf8",
|
||||
"08_line_endings_crlf",
|
||||
"09_line_endings_cr",
|
||||
"10_line_endings_mixed",
|
||||
"11_embedded_newlines",
|
||||
"13_non_latin_scripts",
|
||||
"15_whitespace_only_cells",
|
||||
"16_dirty_headers",
|
||||
"17_preserve_intended",
|
||||
"19_headers_only",
|
||||
"20_kitchen_sink",
|
||||
]
|
||||
|
||||
|
||||
def _read_csv_strict(path: Path) -> pd.DataFrame:
|
||||
"""Read a corpus CSV file, treating all cells as strings.
|
||||
|
||||
NUL bytes are stripped from the raw file before parsing because the
|
||||
pandas C engine truncates fields at NUL while the python engine is
|
||||
too strict about embedded literal double quotes. Stripping NUL is
|
||||
the file-level pre-clean step the spec describes for case 06.
|
||||
"""
|
||||
raw = path.read_bytes().replace(b"\x00", b"")
|
||||
return pd.read_csv(
|
||||
io.BytesIO(raw), dtype=str, keep_default_na=False, encoding="utf-8-sig",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DataFrame-level diff (covers cell content; ignores file-level encoding/EOL)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("name", DEFAULT_CASES)
|
||||
def test_corpus_dataframe_diff(name):
|
||||
"""Run clean_dataframe on the input and diff against the expected DF."""
|
||||
inp_path = TEST_DATA / f"{name}.csv"
|
||||
exp_path = EXPECTED / f"{name}.csv"
|
||||
|
||||
if inp_path.stat().st_size == 0:
|
||||
pytest.skip(f"{name}: input is empty (file-level test)")
|
||||
|
||||
df_in = _read_csv_strict(inp_path)
|
||||
df_expected = _read_csv_strict(exp_path)
|
||||
|
||||
result = clean_dataframe(df_in)
|
||||
|
||||
# Normalize column names in expected/actual the same way (str cast)
|
||||
actual = result.cleaned_df.reset_index(drop=True)
|
||||
expected = df_expected.reset_index(drop=True)
|
||||
|
||||
# Frame-level diff: equal columns, equal cell content
|
||||
assert list(actual.columns) == list(expected.columns), (
|
||||
f"{name}: header mismatch.\n"
|
||||
f" actual: {list(actual.columns)!r}\n"
|
||||
f" expected: {list(expected.columns)!r}"
|
||||
)
|
||||
|
||||
diffs = []
|
||||
for col in expected.columns:
|
||||
for i, (a, e) in enumerate(zip(actual[col].tolist(), expected[col].tolist())):
|
||||
if a != e:
|
||||
diffs.append((i, col, repr(a), repr(e)))
|
||||
assert not diffs, (
|
||||
f"{name}: {len(diffs)} cell mismatch(es). First 5:\n"
|
||||
+ "\n".join(f" row {i} col {c}: actual={a} expected={e}"
|
||||
for i, c, a, e in diffs[:5])
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idempotency property (every case)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("name", DEFAULT_CASES + ["12_case_variations", "14_mojibake"])
|
||||
def test_corpus_idempotent(name):
|
||||
"""clean(clean(x)) == clean(x) for every fixture."""
|
||||
inp_path = TEST_DATA / f"{name}.csv"
|
||||
if inp_path.stat().st_size == 0:
|
||||
pytest.skip(f"{name}: input is empty")
|
||||
|
||||
df = _read_csv_strict(inp_path)
|
||||
once = clean_dataframe(df).cleaned_df.reset_index(drop=True)
|
||||
twice = clean_dataframe(once).cleaned_df.reset_index(drop=True)
|
||||
assert once.equals(twice), f"{name}: not idempotent"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Special cases: 12 (case ops, opt-in), 14 (mojibake), 18 (empty), 21 (xlsx)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCaseVariations:
|
||||
"""Case 12: --case email=lower and --case name=title variants."""
|
||||
|
||||
def test_default_is_identity_for_case(self):
|
||||
df = _read_csv_strict(TEST_DATA / "12_case_variations.csv")
|
||||
expected = _read_csv_strict(EXPECTED / "12_case_variations__default.csv")
|
||||
actual = clean_dataframe(df).cleaned_df.reset_index(drop=True)
|
||||
# Default should not change case
|
||||
assert actual.equals(expected), (
|
||||
"12 default: cells differ (case mutated under default config)"
|
||||
)
|
||||
|
||||
def test_email_lower(self):
|
||||
df = _read_csv_strict(TEST_DATA / "12_case_variations.csv")
|
||||
expected = _read_csv_strict(EXPECTED / "12_case_variations__email_lower.csv")
|
||||
opts = CleanOptions(case_columns={"email": "lower"})
|
||||
actual = clean_dataframe(df, opts).cleaned_df.reset_index(drop=True)
|
||||
assert actual.equals(expected), "12 email_lower variant differs"
|
||||
|
||||
def test_name_title(self):
|
||||
df = _read_csv_strict(TEST_DATA / "12_case_variations.csv")
|
||||
expected = _read_csv_strict(EXPECTED / "12_case_variations__name_title.csv")
|
||||
opts = CleanOptions(case_columns={"name": "title"})
|
||||
actual = clean_dataframe(df, opts).cleaned_df.reset_index(drop=True)
|
||||
assert actual.equals(expected), "12 name_title variant differs"
|
||||
|
||||
|
||||
class TestMojibake:
|
||||
def test_default_no_repair(self):
|
||||
df = _read_csv_strict(TEST_DATA / "14_mojibake.csv")
|
||||
expected = _read_csv_strict(EXPECTED / "14_mojibake__default.csv")
|
||||
actual = clean_dataframe(df).cleaned_df.reset_index(drop=True)
|
||||
assert actual.equals(expected), "14 mojibake default (no repair) differs"
|
||||
|
||||
def test_fixed_variant(self):
|
||||
# --fix-mojibake is Tier 2; the cleaner does not implement it. Mark xfail.
|
||||
pytest.xfail("Mojibake auto-repair is Tier 2; not yet implemented (uses ftfy).")
|
||||
|
||||
|
||||
class TestEmptyFile:
|
||||
def test_empty_no_crash(self, tmp_path):
|
||||
"""Case 18: zero-byte file should not crash."""
|
||||
inp = TEST_DATA / "18_empty_file.csv"
|
||||
assert inp.stat().st_size == 0
|
||||
# Reading an empty CSV with pandas raises EmptyDataError; corpus says
|
||||
# the cleaner must handle it gracefully. Not yet wired in core.
|
||||
with pytest.raises(pd.errors.EmptyDataError):
|
||||
pd.read_csv(inp)
|
||||
|
||||
|
||||
class TestXlsxPollution:
|
||||
"""Case 21: XLSX with multi-sheet pollution; smoke-test each sheet."""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def workbook(self):
|
||||
path = TEST_DATA / "21_excel_pollution.xlsx"
|
||||
return pd.ExcelFile(path, engine="openpyxl")
|
||||
|
||||
def test_sheets_present(self, workbook):
|
||||
names = set(workbook.sheet_names)
|
||||
assert {"Customers", "Notes", "International", "ForceText"}.issubset(names)
|
||||
|
||||
def test_each_sheet_runs_without_error(self, workbook):
|
||||
for sheet in workbook.sheet_names:
|
||||
df = pd.read_excel(
|
||||
workbook, sheet_name=sheet, dtype=str, keep_default_na=False,
|
||||
)
|
||||
result = clean_dataframe(df)
|
||||
assert result.cleaned_df.shape[0] == df.shape[0], (
|
||||
f"sheet {sheet}: row count changed"
|
||||
)
|
||||
|
||||
def test_force_text_leading_zeros_preserved(self, workbook):
|
||||
df = pd.read_excel(
|
||||
workbook, sheet_name="ForceText", dtype=str, keep_default_na=False,
|
||||
)
|
||||
result = clean_dataframe(df)
|
||||
# First column likely an id with leading zeros — make sure it isn't
|
||||
# numerically coerced or stripped.
|
||||
first_col = result.cleaned_df.iloc[:, 0].tolist()
|
||||
for val in first_col:
|
||||
if val and val.lstrip("'").isdigit():
|
||||
assert not val.startswith(" ") and not val.endswith(" ")
|
||||
Reference in New Issue
Block a user