import os import sys import numpy as np import pandas as pd import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import analysis as A # noqa: E402 @pytest.fixture(scope="module") def df(): return A.load_players() def test_dataset_shape_and_columns(df): assert len(df) == 148 for col in A.REQUIRED_COLUMNS: assert col in df.columns assert df[A.STAT_COLUMNS + [A.SALARY_COLUMN]].isna().sum().sum() == 0 assert df[A.NAME_COLUMN].is_unique def test_first_player_is_pete_crow_armstrong(df): assert df.at[0, A.NAME_COLUMN] == "Pete Crow-Armstrong" assert df.at[0, A.SALARY_COLUMN] == 19166667 def test_percentile_rank_definition(): others = np.array([1, 2, 3, 4]) assert A.percentile_rank(others, 5) == 100.0 assert A.percentile_rank(others, 0) == 0.0 assert A.percentile_rank(others, 2.5) == 50.0 assert A.percentile_rank(others, 2) == 37.5 # one below, one tie counts half assert A.percentile_rank(others, 0, higher_is_better=False) == 100.0 def test_performance_table_directions(df): idx = A.player_index(df, "Pete Crow-Armstrong") table = A.performance_table(df, idx) assert list(table["Statistic"]) == A.STAT_COLUMNS war = table.set_index("Statistic").loc["WAR"] assert war["Rank"] == "1 of 148" assert war["Percentile"] == 100.0 k = table.set_index("Statistic").loc["K%"] # Higher strikeout rate than most players, so a low percentile and a negative z-score. assert k["Percentile"] < 50 assert k["z-score"] < 0 assert 0 <= table["Percentile"].min() and table["Percentile"].max() <= 100 def test_format_helpers(): assert A.format_stat("PA", 436.0) == "436" assert A.format_stat("K%", 0.261467) == "26.1%" assert A.format_stat("AVG", 0.28074) == "0.281" assert A.format_stat("WAR", 5.8616) == "5.9" assert A.format_stat("wRC+", 144.98) == "145" assert A.format_money(19166667) == "$19,166,667" assert A.format_money_short(19166667) == "$19.17M" assert A.format_money_short(780000) == "$780K" def test_salary_summary(df): idx = A.player_index(df, "Pete Crow-Armstrong") s = A.salary_summary(df, idx) assert s["n_players"] == 148 assert s["n_market_contracts"] + s["n_minimum_contracts"] == 148 assert s["is_market_contract"] is True assert 0 < s["salary_percentile"] < 100 assert s["salary_rank"] == int((df[A.SALARY_COLUMN] > 19166667).sum()) + 1 def test_comparables_exclude_self_and_have_k_rows(df): idx = A.player_index(df, "Pete Crow-Armstrong") comps, summary = A.comparable_players(df, idx) assert len(comps) == A.N_COMPARABLES assert "Pete Crow-Armstrong" not in comps["Player"].tolist() assert comps["Distance"].astype(float).is_monotonic_increasing salaries = df.set_index(A.NAME_COLUMN).loc[comps["Player"], A.SALARY_COLUMN] assert (salaries > A.MARKET_CONTRACT_THRESHOLD).all() assert summary["comparable_names"] == comps["Player"].tolist() assert summary["comparable_median_salary"] == float(salaries.median()) def test_cost_per_war_handles_non_positive_war(df): idx = int(df.index[df["WAR"] <= 0][0]) out = A.cost_per_war(df, idx) assert out["cost_per_war"] is None idx = A.player_index(df, "Pete Crow-Armstrong") out = A.cost_per_war(df, idx) assert out["cost_per_war"] == pytest.approx(19166667 / df.at[idx, "WAR"]) def test_percentile_within_distribution_interpolates(): q = np.linspace(0, 1, len(A.QUANTILE_GRID)) assert A.percentile_within_distribution(-1, q) == 1.0 assert A.percentile_within_distribution(2, q) == 99.0 assert A.percentile_within_distribution(0.5, q) == pytest.approx(50.0, abs=0.6) def test_load_players_rejects_bad_data(tmp_path, df): bad = df.copy() bad.loc[0, "WAR"] = np.nan path = tmp_path / "bad.csv" bad.to_csv(path, index=False) with pytest.raises(ValueError): A.load_players(str(path)) dup = df.copy() dup.loc[1, A.NAME_COLUMN] = dup.loc[0, A.NAME_COLUMN] dup.to_csv(path, index=False) with pytest.raises(ValueError): A.load_players(str(path)) def test_cached_validation_requires_matching_fingerprint(tmp_path, df): path = tmp_path / "loo.json" A.save_validation({"fingerprint": "wrong", "n_estimators": A._model_n_estimators()}, str(path)) assert A.load_cached_validation(df, str(path)) is None A.save_validation({"fingerprint": A.dataset_fingerprint(df), "n_estimators": A._model_n_estimators(), "r2_log_salary": 0.1}, str(path)) assert A.load_cached_validation(df, str(path))["r2_log_salary"] == 0.1 def test_assessment_rules(): def perf_with(pct): return pd.DataFrame({"Statistic": A.STAT_COLUMNS, "Percentile": [pct] * len(A.STAT_COLUMNS)}) comps = {"comparable_median_salary": 2_000_000} # Underpaid relative to the model and to rank. out = A.assessment(perf_with(90.0), {"salary": 1_000_000, "salary_percentile": 30.0}, {"actual_percentile_in_predicted": 10.0}, comps) assert out["model_band"] == "below" assert out["headline"].startswith("Performance supports the salary") assert out["comparables_reading"].startswith("paid less") # Inside the model range, ranks close. out = A.assessment(perf_with(60.0), {"salary": 2_100_000, "salary_percentile": 55.0}, {"actual_percentile_in_predicted": 50.0}, comps) assert out["model_band"] == "inside" assert out["headline"].startswith("Performance supports the salary") assert out["comparables_reading"].startswith("paid about the same") # Overpaid relative to the model and to rank. out = A.assessment(perf_with(20.0), {"salary": 30_000_000, "salary_percentile": 95.0}, {"actual_percentile_in_predicted": 90.0}, comps) assert out["model_band"] == "above" assert out["headline"].startswith("Performance does not support") assert out["comparables_reading"].startswith("paid more") # Conflicts produce a mixed reading. out = A.assessment(perf_with(95.0), {"salary": 30_000_000, "salary_percentile": 60.0}, {"actual_percentile_in_predicted": 90.0}, comps) assert out["headline"].startswith("The evidence is mixed") out = A.assessment(perf_with(10.0), {"salary": 5_000_000, "salary_percentile": 60.0}, {"actual_percentile_in_predicted": 40.0}, comps) assert out["headline"].startswith("The evidence is mixed") @pytest.mark.slow def test_model_prediction_for_pete_crow_armstrong(df): idx = A.player_index(df, "Pete Crow-Armstrong") out = A.model_salary_for_player(df, idx, n_estimators=4) assert out["n_train"] == int((df[A.SALARY_COLUMN] > A.MARKET_CONTRACT_THRESHOLD).sum()) - 1 assert out["selected_player_is_market_contract"] is True q = out["predicted_quantiles"] assert q["q10"] <= q["q25"] <= q["q50"] <= q["q75"] <= q["q90"] assert 1.0 <= out["actual_percentile_in_predicted"] <= 99.0 assert out["predicted_median_salary"] > 0 @pytest.mark.slow def test_analyze_player_bundle_is_json_serializable(df): result = A.analyze_player(df, "Pete Crow-Armstrong", n_estimators=4) payload = A.analysis_to_json(result) assert payload["player"]["name"] == "Pete Crow-Armstrong" assert len(payload["performance_table"]) == len(A.STAT_COLUMNS) assert isinstance(payload["assessment"]["headline"], str) def test_training_mask_excludes_minimum_contracts_and_selected_player(df): idx = A.player_index(df, "Pete Crow-Armstrong") mask = A.model_training_mask(df, idx) assert not mask[idx] assert (df.loc[mask, A.SALARY_COLUMN] > A.MARKET_CONTRACT_THRESHOLD).all() minimum_idx = int(df.index[df[A.SALARY_COLUMN] <= A.MARKET_CONTRACT_THRESHOLD][0]) assert not A.model_training_mask(df, minimum_idx)[minimum_idx] assert A.model_training_mask(df, minimum_idx).sum() == A.model_training_mask(df).sum() def test_salary_percentile_among_market_is_none_for_minimum_players(df): minimum_idx = int(df.index[df[A.SALARY_COLUMN] <= A.MARKET_CONTRACT_THRESHOLD][0]) assert A.salary_summary(df, minimum_idx)["salary_percentile_among_market"] is None idx = A.player_index(df, "Pete Crow-Armstrong") assert 0 < A.salary_summary(df, idx)["salary_percentile_among_market"] < 100 def test_analysis_to_json_replaces_non_finite_values(): payload = A.analysis_to_json({"a": float("nan"), "b": np.float64("inf"), "c": np.int64(3), "d": [np.float32(1.5)], "e": pd.DataFrame({"x": [np.nan, 2.0]})}) assert payload == {"a": None, "b": None, "c": 3, "d": [1.5], "e": [{"x": None}, {"x": 2.0}]}