Files
datatools-dev/tests/test_audit_fixes.py
Michael 2eece6467d refactor: dedup, consolidate, harden public APIs across core modules
Closes 16 high-value findings from a parallel cross-module review.

Refactors:
- New src/core/_constants.py centralizes USPS street-suffix
  abbreviations, US state names, and 2-letter postal codes — one source
  of truth for both normalize_address (matching keys) and
  standardize_address (display formatting). Eliminates ~80 lines of
  duplicated dicts across normalizers.py and format_standardize.py.
- format_standardize.py: collapse 4 identical nested _err() helpers
  into one shared _err_or_passthrough() module function; drop a dead
  duplicate `return _err("not a phone number")` branch in
  standardize_phone.
- format_standardize.py: precompile per-locale month-name regexes
  (_MONTH_LOCALE_PATTERNS) and per-state-name regexes
  (_STATE_NAME_PATTERNS) at import time — they were rebuilt on every
  cell, a measurable hot path on million-row inputs.
- dedup.py: extract _is_missing(value) helper; one definition of
  "this cell is None / NaN / pd.NA" instead of two.
- fixes.py: extract _is_string_column(ser) helper; one dtype check
  instead of three duplicates across _apply_to_strings,
  _vectorized_translate, _vectorized_regex_sub.

Production-readiness:
- format_standardize.standardize_dataframe now logs a warning when
  more than 10% of typed cells are unparseable — surfaces the
  silently-broken-pipeline failure mode.
- StandardizeOptions.from_dict validates date_order / phone_format /
  currency_decimal / name_case / boolean_style / *_error_policy
  enum values up front, with a clear error message instead of a deep
  crash inside the per-cell function.
- StandardizeOptions.from_file and DeduplicationConfig.from_file wrap
  read + json.loads with descriptive OSError / ValueError messages
  including the file path.
- standardize_date(month_locales=...) validates locale codes against
  the available set instead of silently passing through unknown ones.
- io.read_file rejects chunk_size <= 0 (was silently failing inside
  pandas) and logs the resolved suffix + chunk_size at info level so
  data-pipeline runs are debuggable.
- io.read_file's FileNotFoundError gains explanatory context.
- io.write_file, text_clean.clean_dataframe, and dedup.deduplicate
  now reject non-DataFrame inputs with clear TypeError instead of
  cryptic pandas tracebacks downstream.
- dedup.deduplicate validates that survivor_rule=KEEP_MOST_RECENT has
  a usable date_column up front; the helper _select_survivor now
  raises (instead of silently falling back to keep_first) when called
  directly with bad arguments.
- dedup.deduplicate gains a structured no-op return when strategies
  is empty after auto-detection — preserves schema instead of crashing.
- analyze._detect_inconsistent_date_format narrows its bare except to
  (TypeError, ValueError) and logs a debug line so genuine bugs don't
  hide behind silent skip.

Tests:
- tests/test_audit_fixes.py grows by 11 cases covering the new
  validation paths (chunk_size, DataFrame guards, KEEP_MOST_RECENT
  date_column, enum validation, locale validation, JSON error wrapping).

Full project suite: 1208 passed, 4 skipped, 17 xfailed.

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

