test_bar.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import numpy as np
  2. import pytest
  3. from pandas import DataFrame
  4. pytest.importorskip("jinja2")
  5. def bar_grad(a=None, b=None, c=None, d=None):
  6. """Used in multiple tests to simplify formatting of expected result"""
  7. ret = [("width", "10em")]
  8. if all(x is None for x in [a, b, c, d]):
  9. return ret
  10. return ret + [
  11. (
  12. "background",
  13. f"linear-gradient(90deg,{','.join([x for x in [a, b, c, d] if x])})",
  14. )
  15. ]
  16. def no_bar():
  17. return bar_grad()
  18. def bar_to(x, color="#d65f5f"):
  19. return bar_grad(f" {color} {x:.1f}%", f" transparent {x:.1f}%")
  20. def bar_from_to(x, y, color="#d65f5f"):
  21. return bar_grad(
  22. f" transparent {x:.1f}%",
  23. f" {color} {x:.1f}%",
  24. f" {color} {y:.1f}%",
  25. f" transparent {y:.1f}%",
  26. )
  27. @pytest.fixture
  28. def df_pos():
  29. return DataFrame([[1], [2], [3]])
  30. @pytest.fixture
  31. def df_neg():
  32. return DataFrame([[-1], [-2], [-3]])
  33. @pytest.fixture
  34. def df_mix():
  35. return DataFrame([[-3], [1], [2]])
  36. @pytest.mark.parametrize(
  37. "align, exp",
  38. [
  39. ("left", [no_bar(), bar_to(50), bar_to(100)]),
  40. ("right", [bar_to(100), bar_from_to(50, 100), no_bar()]),
  41. ("mid", [bar_to(33.33), bar_to(66.66), bar_to(100)]),
  42. ("zero", [bar_from_to(50, 66.7), bar_from_to(50, 83.3), bar_from_to(50, 100)]),
  43. ("mean", [bar_to(50), no_bar(), bar_from_to(50, 100)]),
  44. (2.0, [bar_to(50), no_bar(), bar_from_to(50, 100)]),
  45. (np.median, [bar_to(50), no_bar(), bar_from_to(50, 100)]),
  46. ],
  47. )
  48. def test_align_positive_cases(df_pos, align, exp):
  49. # test different align cases for all positive values
  50. result = df_pos.style.bar(align=align)._compute().ctx
  51. expected = {(0, 0): exp[0], (1, 0): exp[1], (2, 0): exp[2]}
  52. assert result == expected
  53. @pytest.mark.parametrize(
  54. "align, exp",
  55. [
  56. ("left", [bar_to(100), bar_to(50), no_bar()]),
  57. ("right", [no_bar(), bar_from_to(50, 100), bar_to(100)]),
  58. ("mid", [bar_from_to(66.66, 100), bar_from_to(33.33, 100), bar_to(100)]),
  59. ("zero", [bar_from_to(33.33, 50), bar_from_to(16.66, 50), bar_to(50)]),
  60. ("mean", [bar_from_to(50, 100), no_bar(), bar_to(50)]),
  61. (-2.0, [bar_from_to(50, 100), no_bar(), bar_to(50)]),
  62. (np.median, [bar_from_to(50, 100), no_bar(), bar_to(50)]),
  63. ],
  64. )
  65. def test_align_negative_cases(df_neg, align, exp):
  66. # test different align cases for all negative values
  67. result = df_neg.style.bar(align=align)._compute().ctx
  68. expected = {(0, 0): exp[0], (1, 0): exp[1], (2, 0): exp[2]}
  69. assert result == expected
  70. @pytest.mark.parametrize(
  71. "align, exp",
  72. [
  73. ("left", [no_bar(), bar_to(80), bar_to(100)]),
  74. ("right", [bar_to(100), bar_from_to(80, 100), no_bar()]),
  75. ("mid", [bar_to(60), bar_from_to(60, 80), bar_from_to(60, 100)]),
  76. ("zero", [bar_to(50), bar_from_to(50, 66.66), bar_from_to(50, 83.33)]),
  77. ("mean", [bar_to(50), bar_from_to(50, 66.66), bar_from_to(50, 83.33)]),
  78. (-0.0, [bar_to(50), bar_from_to(50, 66.66), bar_from_to(50, 83.33)]),
  79. (np.nanmedian, [bar_to(50), no_bar(), bar_from_to(50, 62.5)]),
  80. ],
  81. )
  82. @pytest.mark.parametrize("nans", [True, False])
  83. def test_align_mixed_cases(df_mix, align, exp, nans):
  84. # test different align cases for mixed positive and negative values
  85. # also test no impact of NaNs and no_bar
  86. expected = {(0, 0): exp[0], (1, 0): exp[1], (2, 0): exp[2]}
  87. if nans:
  88. df_mix.loc[3, :] = np.nan
  89. expected.update({(3, 0): no_bar()})
  90. result = df_mix.style.bar(align=align)._compute().ctx
  91. assert result == expected
  92. @pytest.mark.parametrize(
  93. "align, exp",
  94. [
  95. (
  96. "left",
  97. {
  98. "index": [[no_bar(), no_bar()], [bar_to(100), bar_to(100)]],
  99. "columns": [[no_bar(), bar_to(100)], [no_bar(), bar_to(100)]],
  100. "none": [[no_bar(), bar_to(33.33)], [bar_to(66.66), bar_to(100)]],
  101. },
  102. ),
  103. (
  104. "mid",
  105. {
  106. "index": [[bar_to(33.33), bar_to(50)], [bar_to(100), bar_to(100)]],
  107. "columns": [[bar_to(50), bar_to(100)], [bar_to(75), bar_to(100)]],
  108. "none": [[bar_to(25), bar_to(50)], [bar_to(75), bar_to(100)]],
  109. },
  110. ),
  111. (
  112. "zero",
  113. {
  114. "index": [
  115. [bar_from_to(50, 66.66), bar_from_to(50, 75)],
  116. [bar_from_to(50, 100), bar_from_to(50, 100)],
  117. ],
  118. "columns": [
  119. [bar_from_to(50, 75), bar_from_to(50, 100)],
  120. [bar_from_to(50, 87.5), bar_from_to(50, 100)],
  121. ],
  122. "none": [
  123. [bar_from_to(50, 62.5), bar_from_to(50, 75)],
  124. [bar_from_to(50, 87.5), bar_from_to(50, 100)],
  125. ],
  126. },
  127. ),
  128. (
  129. 2,
  130. {
  131. "index": [
  132. [bar_to(50), no_bar()],
  133. [bar_from_to(50, 100), bar_from_to(50, 100)],
  134. ],
  135. "columns": [
  136. [bar_to(50), no_bar()],
  137. [bar_from_to(50, 75), bar_from_to(50, 100)],
  138. ],
  139. "none": [
  140. [bar_from_to(25, 50), no_bar()],
  141. [bar_from_to(50, 75), bar_from_to(50, 100)],
  142. ],
  143. },
  144. ),
  145. ],
  146. )
  147. @pytest.mark.parametrize("axis", ["index", "columns", "none"])
  148. def test_align_axis(align, exp, axis):
  149. # test all axis combinations with positive values and different aligns
  150. data = DataFrame([[1, 2], [3, 4]])
  151. result = (
  152. data.style.bar(align=align, axis=None if axis == "none" else axis)
  153. ._compute()
  154. .ctx
  155. )
  156. expected = {
  157. (0, 0): exp[axis][0][0],
  158. (0, 1): exp[axis][0][1],
  159. (1, 0): exp[axis][1][0],
  160. (1, 1): exp[axis][1][1],
  161. }
  162. assert result == expected
  163. @pytest.mark.parametrize(
  164. "values, vmin, vmax",
  165. [
  166. ("positive", 1.5, 2.5),
  167. ("negative", -2.5, -1.5),
  168. ("mixed", -2.5, 1.5),
  169. ],
  170. )
  171. @pytest.mark.parametrize("nullify", [None, "vmin", "vmax"]) # test min/max separately
  172. @pytest.mark.parametrize("align", ["left", "right", "zero", "mid"])
  173. def test_vmin_vmax_clipping(df_pos, df_neg, df_mix, values, vmin, vmax, nullify, align):
  174. # test that clipping occurs if any vmin > data_values or vmax < data_values
  175. if align == "mid": # mid acts as left or right in each case
  176. if values == "positive":
  177. align = "left"
  178. elif values == "negative":
  179. align = "right"
  180. df = {"positive": df_pos, "negative": df_neg, "mixed": df_mix}[values]
  181. vmin = None if nullify == "vmin" else vmin
  182. vmax = None if nullify == "vmax" else vmax
  183. clip_df = df.where(df <= (vmax if vmax else 999), other=vmax)
  184. clip_df = clip_df.where(clip_df >= (vmin if vmin else -999), other=vmin)
  185. result = (
  186. df.style.bar(align=align, vmin=vmin, vmax=vmax, color=["red", "green"])
  187. ._compute()
  188. .ctx
  189. )
  190. expected = clip_df.style.bar(align=align, color=["red", "green"])._compute().ctx
  191. assert result == expected
  192. @pytest.mark.parametrize(
  193. "values, vmin, vmax",
  194. [
  195. ("positive", 0.5, 4.5),
  196. ("negative", -4.5, -0.5),
  197. ("mixed", -4.5, 4.5),
  198. ],
  199. )
  200. @pytest.mark.parametrize("nullify", [None, "vmin", "vmax"]) # test min/max separately
  201. @pytest.mark.parametrize("align", ["left", "right", "zero", "mid"])
  202. def test_vmin_vmax_widening(df_pos, df_neg, df_mix, values, vmin, vmax, nullify, align):
  203. # test that widening occurs if any vmax > data_values or vmin < data_values
  204. if align == "mid": # mid acts as left or right in each case
  205. if values == "positive":
  206. align = "left"
  207. elif values == "negative":
  208. align = "right"
  209. df = {"positive": df_pos, "negative": df_neg, "mixed": df_mix}[values]
  210. vmin = None if nullify == "vmin" else vmin
  211. vmax = None if nullify == "vmax" else vmax
  212. expand_df = df.copy()
  213. expand_df.loc[3, :], expand_df.loc[4, :] = vmin, vmax
  214. result = (
  215. df.style.bar(align=align, vmin=vmin, vmax=vmax, color=["red", "green"])
  216. ._compute()
  217. .ctx
  218. )
  219. expected = expand_df.style.bar(align=align, color=["red", "green"])._compute().ctx
  220. assert result.items() <= expected.items()
  221. def test_numerics():
  222. # test data is pre-selected for numeric values
  223. data = DataFrame([[1, "a"], [2, "b"]])
  224. result = data.style.bar()._compute().ctx
  225. assert (0, 1) not in result
  226. assert (1, 1) not in result
  227. @pytest.mark.parametrize(
  228. "align, exp",
  229. [
  230. ("left", [no_bar(), bar_to(100, "green")]),
  231. ("right", [bar_to(100, "red"), no_bar()]),
  232. ("mid", [bar_to(25, "red"), bar_from_to(25, 100, "green")]),
  233. ("zero", [bar_from_to(33.33, 50, "red"), bar_from_to(50, 100, "green")]),
  234. ],
  235. )
  236. def test_colors_mixed(align, exp):
  237. data = DataFrame([[-1], [3]])
  238. result = data.style.bar(align=align, color=["red", "green"])._compute().ctx
  239. assert result == {(0, 0): exp[0], (1, 0): exp[1]}
  240. def test_bar_align_height():
  241. # test when keyword height is used 'no-repeat center' and 'background-size' present
  242. data = DataFrame([[1], [2]])
  243. result = data.style.bar(align="left", height=50)._compute().ctx
  244. bg_s = "linear-gradient(90deg, #d65f5f 100.0%, transparent 100.0%) no-repeat center"
  245. expected = {
  246. (0, 0): [("width", "10em")],
  247. (1, 0): [
  248. ("width", "10em"),
  249. ("background", bg_s),
  250. ("background-size", "100% 50.0%"),
  251. ],
  252. }
  253. assert result == expected
  254. def test_bar_value_error_raises():
  255. df = DataFrame({"A": [-100, -60, -30, -20]})
  256. msg = "`align` should be in {'left', 'right', 'mid', 'mean', 'zero'} or"
  257. with pytest.raises(ValueError, match=msg):
  258. df.style.bar(align="poorly", color=["#d65f5f", "#5fba7d"]).to_html()
  259. msg = r"`width` must be a value in \[0, 100\]"
  260. with pytest.raises(ValueError, match=msg):
  261. df.style.bar(width=200).to_html()
  262. msg = r"`height` must be a value in \[0, 100\]"
  263. with pytest.raises(ValueError, match=msg):
  264. df.style.bar(height=200).to_html()