123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307 |
- import numpy as np
- import pytest
- from pandas import DataFrame
- pytest.importorskip("jinja2")
- def bar_grad(a=None, b=None, c=None, d=None):
- """Used in multiple tests to simplify formatting of expected result"""
- ret = [("width", "10em")]
- if all(x is None for x in [a, b, c, d]):
- return ret
- return ret + [
- (
- "background",
- f"linear-gradient(90deg,{','.join([x for x in [a, b, c, d] if x])})",
- )
- ]
- def no_bar():
- return bar_grad()
- def bar_to(x, color="#d65f5f"):
- return bar_grad(f" {color} {x:.1f}%", f" transparent {x:.1f}%")
- def bar_from_to(x, y, color="#d65f5f"):
- return bar_grad(
- f" transparent {x:.1f}%",
- f" {color} {x:.1f}%",
- f" {color} {y:.1f}%",
- f" transparent {y:.1f}%",
- )
- @pytest.fixture
- def df_pos():
- return DataFrame([[1], [2], [3]])
- @pytest.fixture
- def df_neg():
- return DataFrame([[-1], [-2], [-3]])
- @pytest.fixture
- def df_mix():
- return DataFrame([[-3], [1], [2]])
- @pytest.mark.parametrize(
- "align, exp",
- [
- ("left", [no_bar(), bar_to(50), bar_to(100)]),
- ("right", [bar_to(100), bar_from_to(50, 100), no_bar()]),
- ("mid", [bar_to(33.33), bar_to(66.66), bar_to(100)]),
- ("zero", [bar_from_to(50, 66.7), bar_from_to(50, 83.3), bar_from_to(50, 100)]),
- ("mean", [bar_to(50), no_bar(), bar_from_to(50, 100)]),
- (2.0, [bar_to(50), no_bar(), bar_from_to(50, 100)]),
- (np.median, [bar_to(50), no_bar(), bar_from_to(50, 100)]),
- ],
- )
- def test_align_positive_cases(df_pos, align, exp):
- # test different align cases for all positive values
- result = df_pos.style.bar(align=align)._compute().ctx
- expected = {(0, 0): exp[0], (1, 0): exp[1], (2, 0): exp[2]}
- assert result == expected
- @pytest.mark.parametrize(
- "align, exp",
- [
- ("left", [bar_to(100), bar_to(50), no_bar()]),
- ("right", [no_bar(), bar_from_to(50, 100), bar_to(100)]),
- ("mid", [bar_from_to(66.66, 100), bar_from_to(33.33, 100), bar_to(100)]),
- ("zero", [bar_from_to(33.33, 50), bar_from_to(16.66, 50), bar_to(50)]),
- ("mean", [bar_from_to(50, 100), no_bar(), bar_to(50)]),
- (-2.0, [bar_from_to(50, 100), no_bar(), bar_to(50)]),
- (np.median, [bar_from_to(50, 100), no_bar(), bar_to(50)]),
- ],
- )
- def test_align_negative_cases(df_neg, align, exp):
- # test different align cases for all negative values
- result = df_neg.style.bar(align=align)._compute().ctx
- expected = {(0, 0): exp[0], (1, 0): exp[1], (2, 0): exp[2]}
- assert result == expected
- @pytest.mark.parametrize(
- "align, exp",
- [
- ("left", [no_bar(), bar_to(80), bar_to(100)]),
- ("right", [bar_to(100), bar_from_to(80, 100), no_bar()]),
- ("mid", [bar_to(60), bar_from_to(60, 80), bar_from_to(60, 100)]),
- ("zero", [bar_to(50), bar_from_to(50, 66.66), bar_from_to(50, 83.33)]),
- ("mean", [bar_to(50), bar_from_to(50, 66.66), bar_from_to(50, 83.33)]),
- (-0.0, [bar_to(50), bar_from_to(50, 66.66), bar_from_to(50, 83.33)]),
- (np.nanmedian, [bar_to(50), no_bar(), bar_from_to(50, 62.5)]),
- ],
- )
- @pytest.mark.parametrize("nans", [True, False])
- def test_align_mixed_cases(df_mix, align, exp, nans):
- # test different align cases for mixed positive and negative values
- # also test no impact of NaNs and no_bar
- expected = {(0, 0): exp[0], (1, 0): exp[1], (2, 0): exp[2]}
- if nans:
- df_mix.loc[3, :] = np.nan
- expected.update({(3, 0): no_bar()})
- result = df_mix.style.bar(align=align)._compute().ctx
- assert result == expected
- @pytest.mark.parametrize(
- "align, exp",
- [
- (
- "left",
- {
- "index": [[no_bar(), no_bar()], [bar_to(100), bar_to(100)]],
- "columns": [[no_bar(), bar_to(100)], [no_bar(), bar_to(100)]],
- "none": [[no_bar(), bar_to(33.33)], [bar_to(66.66), bar_to(100)]],
- },
- ),
- (
- "mid",
- {
- "index": [[bar_to(33.33), bar_to(50)], [bar_to(100), bar_to(100)]],
- "columns": [[bar_to(50), bar_to(100)], [bar_to(75), bar_to(100)]],
- "none": [[bar_to(25), bar_to(50)], [bar_to(75), bar_to(100)]],
- },
- ),
- (
- "zero",
- {
- "index": [
- [bar_from_to(50, 66.66), bar_from_to(50, 75)],
- [bar_from_to(50, 100), bar_from_to(50, 100)],
- ],
- "columns": [
- [bar_from_to(50, 75), bar_from_to(50, 100)],
- [bar_from_to(50, 87.5), bar_from_to(50, 100)],
- ],
- "none": [
- [bar_from_to(50, 62.5), bar_from_to(50, 75)],
- [bar_from_to(50, 87.5), bar_from_to(50, 100)],
- ],
- },
- ),
- (
- 2,
- {
- "index": [
- [bar_to(50), no_bar()],
- [bar_from_to(50, 100), bar_from_to(50, 100)],
- ],
- "columns": [
- [bar_to(50), no_bar()],
- [bar_from_to(50, 75), bar_from_to(50, 100)],
- ],
- "none": [
- [bar_from_to(25, 50), no_bar()],
- [bar_from_to(50, 75), bar_from_to(50, 100)],
- ],
- },
- ),
- ],
- )
- @pytest.mark.parametrize("axis", ["index", "columns", "none"])
- def test_align_axis(align, exp, axis):
- # test all axis combinations with positive values and different aligns
- data = DataFrame([[1, 2], [3, 4]])
- result = (
- data.style.bar(align=align, axis=None if axis == "none" else axis)
- ._compute()
- .ctx
- )
- expected = {
- (0, 0): exp[axis][0][0],
- (0, 1): exp[axis][0][1],
- (1, 0): exp[axis][1][0],
- (1, 1): exp[axis][1][1],
- }
- assert result == expected
- @pytest.mark.parametrize(
- "values, vmin, vmax",
- [
- ("positive", 1.5, 2.5),
- ("negative", -2.5, -1.5),
- ("mixed", -2.5, 1.5),
- ],
- )
- @pytest.mark.parametrize("nullify", [None, "vmin", "vmax"]) # test min/max separately
- @pytest.mark.parametrize("align", ["left", "right", "zero", "mid"])
- def test_vmin_vmax_clipping(df_pos, df_neg, df_mix, values, vmin, vmax, nullify, align):
- # test that clipping occurs if any vmin > data_values or vmax < data_values
- if align == "mid": # mid acts as left or right in each case
- if values == "positive":
- align = "left"
- elif values == "negative":
- align = "right"
- df = {"positive": df_pos, "negative": df_neg, "mixed": df_mix}[values]
- vmin = None if nullify == "vmin" else vmin
- vmax = None if nullify == "vmax" else vmax
- clip_df = df.where(df <= (vmax if vmax else 999), other=vmax)
- clip_df = clip_df.where(clip_df >= (vmin if vmin else -999), other=vmin)
- result = (
- df.style.bar(align=align, vmin=vmin, vmax=vmax, color=["red", "green"])
- ._compute()
- .ctx
- )
- expected = clip_df.style.bar(align=align, color=["red", "green"])._compute().ctx
- assert result == expected
- @pytest.mark.parametrize(
- "values, vmin, vmax",
- [
- ("positive", 0.5, 4.5),
- ("negative", -4.5, -0.5),
- ("mixed", -4.5, 4.5),
- ],
- )
- @pytest.mark.parametrize("nullify", [None, "vmin", "vmax"]) # test min/max separately
- @pytest.mark.parametrize("align", ["left", "right", "zero", "mid"])
- def test_vmin_vmax_widening(df_pos, df_neg, df_mix, values, vmin, vmax, nullify, align):
- # test that widening occurs if any vmax > data_values or vmin < data_values
- if align == "mid": # mid acts as left or right in each case
- if values == "positive":
- align = "left"
- elif values == "negative":
- align = "right"
- df = {"positive": df_pos, "negative": df_neg, "mixed": df_mix}[values]
- vmin = None if nullify == "vmin" else vmin
- vmax = None if nullify == "vmax" else vmax
- expand_df = df.copy()
- expand_df.loc[3, :], expand_df.loc[4, :] = vmin, vmax
- result = (
- df.style.bar(align=align, vmin=vmin, vmax=vmax, color=["red", "green"])
- ._compute()
- .ctx
- )
- expected = expand_df.style.bar(align=align, color=["red", "green"])._compute().ctx
- assert result.items() <= expected.items()
- def test_numerics():
- # test data is pre-selected for numeric values
- data = DataFrame([[1, "a"], [2, "b"]])
- result = data.style.bar()._compute().ctx
- assert (0, 1) not in result
- assert (1, 1) not in result
- @pytest.mark.parametrize(
- "align, exp",
- [
- ("left", [no_bar(), bar_to(100, "green")]),
- ("right", [bar_to(100, "red"), no_bar()]),
- ("mid", [bar_to(25, "red"), bar_from_to(25, 100, "green")]),
- ("zero", [bar_from_to(33.33, 50, "red"), bar_from_to(50, 100, "green")]),
- ],
- )
- def test_colors_mixed(align, exp):
- data = DataFrame([[-1], [3]])
- result = data.style.bar(align=align, color=["red", "green"])._compute().ctx
- assert result == {(0, 0): exp[0], (1, 0): exp[1]}
- def test_bar_align_height():
- # test when keyword height is used 'no-repeat center' and 'background-size' present
- data = DataFrame([[1], [2]])
- result = data.style.bar(align="left", height=50)._compute().ctx
- bg_s = "linear-gradient(90deg, #d65f5f 100.0%, transparent 100.0%) no-repeat center"
- expected = {
- (0, 0): [("width", "10em")],
- (1, 0): [
- ("width", "10em"),
- ("background", bg_s),
- ("background-size", "100% 50.0%"),
- ],
- }
- assert result == expected
- def test_bar_value_error_raises():
- df = DataFrame({"A": [-100, -60, -30, -20]})
- msg = "`align` should be in {'left', 'right', 'mid', 'mean', 'zero'} or"
- with pytest.raises(ValueError, match=msg):
- df.style.bar(align="poorly", color=["#d65f5f", "#5fba7d"]).to_html()
- msg = r"`width` must be a value in \[0, 100\]"
- with pytest.raises(ValueError, match=msg):
- df.style.bar(width=200).to_html()
- msg = r"`height` must be a value in \[0, 100\]"
- with pytest.raises(ValueError, match=msg):
- df.style.bar(height=200).to_html()
|