377 lines
15 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Regression tests for bugs surfaced by the cross-tool audit.
Each test pins a specific behavioral bug or gap that an audit
identified. Test names match the BUG-N / GAP-N tags in the audit
notes so a future reader can trace why each test exists.
"""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
from src.core.analyze import _NULL_LIKE, _detect_mixed_case_email
import src.core.fixes as f
from src.core.config import (
ColumnStrategyConfig,
DeduplicationConfig,
StrategyConfig,
)
from src.core.dedup import (
Algorithm,
ColumnMatchStrategy,
MatchStrategy,
deduplicate,
)
from src.core.io import detect_header_row
from src.core.text_clean import sentence_case, smart_title_case, strip_bom
# ---------------------------------------------------------------------------
# BUG-1: dedup NaN values must not match as duplicates
# ---------------------------------------------------------------------------
class TestDedupNaNHandling:
def test_two_nan_emails_do_not_match(self):
# Both rows have NaN for email; no other matching column. Without
# the fix, str(NaN) == "nan" would match exactly and the rows
# would silently merge.
df = pd.DataFrame({
"id": [1, 2],
"email": [np.nan, np.nan],
})
strategies = [MatchStrategy(column_strategies=[
ColumnMatchStrategy(column="email", algorithm=Algorithm.EXACT,
threshold=100.0),
])]
result = deduplicate(df, strategies=strategies)
assert len(result.deduplicated_df) == 2
assert len(result.match_groups) == 0
def test_one_nan_one_real_does_not_match(self):
df = pd.DataFrame({
"email": [np.nan, "alice@example.com"],
})
strategies = [MatchStrategy(column_strategies=[
ColumnMatchStrategy(column="email", algorithm=Algorithm.EXACT),
])]
result = deduplicate(df, strategies=strategies)
assert len(result.deduplicated_df) == 2
def test_none_does_not_match_string_none(self):
df = pd.DataFrame({
"name": [None, "None"],
})
strategies = [MatchStrategy(column_strategies=[
ColumnMatchStrategy(column="name", algorithm=Algorithm.EXACT),
])]
result = deduplicate(df, strategies=strategies)
assert len(result.deduplicated_df) == 2
# ---------------------------------------------------------------------------
# BUG-2: removed_df must preserve column schema even when empty
# ---------------------------------------------------------------------------
class TestDedupRemovedDfSchema:
def test_empty_removed_df_has_same_columns(self):
df = pd.DataFrame({
"name": ["alice", "bob", "carol"],
"email": ["a@x.com", "b@x.com", "c@x.com"],
})
strategies = [MatchStrategy(column_strategies=[
ColumnMatchStrategy(column="email", algorithm=Algorithm.EXACT),
])]
result = deduplicate(df, strategies=strategies)
# No duplicates → empty removed_df, but columns must match.
assert len(result.removed_df) == 0
assert list(result.removed_df.columns) == list(result.deduplicated_df.columns)
# ---------------------------------------------------------------------------
# GAP-3: missing column reference should raise
# ---------------------------------------------------------------------------
class TestDedupMissingColumn:
def test_missing_column_raises(self):
df = pd.DataFrame({"email": ["a@x.com"]})
strategies = [MatchStrategy(column_strategies=[
ColumnMatchStrategy(column="e_mail", algorithm=Algorithm.EXACT),
])]
with pytest.raises(ValueError, match="not present in the input"):
deduplicate(df, strategies=strategies)
# ---------------------------------------------------------------------------
# GAP-4: threshold must be in [0, 100]
# ---------------------------------------------------------------------------
class TestThresholdValidation:
def test_negative_threshold_rejected(self):
with pytest.raises(ValueError, match=r"\[0, 100\]"):
ColumnMatchStrategy(column="x", threshold=-1)
def test_over_hundred_rejected(self):
with pytest.raises(ValueError, match=r"\[0, 100\]"):
ColumnMatchStrategy(column="x", threshold=101)
def test_zero_and_hundred_allowed(self):
ColumnMatchStrategy(column="x", threshold=0)
ColumnMatchStrategy(column="x", threshold=100)
def test_non_numeric_rejected(self):
with pytest.raises(TypeError):
ColumnMatchStrategy(column="x", threshold="high") # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# BUG-9: replace_null_sentinels must coerce non-string sentinels
# ---------------------------------------------------------------------------
class TestReplaceNullSentinelsTypes:
def test_int_sentinels_do_not_crash(self):
df = pd.DataFrame({"x": ["0", "5", ""]})
out, _ = f.replace_null_sentinels(df, {"sentinels": [0, "5"]})
assert out.loc[0, "x"] == "" # "0" matched int 0 stringified
assert out.loc[1, "x"] == "" # "5" matched
assert out.loc[2, "x"] == "" # already empty
def test_none_sentinel_skipped(self):
df = pd.DataFrame({"x": ["a", "b"]})
# Should not crash on None entry in the sentinel list.
out, _ = f.replace_null_sentinels(df, {"sentinels": ["a", None]})
assert out.loc[0, "x"] == ""
assert out.loc[1, "x"] == "b"
# ---------------------------------------------------------------------------
# BUG-10: malformed regex should raise ValueError, not re.error
# ---------------------------------------------------------------------------
class TestVectorizedRegexErrorHandling:
def test_malformed_pattern_raises_valueerror(self):
df = pd.DataFrame({"x": ["abc"]})
with pytest.raises(ValueError, match="Invalid regex pattern"):
f._vectorized_regex_sub(df, "[invalid", "")
# ---------------------------------------------------------------------------
# NIT-12: strip_bom strips at most one BOM
# ---------------------------------------------------------------------------
class TestStripBomSingleChar:
def test_strips_one_leading_bom(self):
assert strip_bom("hello") == "hello"
def test_does_not_strip_multiple_consecutive_boms(self):
# Per docstring: "at most one BOM". Second BOM stays so the
# caller can see something odd happened.
assert strip_bom("hello") == "hello"
def test_no_bom_unchanged(self):
assert strip_bom("hello") == "hello"
def test_non_string_passthrough(self):
assert strip_bom(None) is None # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# Smart title case — particle behavior at boundaries (regression / docs)
# ---------------------------------------------------------------------------
class TestSmartTitleCaseBoundaries:
def test_first_word_particle_capitalized(self):
# "a" at index 0 is a particle but must capitalize as the first
# word of a title.
assert smart_title_case("a story") == "A Story"
def test_last_word_particle_capitalized(self):
# "to" at the end is the last word; must capitalize.
assert smart_title_case("things to") == "Things To"
def test_mid_string_particles_lowercase(self):
assert smart_title_case("the cat in the hat") == "The Cat in the Hat"
# ---------------------------------------------------------------------------
# NIT-14: sentence_case dead branch removed — regression guard
# ---------------------------------------------------------------------------
class TestSentenceCaseUnchanged:
def test_basic(self):
assert sentence_case("hello. world.") == "Hello. World."
def test_open_paren_does_not_consume_trigger(self):
# The dead-branch removal didn't change behavior; this is a
# regression guard that opening punctuation still doesn't
# capitalize itself but doesn't reset the trigger either.
assert sentence_case('hello. "world"') == 'Hello. "World"'
# ---------------------------------------------------------------------------
# BUG-18: detect_header_row must not pick all-empty rows
# ---------------------------------------------------------------------------
class TestDetectHeaderRowEmptyRows:
def test_all_empty_first_row_skipped(self, tmp_path: Path):
# First row is all-empty — the header is on row 1.
p = tmp_path / "blank_first.csv"
p.write_text(",,\nname,email,phone\nalice,a@x.com,555\n")
assert detect_header_row(p) == 1
def test_pure_header_at_row_zero(self, tmp_path: Path):
p = tmp_path / "normal.csv"
p.write_text("name,email,phone\nalice,a@x.com,555\n")
assert detect_header_row(p) == 0
# ---------------------------------------------------------------------------
# BUG-20: config.from_dict must accept unknown fields (forward compat)
# ---------------------------------------------------------------------------
class TestConfigForwardCompat:
def test_extra_field_in_column_config_ignored(self, tmp_path: Path):
# Simulate a config file written by a future version with an
# extra ``priority`` field.
config_dict = {
"strategies": [{
"columns": [{
"column": "email",
"algorithm": "exact",
"threshold": 100.0,
"normalizer": None,
"priority": 5, # future field — must not crash
}],
}],
"survivor_rule": "first",
"merge": False,
}
loaded = DeduplicationConfig.from_dict(config_dict)
assert len(loaded.strategies) == 1
assert loaded.strategies[0].columns[0].column == "email"
def test_roundtrip_then_reload_with_extra(self, tmp_path: Path):
cfg = DeduplicationConfig(
strategies=[StrategyConfig(columns=[
ColumnStrategyConfig(column="email"),
])],
)
path = tmp_path / "cfg.json"
cfg.to_file(path)
# Manually inject an unknown field to simulate forward-compat.
data = json.loads(path.read_text())
data["strategies"][0]["columns"][0]["future_thing"] = "abc"
path.write_text(json.dumps(data))
loaded = DeduplicationConfig.from_file(path)
assert loaded.strategies[0].columns[0].column == "email"
# ---------------------------------------------------------------------------
# BUG-22: mixed-case email detector must not flag all-None columns
# ---------------------------------------------------------------------------
class TestMixedCaseEmailFalsePositive:
def test_all_none_email_column_no_finding(self):
df = pd.DataFrame({
"email": [None, None, None],
})
findings = _detect_mixed_case_email(df)
assert findings == []
def test_real_mixed_case_still_flagged(self):
df = pd.DataFrame({
"email": ["Alice@X.com", "bob@y.com"],
})
findings = _detect_mixed_case_email(df)
assert len(findings) == 1
assert findings[0].column == "email"
# ---------------------------------------------------------------------------
# NIT-24: <NA> recognized as a null-like sentinel
# ---------------------------------------------------------------------------
class TestNullLikeIncludesPandasNA:
def test_pd_na_string_repr_recognized(self):
# str(pd.NA) → "<NA>" — when a DataFrame is loaded with
# keep_default_na=False, pandas NA values appear as the literal
# string "<NA>" and the analyzer should flag them.
assert "<na>" in _NULL_LIKE
# ---------------------------------------------------------------------------
# Production-readiness review (refactor pass)
# ---------------------------------------------------------------------------
class TestProductionReadyValidation:
def test_chunk_size_must_be_positive(self, tmp_path: Path):
from src.core.io import read_file
f = tmp_path / "tiny.csv"
f.write_text("a,b\n1,2\n")
with pytest.raises(ValueError, match="chunk_size must be positive"):
list(read_file(f, chunk_size=0))
with pytest.raises(ValueError, match="chunk_size must be positive"):
list(read_file(f, chunk_size=-5))
def test_write_file_rejects_non_dataframe(self, tmp_path: Path):
from src.core.io import write_file
with pytest.raises(TypeError, match="requires a pandas DataFrame"):
write_file({"a": [1]}, tmp_path / "out.csv") # type: ignore[arg-type]
def test_clean_dataframe_rejects_non_dataframe(self):
from src.core.text_clean import clean_dataframe
with pytest.raises(TypeError, match="requires a pandas DataFrame"):
clean_dataframe([{"a": 1}]) # type: ignore[arg-type]
def test_deduplicate_rejects_non_dataframe(self):
from src.core.dedup import deduplicate
with pytest.raises(TypeError, match="requires a pandas DataFrame"):
deduplicate({"x": [1]}) # type: ignore[arg-type]
def test_keep_most_recent_requires_date_column(self):
from src.core.dedup import deduplicate, SurvivorRule
df = pd.DataFrame({"name": ["a", "b"]})
with pytest.raises(ValueError, match="date_column"):
deduplicate(df, survivor_rule=SurvivorRule.KEEP_MOST_RECENT)
def test_keep_most_recent_with_unknown_date_column(self):
from src.core.dedup import deduplicate, SurvivorRule
df = pd.DataFrame({"name": ["a", "b"]})
with pytest.raises(ValueError, match="not found in input"):
deduplicate(
df,
survivor_rule=SurvivorRule.KEEP_MOST_RECENT,
date_column="created_at",
)
def test_standardize_options_invalid_enum(self):
from src.core.format_standardize import StandardizeOptions
with pytest.raises(ValueError, match="Invalid date_order"):
StandardizeOptions.from_dict({"date_order": "XYZ"})
def test_standardize_options_invalid_field_type(self):
from src.core.format_standardize import StandardizeOptions
with pytest.raises(ValueError, match="Invalid field type"):
StandardizeOptions.from_dict({"column_types": {"x": "made_up"}})
def test_month_locale_validation(self):
from src.core.format_standardize import standardize_date
with pytest.raises(ValueError, match="Unknown month locale"):
standardize_date("15 januar 2024", month_locales=["DE"]) # uppercase typo
def test_config_from_file_missing(self, tmp_path: Path):
from src.core.config import DeduplicationConfig
with pytest.raises(OSError, match="Could not read"):
DeduplicationConfig.from_file(tmp_path / "missing.json")
def test_config_from_file_bad_json(self, tmp_path: Path):
from src.core.config import DeduplicationConfig
path = tmp_path / "bad.json"
path.write_text("{not: valid json")
with pytest.raises(ValueError, match="Invalid JSON"):
DeduplicationConfig.from_file(path)