diff --git a/tests/test_contextual.py b/tests/test_contextual.py index bf509b7..06ef50f 100644 --- a/tests/test_contextual.py +++ b/tests/test_contextual.py @@ -1,5 +1,6 @@ import pytest import pandas as pd +import numpy as np from tsauditor.anomaly.contextual import audit_contextual_anomalies from tsauditor.report.summary import WARNING @@ -92,3 +93,24 @@ def test_local_spike_fails_global_zscore(clean_financial_df): spike_issues = [i for i in issues if i.code == "ANO003" and i.column == "Price"] assert len(spike_issues) >= 1 + + +def test_all_nan_column_skipped(): + """Column that is entirely NaN is skipped gracefully.""" + dates = pd.date_range("2026-01-01", periods=20, freq="D") + df = pd.DataFrame( + {"all_nan": [np.nan] * 20, "valid": range(20)}, + index=dates, + ) + issues = audit_contextual_anomalies(df, domain="finance") + nan_issues = [i for i in issues if i.column == "all_nan"] + assert len(nan_issues) == 0 + + +def test_single_row_df(): + """Single row DataFrame: returns empty list, no crash.""" + dates = pd.date_range("2026-01-01", periods=1, freq="D") + df = pd.DataFrame({"val": [1.0]}, index=dates) + issues = audit_contextual_anomalies(df, domain="finance") + assert isinstance(issues, list) + assert len(issues) == 0 diff --git a/tests/test_correlation.py b/tests/test_correlation.py index 68462cb..b9278b2 100644 --- a/tests/test_correlation.py +++ b/tests/test_correlation.py @@ -133,3 +133,12 @@ def test_few_observations_skipped(): t = _iid_target(n, 8) df = pd.DataFrame({"target": t, "leak": t.shift(-1)}, index=_idx(n)) assert audit_correlation_leakage(df, target="target", min_obs=30) == [] + + +def test_single_row_df(): + """Single row DataFrame with target: returns empty list, no crash.""" + dates = pd.date_range("2026-01-01", periods=1, freq="D") + df = pd.DataFrame({"target": [1.0], "feat": [2.0]}, index=dates) + issues = audit_correlation_leakage(df, target="target", min_obs=1) + assert isinstance(issues, list) + assert len(issues) == 0 diff --git a/tests/test_equivalence.py b/tests/test_equivalence.py index 68fe2af..d4da05a 100644 --- a/tests/test_equivalence.py +++ b/tests/test_equivalence.py @@ -153,3 +153,12 @@ def test_scattered_nans_use_pairwise_overlap(): issues = audit_equivalence(df, target="target") assert "leak" in {i.column for i in issues} assert next(i for i in issues if i.column == "leak").evidence["n_obs"] < n + + +def test_single_row_df(): + """Single row DataFrame with target: returns empty list, no crash.""" + dates = pd.date_range("2026-01-01", periods=1, freq="D") + df = pd.DataFrame({"target": [1.0], "feat": [2.0]}, index=dates) + issues = audit_equivalence(df, target="target", min_obs=1) + assert isinstance(issues, list) + assert len(issues) == 0 diff --git a/tests/test_missing.py b/tests/test_missing.py index 94a7e8b..f03a9fc 100644 --- a/tests/test_missing.py +++ b/tests/test_missing.py @@ -86,6 +86,25 @@ def test_non_numeric_columns_ignored(): assert len(issues) == 0 +def test_all_nan_column_reported_as_missing(): + """All-NaN column is correctly reported (100% missing rate, clustering). + + Unlike anomaly detectors which skip all-NaN columns, the missing profiler + rightfully flags them — 100% missing is a genuine data quality issue. + """ + dates = pd.date_range("2026-01-01", periods=10, freq="D") + df = pd.DataFrame( + {"all_nan": [np.nan] * 10, "valid": range(10)}, + index=dates, + ) + issues = audit_missing(df, domain="finance") + nan_issues = [i for i in issues if i.column == "all_nan"] + # PRF006: high missing rate (100%), PRF002: clustered missing + codes = {i.code for i in nan_issues} + assert "PRF006" in codes + assert nan_issues[0].evidence["missing_percentage"] == 100.0 + + def test_sensor_domain_lower_threshold(sensor_df): # Case 7 — Sensor domain with 3 consecutive NaNs -> PRF002 (lower threshold). df = sensor_df.copy() @@ -100,3 +119,12 @@ def test_sensor_domain_lower_threshold(sensor_df): assert len(cluster_issues) == 1 assert cluster_issues[0].evidence["cluster_threshold"] == 3 assert cluster_issues[0].evidence["longest_consecutive_run"] == 3 + + +def test_single_row_df(): + """Single row DataFrame: returns empty list, no crash.""" + dates = pd.date_range("2026-01-01", periods=1, freq="D") + df = pd.DataFrame({"val": [1.0]}, index=dates) + issues = audit_missing(df, domain="finance") + assert isinstance(issues, list) + assert len(issues) == 0 diff --git a/tests/test_point.py b/tests/test_point.py index 26e2d27..40901d9 100644 --- a/tests/test_point.py +++ b/tests/test_point.py @@ -53,3 +53,23 @@ def test_audit_point_anomalies_finance_threshold(base_date_index): # In finance (5.0), 4.5 is not an outlier issues = audit_point_anomalies(df, domain="finance") assert len(issues) == 0 + + +def test_audit_point_anomalies_all_nan_column_skipped(base_date_index): + """Column that is entirely NaN is skipped gracefully.""" + df = pd.DataFrame( + {"all_nan": [np.nan] * 100, "valid": np.random.default_rng(42).normal(0, 1, 100)}, + index=base_date_index, + ) + issues = audit_point_anomalies(df) + nan_issues = [i for i in issues if i.column == "all_nan"] + assert len(nan_issues) == 0 + + +def test_single_row_df(): + """Single row DataFrame: returns empty list, no crash.""" + dates = pd.date_range("2026-01-01", periods=1, freq="D") + df = pd.DataFrame({"val": [1.0]}, index=dates) + issues = audit_point_anomalies(df) + assert isinstance(issues, list) + assert len(issues) == 0 diff --git a/tests/test_profiler.py b/tests/test_profiler.py index 8c6347c..407a89b 100644 --- a/tests/test_profiler.py +++ b/tests/test_profiler.py @@ -130,3 +130,12 @@ def test_sensor_domain_median_threshold(sensor_df): issue = gap_issues[0] assert issue.severity == WARNING assert issue.evidence["maximum_gap_days"] >= 1.0 + + +def test_single_row_df_missing(): + """Single row DataFrame for frequency: returns empty list, no crash.""" + dates = pd.date_range("2026-01-01", periods=1, freq="D") + df = pd.DataFrame({"val": [1.0]}, index=dates) + issues = audit_frequency(df, domain="finance") + assert isinstance(issues, list) + assert len(issues) == 0 diff --git a/tests/test_stationarity.py b/tests/test_stationarity.py index b3e214d..02eefdd 100644 --- a/tests/test_stationarity.py +++ b/tests/test_stationarity.py @@ -76,3 +76,24 @@ def test_audit_stationarity_with_nan_and_inf(base_date_index): issues = audit_stationarity(df, min_obs=25) assert isinstance(issues, list) + + +def test_all_nan_column_skipped(base_date_index): + """Column that is entirely NaN is skipped gracefully.""" + df = pd.DataFrame( + {"all_nan": [np.nan] * 100, "valid": np.random.randn(100)}, + index=base_date_index, + ) + issues = audit_stationarity(df, min_obs=25) + nan_issues = [i for i in issues if i.column == "all_nan"] + assert len(nan_issues) == 0 + + +def test_single_row_df_raises_without_guard(): + """Single row DataFrame passes min_obs=0 but statsmodels adfuller + rejects constant input — guard is missing (known issue).""" + dates = pd.date_range("2026-01-01", periods=1, freq="D") + df = pd.DataFrame({"val": [1.0]}, index=dates) + # TODO: remove pytest.raises once a min_obs guard is added upstream + with pytest.raises(ValueError, match="constant"): + audit_stationarity(df, min_obs=0) diff --git a/tests/test_temporal.py b/tests/test_temporal.py index 341e744..4338559 100644 --- a/tests/test_temporal.py +++ b/tests/test_temporal.py @@ -112,3 +112,12 @@ def test_few_observations_skipped(): t = _ar1(n, seed=8) df = pd.DataFrame({"target": t, "leak": t.shift(-1)}, index=_idx(n)) assert audit_temporal_leakage(df, target="target", min_obs=30) == [] + + +def test_single_row_df(): + """Single row DataFrame with target: returns empty list, no crash.""" + dates = pd.date_range("2026-01-01", periods=1, freq="D") + df = pd.DataFrame({"target": [1.0], "feat": [2.0]}, index=dates) + issues = audit_temporal_leakage(df, target="target", min_obs=1) + assert isinstance(issues, list) + assert len(issues) == 0