💻 源码
"""Tests for the visualizer module.

Computation helpers are tested with a small in-memory DataFrame (no dependency
on the real data files). Figure builders are smoke-tested to confirm they
return Plotly figures without raising.
"""

import pandas as pd
import plotly.graph_objects as go
import pytest

from app.models import visualizer


@pytest.fixture
def sample_df():
    """Small, hand-built dataset with a known subscribe distribution."""
    return pd.DataFrame(
        {
            "age": [23, 30, 41, 52, 67, 38, 29, 45],
            "job": [
                "admin.",
                "admin.",
                "services",
                "services",
                "retired",
                "admin.",
                "blue-collar",
                "services",
            ],
            "duration": [100, 200, 300, 400, 500, 150, 250, 350],
            "subscribe": ["no", "yes", "no", "yes", "yes", "no", "no", "no"],
        }
    )


class TestComputeOverview:
    def test_overview_counts_and_rate(self, sample_df):
        result = visualizer.compute_overview(sample_df)
        assert result["total_records"] == 8
        assert result["subscribed"] == 3
        assert result["not_subscribed"] == 5
        assert result["subscribe_rate"] == pytest.approx(3 / 8)

    def test_overview_feature_count(self, sample_df):
        result = visualizer.compute_overview(sample_df)
        assert result["feature_count"] == 4

    def test_overview_missing_target_raises(self):
        df = pd.DataFrame({"age": [1, 2]})
        with pytest.raises(ValueError, match="Target column"):
            visualizer.compute_overview(df)


class TestComputeValueCounts:
    def test_value_counts_sums_to_total(self, sample_df):
        result = visualizer.compute_value_counts(sample_df, "job")
        assert result["count"].sum() == len(sample_df)

    def test_value_counts_columns(self, sample_df):
        result = visualizer.compute_value_counts(sample_df, "job")
        assert list(result.columns) == ["job", "count"]

    def test_value_counts_admin_count(self, sample_df):
        result = visualizer.compute_value_counts(sample_df, "job")
        admin_row = result[result["job"] == "admin."]
        assert int(admin_row["count"].iloc[0]) == 3

    def test_value_counts_missing_column_raises(self, sample_df):
        with pytest.raises(ValueError, match="Column not found"):
            visualizer.compute_value_counts(sample_df, "nonexistent")


class TestSubscribeRateByCategory:
    def test_rate_values(self, sample_df):
        result = visualizer.compute_subscribe_rate_by_category(sample_df, "job")
        retired = result[result["job"] == "retired"]
        assert retired["rate"].iloc[0] == pytest.approx(1.0)
        admin = result[result["job"] == "admin."]
        assert admin["rate"].iloc[0] == pytest.approx(1 / 3)

    def test_rate_sorted_descending(self, sample_df):
        result = visualizer.compute_subscribe_rate_by_category(sample_df, "job")
        rates = result["rate"].tolist()
        assert rates == sorted(rates, reverse=True)

    def test_totals_match(self, sample_df):
        result = visualizer.compute_subscribe_rate_by_category(sample_df, "job")
        assert result["total"].sum() == len(sample_df)

    def test_missing_target_raises(self):
        df = pd.DataFrame({"job": ["a", "b"]})
        with pytest.raises(ValueError, match="Target column"):
            visualizer.compute_subscribe_rate_by_category(df, "job")


class TestComputeAgeGroups:
    def test_age_group_counts_sum(self, sample_df):
        result = visualizer.compute_age_groups(sample_df)
        assert result["count"].sum() == len(sample_df)

    def test_age_group_has_rate_column(self, sample_df):
        result = visualizer.compute_age_groups(sample_df)
        assert "rate" in result.columns
        assert (result["rate"] >= 0).all() and (result["rate"] <= 1).all()

    def test_age_group_custom_bins(self, sample_df):
        result = visualizer.compute_age_groups(
            sample_df, bins=[0, 40, 120], labels=["young", "old"]
        )
        assert set(result["age_group"]) == {"young", "old"}

    def test_age_group_invalid_labels_raises(self, sample_df):
        with pytest.raises(ValueError, match="labels length"):
            visualizer.compute_age_groups(sample_df, bins=[0, 50, 120], labels=["only_one"])


class TestNumericSummary:
    def test_summary_keys(self, sample_df):
        result = visualizer.compute_numeric_summary(sample_df, "age")
        for key in ["count", "mean", "std", "min", "q25", "median", "q75", "max"]:
            assert key in result

    def test_summary_values(self, sample_df):
        result = visualizer.compute_numeric_summary(sample_df, "duration")
        assert result["min"] == 100.0
        assert result["max"] == 500.0
        assert result["count"] == 8

    def test_summary_non_numeric_raises(self, sample_df):
        with pytest.raises(ValueError, match="not numeric"):
            visualizer.compute_numeric_summary(sample_df, "job")

    def test_summary_missing_column_raises(self, sample_df):
        with pytest.raises(ValueError, match="Column not found"):
            visualizer.compute_numeric_summary(sample_df, "nope")


class TestFigureBuilders:
    def test_build_subscribe_pie(self, sample_df):
        assert isinstance(visualizer.build_subscribe_pie(sample_df), go.Figure)

    def test_build_category_bar(self, sample_df):
        assert isinstance(visualizer.build_category_bar(sample_df, "job"), go.Figure)

    def test_build_subscribe_rate_bar(self, sample_df):
        assert isinstance(visualizer.build_subscribe_rate_bar(sample_df, "job"), go.Figure)

    def test_build_age_histogram(self, sample_df):
        assert isinstance(visualizer.build_age_histogram(sample_df), go.Figure)

    def test_build_numeric_histogram(self, sample_df):
        assert isinstance(visualizer.build_numeric_histogram(sample_df, "duration"), go.Figure)